├── .devcontainer ├── devcontainer.json └── features │ ├── aws-sam │ ├── devcontainer-feature.json │ └── install.sh │ ├── azure-function │ ├── devcontainer-feature.json │ └── install.sh │ ├── common-deps │ ├── devcontainer-feature.json │ └── install.sh │ ├── hadolint │ ├── devcontainer-feature.json │ └── install.sh │ └── python-binary │ ├── devcontainer-feature.json │ └── install.sh ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── docker.yml │ └── validation.yml ├── .gitignore ├── .hadolint.yml ├── .prettierignore ├── .prettierrc.yml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .yarnrc.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── docs ├── Azure-Install.md └── Developing.md ├── esbuild.config.mjs ├── eslint.config.mjs ├── jest.config.mjs ├── package.json ├── packages ├── app │ ├── esbuild.config.mjs │ ├── jest.config.mjs │ ├── package.json │ ├── src │ │ ├── app.ts │ │ ├── config │ │ │ ├── index.ts │ │ │ ├── process.d.ts │ │ │ └── types.ts │ │ ├── events │ │ │ ├── approvingTeam.ts │ │ │ ├── codeScanningAlertDismissed.ts │ │ │ ├── dependabotAlertDismissed.ts │ │ │ ├── secretScanningAlertDismissed.ts │ │ │ └── types.ts │ │ ├── handler.ts │ │ └── index.ts │ ├── test │ │ ├── app.test.ts │ │ ├── config │ │ │ ├── config.test.ts │ │ │ └── index.test.ts │ │ ├── events │ │ │ ├── approvingTeam.test.ts │ │ │ ├── codeScanningAlertDismissed.test.ts │ │ │ ├── dependabotAlertDismissed.test.ts │ │ │ └── secretScanningAlertDismissed.test.ts │ │ ├── fixtures │ │ │ ├── code_scanning_alert │ │ │ │ └── closed_by_user.json │ │ │ ├── dependabot_alert │ │ │ │ ├── dismissed.json │ │ │ │ ├── fixed.json │ │ │ │ ├── reintroduced.json │ │ │ │ └── reopened.json │ │ │ ├── installation │ │ │ │ ├── created.json │ │ │ │ └── new_permissions_accepted.json │ │ │ ├── installation_repositories │ │ │ │ └── added.json │ │ │ ├── mock-cert.pem │ │ │ └── secret_scanning_alert │ │ │ │ ├── created.json │ │ │ │ ├── resolved.pattern_deleted.json │ │ │ │ ├── resolved.pattern_edited.json │ │ │ │ └── resolved.wont_fix.json │ │ ├── handler.test.ts │ │ └── utils │ │ │ ├── helpers.ts │ │ │ └── services.ts │ └── tsconfig.json ├── aws │ ├── esbuild.config.mjs │ ├── jest.config.mjs │ ├── package.json │ ├── samconfig.toml │ ├── src │ │ └── index.ts │ ├── template.yml │ ├── test │ │ ├── fixtures │ │ │ ├── env.emulator.json │ │ │ ├── event-template.json │ │ │ ├── event.body.txt │ │ │ ├── event.headers.txt │ │ │ └── event.json │ │ ├── index.test.ts │ │ ├── integration.test.ts │ │ └── utils │ │ │ ├── .env.emulator.json │ │ │ ├── emulator.ts │ │ │ ├── fixtures.ts │ │ │ ├── mockserver.ts │ │ │ └── spawn.ts │ └── tsconfig.json ├── azure │ ├── .funcignore │ ├── .gitignore │ ├── Dockerfile │ ├── Dockerfile.dockerignore │ ├── esbuild.config.mjs │ ├── host.json │ ├── iac │ │ └── function.bicep │ ├── package.json │ ├── setup │ │ ├── _common.sh │ │ ├── deploy-function.sh │ │ ├── provision-and-deploy.sh │ │ ├── provision-resources.sh │ │ └── update-app-webhookurl.sh │ ├── src │ │ └── index.mts │ └── tsconfig.json └── server │ ├── .env.example │ ├── Dockerfile │ ├── Dockerfile.dockerignore │ ├── app.yml │ ├── esbuild.config.mjs │ ├── package.json │ ├── src │ ├── index.ts │ └── server.ts │ ├── test │ └── server.test.ts │ └── tsconfig.json ├── scripts ├── copyEnv.mjs ├── hadolint-matcher.json ├── lintDocker.sh └── updateAppWebHookUrl.mjs ├── tsconfig.base.json ├── tsconfig.json ├── utils ├── cjs-shim.ts └── esm-loader.js └── yarn.lock /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.238.0/containers/typescript-node 3 | { 4 | "name": "Probot", 5 | "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", 6 | "remoteEnv": { 7 | "SAM_CLI_TELEMETRY": "0", 8 | "FUNCTIONS_CORE_TOOLS_TELEMETRY_OPTOUT": "true" 9 | }, 10 | "customizations": { 11 | "vscode": { 12 | "extensions": [ 13 | "arcanis.vscode-zipfs", 14 | "dbaeumer.vscode-eslint", 15 | "esbenp.prettier-vscode", 16 | "EditorConfig.EditorConfig", 17 | "github.vscode-github-actions", 18 | "mhutchie.git-graph", 19 | "ms-azuretools.vscode-azurefunctions", 20 | "Orta.vscode-jest", 21 | "redhat.vscode-yaml" 22 | ], 23 | "settings": { 24 | "aws.telemetry": false, 25 | "jest.virtualFolders": [ 26 | { 27 | "name": "app", 28 | "rootPath": "./packages/app" 29 | }, 30 | { 31 | "name": "server", 32 | "rootPath": "./packages/server" 33 | }, 34 | { 35 | "name": "aws", 36 | "rootPath": "./packages/aws" 37 | } 38 | ], 39 | "jest.runMode": { 40 | "type": "on-demand", 41 | "deferred": true 42 | }, 43 | "search.exclude": { 44 | "**/.yarn": true, 45 | "**/.pnp.*": true 46 | }, 47 | "typescript.tsdk": ".yarn/sdks/typescript/lib", 48 | "typescript.enablePromptUseWorkspaceTsdk": true, 49 | "eslint.nodePath": ".yarn/sdks", 50 | "eslint.validate": ["javascript", "typescript"], 51 | "eslint.enable": true, 52 | "eslint.options": {}, 53 | "eslint.useFlatConfig": true, 54 | "eslint.workingDirectories": [ 55 | { 56 | "mode": "auto" 57 | } 58 | ], 59 | "azureFunctions.deploySubpath": ".", 60 | //"azureFunctions.postDeployTask": "", 61 | "azureFunctions.projectLanguage": "TypeScript", 62 | "azureFunctions.projectRuntime": "~4", 63 | "debug.internalConsoleOptions": "neverOpen", 64 | //"azureFunctions.preDeployTask": "Install Dependencies", 65 | "jest.jestCommandLine": "yarn run test", 66 | "typescript.tsserver.log": "off", 67 | "typescript.tsserver.nodePath": "node", 68 | "js/ts.implicitProjectConfig.module": "ESNext", 69 | // Workaround for https://github.com/yarnpkg/berry/issues/6270 70 | "typescript.tsserver.experimental.useVsCodeWatcher": false, 71 | "prettier.prettierPath": ".yarn/sdks/prettier/index.cjs", 72 | "[javascript]": { 73 | "editor.defaultFormatter": "esbenp.prettier-vscode" 74 | }, 75 | "[jsonc]": { 76 | "editor.defaultFormatter": "vscode.json-language-features" 77 | }, 78 | "[typescript]": { 79 | "editor.defaultFormatter": "esbenp.prettier-vscode" 80 | }, 81 | "[json]": { 82 | "editor.defaultFormatter": "esbenp.prettier-vscode" 83 | }, 84 | "[yaml]": { 85 | "editor.defaultFormatter": "esbenp.prettier-vscode" 86 | } 87 | } 88 | } 89 | }, 90 | "onCreateCommand": { 91 | "setup-yarn": "corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack install --global yarn@stable" 92 | }, 93 | "updateContentCommand": { 94 | "install-packages": "COREPACK_ENABLE_DOWNLOAD_PROMPT=0 yarn install && yarn dlx @yarnpkg/sdks base" 95 | }, 96 | "postAttachCommand": { 97 | "configure-git": "[ \"$(git config --global --get safe.directory)\" = '${containerWorkspaceFolder}' ] || git config --global --add safe.directory '${containerWorkspaceFolder}'" 98 | }, 99 | "remoteUser": "vscode", 100 | "portsAttributes": { 101 | "3000": { 102 | "label": "Probot" 103 | }, 104 | "3001": { 105 | "label": "Emulator" 106 | }, 107 | "5555": { 108 | "label": "Mockserver 1", 109 | "protocol": "http", 110 | "onAutoForward": "silent" 111 | }, 112 | "5556": { 113 | "label": "Mockserver 2", 114 | "protocol": "http", 115 | "onAutoForward": "silent" 116 | }, 117 | "10000": { 118 | "label": "Azurite Storage Emulator" 119 | } 120 | }, 121 | "features": { 122 | "./features/hadolint": {}, 123 | "./features/aws-sam": {}, 124 | "./features/azure-function": {}, 125 | "ghcr.io/devcontainers/features/aws-cli:1": {}, 126 | "ghcr.io/devcontainers/features/azure-cli:1": { 127 | "installBicep": true 128 | }, 129 | "ghcr.io/devcontainers/features/docker-in-docker:2": {}, 130 | "ghcr.io/devcontainers/features/node:1": { 131 | "version": "22.14.0", 132 | "nvmVersion": "latest" 133 | }, 134 | "./features/python-binary": { 135 | "version": "3.13" 136 | } 137 | }, 138 | "otherPortsAttributes": { 139 | "onAutoForward": "ignore" 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /.devcontainer/features/aws-sam/devcontainer-feature.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "local.aws-sam", 3 | "version": "1.0.0", 4 | "name": "AWS SAM", 5 | "description": "Installs AWS SAM tools", 6 | "dependsOn": { 7 | "./features/common-deps": {} 8 | }, 9 | "containerEnv": { 10 | "AWS_SAM_CLI_TELEMETRY": "0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.devcontainer/features/aws-sam/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | if [ "$(id -u)" -ne "0" ]; then echo "Must be run as root or with sudo"; exit 1; fi 6 | 7 | PROCESSOR_ARCHITECTURE=$(uname -m) 8 | if [ "${PROCESSOR_ARCHITECTURE}" == "arm64" ] || [ "${PROCESSOR_ARCHITECTURE}" == "aarch64" ]; then 9 | declare -r PLATFORM=arm64 10 | else 11 | declare -r PLATFORM=x86_64 12 | fi 13 | 14 | echo " ********* Installing AWS SAM CLI ********* " 15 | if ! command -v sam &> /dev/null; then 16 | rm -rf /tmp/sam >/dev/null 17 | curl --no-progress-meter -sSLfo /tmp/sam-cli.zip https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-${PLATFORM}.zip 18 | unzip -qq /tmp/sam-cli.zip -d /tmp/sam 19 | sudo /tmp/sam/install 20 | rm -rf /tmp/sam-cli 21 | rm /tmp/sam-cli.zip 22 | fi 23 | -------------------------------------------------------------------------------- /.devcontainer/features/azure-function/devcontainer-feature.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "local.azure-function", 3 | "version": "1.0.0", 4 | "name": "Azure Function", 5 | "description": "Installs Azure Function Core Tools", 6 | "options": { 7 | "version": { 8 | "type": "string", 9 | "proposals": ["latest", "4.0.7030"], 10 | "default": "latest", 11 | "description": "Select or enter the tools version." 12 | } 13 | }, 14 | "dependsOn": { 15 | "./features/common-deps": {}, 16 | "ghcr.io/devcontainers/features/dotnet:2": { 17 | "version": "8.0.300" 18 | } 19 | }, 20 | "containerEnv": { 21 | "AZURE_FUNC_TOOLS_DIR": "/lib/azure-functions-core-tools-4", 22 | "PATH": "/lib/azure-functions-core-tools-4:${PATH}", 23 | "FUNCTIONS_CORE_TOOLS_TELEMETRY_OPTOUT": "true" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.devcontainer/features/azure-function/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | if [ "$(id -u)" -ne "0" ]; then echo "Must be run as root or with sudo"; exit 1; fi 6 | export DEBIAN_FRONTEND=noninteractive 7 | UPDATE_RC=${UPDATE_RC:-"true"} 8 | AZURE_FUNC_TOOLS_DIR=${AZURE_FUNC_TOOLS_DIR:-"/lib/azure-functions-core-tools-4"} 9 | USERNAME=${USERNAME:-"automatic"} 10 | VERSION=${VERSION:-"latest"} 11 | TARGET_SDK=net8.0 12 | BUILD_NUMBER="9999" 13 | 14 | updaterc() { 15 | if [ "${UPDATE_RC}" = "true" ]; then 16 | echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..." 17 | if [[ "$(cat /etc/bash.bashrc)" != *"$1"* ]]; then 18 | echo -e "$1" >> /etc/bash.bashrc 19 | fi 20 | if [ -f "/etc/zsh/zshrc" ] && [[ "$(cat /etc/zsh/zshrc)" != *"$1"* ]]; then 21 | echo -e "$1" >> /etc/zsh/zshrc 22 | fi 23 | fi 24 | } 25 | 26 | # Ensure that login shells get the correct path if the user updated the PATH using ENV. 27 | rm -f /etc/profile.d/00-restore-env.sh 28 | echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh 29 | chmod +x /etc/profile.d/00-restore-env.sh 30 | 31 | PROCESSOR_ARCHITECTURE=$(uname -m) 32 | if [ "${PROCESSOR_ARCHITECTURE}" == "arm64" ] || [ "${PROCESSOR_ARCHITECTURE}" == "aarch64" ]; then 33 | declare -r PLATFORM=arm64 34 | declare -r RID_PLATFORM=arm64 35 | else 36 | declare -r PLATFORM=amd64 37 | declare -r RID_PLATFORM=x64 38 | fi 39 | 40 | if [ "${PLATFORM}" == "arm64" ] || [ "${VERSION}" != "latest" ]; then 41 | ## Create a temporary space 42 | FXN_BUILD_ROOT_DIR=`mktemp -d` 43 | cd "${FXN_BUILD_ROOT_DIR}" 44 | 45 | ## Identify the correct version of the code 46 | if [ "${VERSION}" == "latest" ]; then 47 | BRANCH=v4.x 48 | else 49 | BRANCH=${VERSION} 50 | 51 | ## Older versions relied on net6.0 52 | if [ "${VERSION:0:3}" == "4.0" ] && [ ${VERSION:4} -lt 5802 ]; then 53 | TARGET_SDK=net6.0 54 | BUILD_NUMBER="${VERSION:4}" 55 | fi 56 | fi 57 | 58 | ## Download the source code for the requested version (branch/tag) 59 | git clone --depth 1 --branch "${BRANCH}" https://github.com/Azure/azure-functions-core-tools 60 | 61 | ## Build the project 62 | cd azure-functions-core-tools/src/Azure.Functions.Cli/ 63 | ${DOTNET_ROOT}/dotnet publish -r "linux-${RID_PLATFORM}" -c Release -f "${TARGET_SDK}" --self-contained=true /p:BuildNumber="${BUILD_NUMBER}" 64 | 65 | ## Clear the nuget caches to reduce the image size several from 17GB to 6GB 66 | ${DOTNET_ROOT}/dotnet nuget locals all --clear 67 | 68 | ## Copy the binaries for the tool 69 | [ -d "${AZURE_FUNC_TOOLS_DIR}" ] && rm -rf "${AZURE_FUNC_TOOLS_DIR}" 70 | mkdir -p "${AZURE_FUNC_TOOLS_DIR}" 71 | mv bin/Release/${TARGET_SDK}/linux-${RID_PLATFORM}/publish/* "${AZURE_FUNC_TOOLS_DIR}" 72 | 73 | ## Cleanup the source code/build 74 | rm -rf "${FXN_BUILD_ROOT_DIR}" 75 | 76 | ## Update the environment variable 77 | updaterc "export AZURE_FUNC_TOOLS_DIR=${AZURE_FUNC_TOOLS_DIR}" 78 | else 79 | sudo apt-get update 80 | sudo apt-get install -y azure-functions-core-tools-4 81 | fi 82 | -------------------------------------------------------------------------------- /.devcontainer/features/common-deps/devcontainer-feature.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "local.common-deps", 3 | "version": "1.0.0", 4 | "name": "Linux Tools", 5 | "description": "Installs core Linux tools" 6 | } 7 | -------------------------------------------------------------------------------- /.devcontainer/features/common-deps/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | if [ "$(id -u)" -ne "0" ]; then echo "Must be run as root or with sudo"; exit 1; fi 6 | export DEBIAN_FRONTEND=noninteractive 7 | apt-get update -qq 8 | apt-get install -y -qq apt-transport-https ca-certificates curl tar unzip software-properties-common > /dev/null 9 | 10 | # Clean up 11 | apt clean 12 | rm -rf /var/lib/apt/lists/* 13 | -------------------------------------------------------------------------------- /.devcontainer/features/hadolint/devcontainer-feature.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "local.hadolint", 3 | "version": "1.0.0", 4 | "name": "Hadolint", 5 | "description": "Installs hadolint", 6 | "dependsOn": { 7 | "./features/common-deps": {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.devcontainer/features/hadolint/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | if [ "$(id -u)" -ne "0" ]; then echo "Must be run as root or with sudo"; exit 1; fi 6 | export DEBIAN_FRONTEND=noninteractive 7 | PROCESSOR_ARCHITECTURE=$(uname -m) 8 | if [ "${PROCESSOR_ARCHITECTURE}" == "arm64" ] || [ "${PROCESSOR_ARCHITECTURE}" == "aarch64" ]; then 9 | declare -r PLATFORM=arm64 10 | else 11 | declare -r PLATFORM=x86_64 12 | fi 13 | 14 | declare -r HADOLINT_VERSION=$(curl -s https://api.github.com/repos/hadolint/hadolint/releases/latest | grep '"tag_name":' | sed -E 's/[^:]+:\ \"v([^\"]+).+/\1/') 15 | echo " ******* Installing Hadolint v${HADOLINT_VERSION} (${PLATFORM}) ******* " 16 | declare -r filepath="/usr/local/bin/hadolint" 17 | if ! [ -f filepath ]; then 18 | curl --no-progress-meter -sSLfo $filepath https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-Linux-${PLATFORM} 19 | chmod +x $filepath 20 | fi 21 | -------------------------------------------------------------------------------- /.devcontainer/features/python-binary/devcontainer-feature.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "local.python-binary", 3 | "version": "1.0.0", 4 | "name": "Python Binary Update (Deadsnakes PPA)", 5 | "description": "Installs prebuilt Python version from the deadsnakes PPA", 6 | "options": { 7 | "version": { 8 | "type": "string", 9 | "proposals": ["3.12", "3.13", "3.14"], 10 | "default": "3.13", 11 | "description": "Select or enter the Python version." 12 | } 13 | }, 14 | "dependsOn": { 15 | "./features/common-deps": {} 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.devcontainer/features/python-binary/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | VERSION=${VERSION:-"3.13"} 6 | 7 | if [ "$(id -u)" -ne "0" ]; then echo "Must be run as root or with sudo"; exit 1; fi 8 | export DEBIAN_FRONTEND=noninteractive 9 | 10 | add-apt-repository ppa:deadsnakes/ppa 11 | apt-get update -qq 12 | apt-get install -y -qq python${VERSION} > /dev/null 13 | 14 | # Clean up 15 | apt clean 16 | rm -rf /var/lib/apt/lists/* 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # This restricts certain files from being copied into 2 | # the build target for the multi-stage build. The main 3 | # image selectively includes a subset of files. 4 | 5 | **/.devcontainer 6 | **/.dockerignore 7 | **/.editorconfig 8 | **/.env 9 | **/.env.example 10 | **/.git 11 | **/.github 12 | **/.gitattributes 13 | **/.gitignore 14 | **/.vscode 15 | **/*.log 16 | **/coverage 17 | **/dist 18 | **/Dockerfile* 19 | **/LICENSE 20 | **/node_modules 21 | **/*.md 22 | **/*.example 23 | **/.yarn 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | 9 | [*.{ts,json,js,dockerignore,editorconfig,cjs,mjs,cts}] 10 | insert_final_newline = true 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [Dockerfile] 15 | insert_final_newline = true 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.sh] 20 | end_of_line = lf 21 | indent_style = tab 22 | 23 | [*.yml] 24 | indent_style = space 25 | indent_size = 2 26 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically normalize line endings. 2 | * text=auto 3 | 4 | # Windows files require CRLF 5 | *.[cC][mM][dD] text eol=crlf 6 | *.[bB][aA][tT] text eol=crlf 7 | 8 | # Bash scripts require LF 9 | *.sh text eol=lf 10 | 11 | # SVG files are binary by default 12 | *.svg text 13 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # For more information, see [docs](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-syntax) 2 | 3 | # This repository is maintained by: 4 | * @kenmuse -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: npm 9 | directory: '/' 10 | schedule: 11 | interval: monthly 12 | ignore: 13 | - dependency-name: '@types/express' 14 | update-types: ['version-update:semver-major'] 15 | - dependency-name: '@types/node' 16 | update-types: ['version-update:semver-major'] 17 | groups: 18 | dev-dependencies: 19 | dependency-type: development 20 | applies-to: version-updates 21 | update-types: 22 | - minor 23 | - patch 24 | prod-dependencies: 25 | dependency-type: production 26 | applies-to: version-updates 27 | update-types: 28 | - minor 29 | - patch 30 | 31 | - package-ecosystem: 'github-actions' 32 | directory: '/' 33 | schedule: 34 | interval: 'monthly' 35 | groups: 36 | actions-all: 37 | applies-to: version-updates 38 | update-types: 39 | - minor 40 | - patch 41 | 42 | - package-ecosystem: 'devcontainers' # See documentation for possible values 43 | directory: '/' 44 | schedule: 45 | interval: weekly 46 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | # schedule: 10 | # - cron: '25 19 * * *' 11 | # push: 12 | # branches: [ "main" ] 13 | # # Publish semver tags as releases. 14 | # tags: [ 'v*.*.*' ] 15 | # push: 16 | # branches: ['vNext'] 17 | # pull_request: 18 | # branches: ['vNext'] 19 | workflow_dispatch: 20 | 21 | env: 22 | # Use docker.io for Docker Hub if empty 23 | REGISTRY: ghcr.io 24 | # github.repository as / 25 | IMAGE_NAME: ${{ github.repository }}-server 26 | 27 | permissions: 28 | contents: read 29 | 30 | jobs: 31 | build: 32 | runs-on: ubuntu-latest 33 | permissions: 34 | contents: read 35 | packages: write 36 | # This is used to complete the identity challenge 37 | # with sigstore/fulcio when running outside of PRs. 38 | id-token: write 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 43 | 44 | # Install the cosign tool except on PR 45 | # https://github.com/sigstore/cosign-installer 46 | - name: Install cosign 47 | if: ${{ github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }} 48 | uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a #v3.8.1 49 | with: 50 | cosign-release: 'v2.4.3' 51 | 52 | - name: Set up QEMU 53 | uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 #v3.6.0 54 | 55 | - name: Extract versions 56 | run: | 57 | NODE_VERSION=$(cat .devcontainer/devcontainer.json | sed 's/^ *\/\/.*//' | jq -r '.features."ghcr.io/devcontainers/features/node:1".version' ) 58 | NODE_MAJOR=$(cat .devcontainer/devcontainer.json | sed 's/^ *\/\/.*//' | jq -r '.features."ghcr.io/devcontainers/features/node:1".version | split(".") | map(tonumber) | .[0]') 59 | PACKAGE_VERSION=$(cat package.json | jq -r '.version') 60 | echo "NODE_VERSION=${NODE_VERSION}" >> $GITHUB_ENV 61 | echo "NODE_MAJOR=${NODE_MAJOR}" >> $GITHUB_ENV 62 | echo "PACKAGE_VERSION=${PACKAGE_VERSION}" >> $GITHUB_ENV 63 | echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV 64 | 65 | # Set up BuildKit Docker container builder to be able to build 66 | # multi-platform images and export cache 67 | # https://github.com/docker/setup-buildx-action 68 | - name: Set up Docker Buildx 69 | uses: ocker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 70 | 71 | # Login against a Docker registry except on PR 72 | # https://github.com/docker/login-action 73 | - name: Log into registry ${{ env.REGISTRY }} 74 | if: ${{ github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }} 75 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 76 | with: 77 | registry: ${{ env.REGISTRY }} 78 | username: ${{ github.actor }} 79 | password: ${{ secrets.GITHUB_TOKEN }} 80 | 81 | # Extract metadata (tags, labels) for Docker 82 | # https://github.com/docker/metadata-action 83 | - name: Extract Docker metadata 84 | id: meta 85 | uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 86 | with: 87 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 88 | tags: | 89 | type=raw,value=${{ env.PACKAGE_VERSION}}-node${{ env.NODE_MAJOR }} 90 | env: 91 | DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index 92 | 93 | # Build and push Docker image with Buildx (don't push on PR) 94 | # https://github.com/docker/build-push-action 95 | - name: Build and push Docker image 96 | id: build-and-push 97 | uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0 98 | with: 99 | context: . 100 | sbom: true 101 | build-args: | 102 | NODE_VERSION=${{ env.NODE_VERSION }} 103 | provenance: mode=max 104 | platforms: linux/amd64,linux/arm64 105 | annotations: ${{ steps.meta.outputs.annotations }} 106 | file: '${{ github.workspace }}/packages/server/Dockerfile' 107 | push: ${{ github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }} 108 | tags: ${{ steps.meta.outputs.tags }} 109 | labels: ${{ steps.meta.outputs.labels }} 110 | cache-from: type=gha 111 | cache-to: type=gha,mode=max 112 | 113 | # Sign the resulting Docker image digest except on PRs. 114 | # This will only write to the public Rekor transparency log when the Docker 115 | # repository is public to avoid leaking data. If you would like to publish 116 | # transparency data even for private images, pass --force to cosign below. 117 | # https://github.com/sigstore/cosign 118 | - name: Sign the published Docker image 119 | if: ${{ github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }} 120 | env: 121 | # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable 122 | TAGS: ${{ steps.meta.outputs.tags }} 123 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 124 | # This step uses the identity token to provision an ephemeral certificate 125 | # against the sigstore community Fulcio instance. 126 | run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} 127 | -------------------------------------------------------------------------------- /.github/workflows/validation.yml: -------------------------------------------------------------------------------- 1 | #yaml-language-server: $schema=https://json.schemastore.org/github-workflow 2 | name: Validation 3 | 4 | on: 5 | push: 6 | branches: ['main'] 7 | pull_request: 8 | branches: ['main', 'kenmuse/vNext'] 9 | 10 | env: 11 | SAM_CLI_TELEMETRY: 0 12 | FUNCTIONS_CORE_TOOLS_TELEMETRY_OPTOUT: true 13 | 14 | jobs: 15 | Validate: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Get target runtime versions 21 | run: | 22 | NODE_VERSION=$(cat .devcontainer/devcontainer.json | sed 's/^ *\/\/.*//' | jq -r '.features["ghcr.io/devcontainers/features/node:1"].version') 23 | echo "NODE_VERSION=$NODE_VERSION" >> $GITHUB_ENV 24 | PYTHON_VERSION=$(cat .devcontainer/devcontainer.json | sed 's/^ *\/\/.*//' | jq -r '.features["./features/python-binary"].version') 25 | echo "PYTHON_VERSION=$PYTHON_VERSION" >> $GITHUB_ENV 26 | - name: Install Hadolint 27 | run: sudo ./.devcontainer/features/hadolint/install.sh 28 | - name: Configure Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ env.NODE_VERSION }} 32 | - name: Configure Python 33 | uses: actions/setup-python@v4 34 | with: 35 | python-version: ${{ env.PYTHON_VERSION }} 36 | - name: Enable corepack 37 | run: corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 yarn set version berry 38 | # Ensures we are using latest yarn after setting corepack (for the cache) 39 | - name: Configure Node.js with cache 40 | uses: actions/setup-node@v4 41 | with: 42 | node-version: ${{ env.NODE_VERSION }} 43 | cache: 'yarn' 44 | - name: Install dependencies 45 | run: yarn install --immutable 46 | - name: Build 47 | run: yarn run build 48 | - name: Lint package code 49 | run: yarn run lint 50 | - run: yarn run test --coverage --testLocationInResults --json --outputFile coverage/report.json 51 | - uses: ArtiomTr/jest-coverage-report-action@v2 52 | if: ${{ github.actor != 'dependabot[bot]' }} 53 | with: 54 | skip-step: all 55 | custom-title: 'Coverage: `packages/app`' 56 | coverage-file: packages/app/coverage/report.json 57 | base-coverage-file: packages/app/coverage/report.json 58 | - uses: ArtiomTr/jest-coverage-report-action@v2 59 | if: ${{ github.actor != 'dependabot[bot]' }} 60 | with: 61 | skip-step: all 62 | custom-title: 'Coverage: `packages/server`' 63 | coverage-file: packages/server/coverage/report.json 64 | base-coverage-file: packages/server/coverage/report.json 65 | - uses: ArtiomTr/jest-coverage-report-action@v2 66 | if: ${{ github.actor != 'dependabot[bot]' }} 67 | with: 68 | skip-step: all 69 | custom-title: 'Coverage: `packages/aws`' 70 | coverage-file: packages/aws/coverage/report.json 71 | base-coverage-file: packages/aws/coverage/report.json 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | *.pem 4 | !mock-cert.pem 5 | .env 6 | coverage 7 | dist 8 | *.log 9 | .aws-sam 10 | *.tsbuildinfo 11 | .env.json 12 | local.settings.json 13 | .pnp.cjs 14 | .pnp.loader.mjs 15 | .yarn 16 | package.tgz 17 | package.zip 18 | publish 19 | 20 | #### Apple 21 | *.DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | Icon 25 | ._* 26 | .DocumentRevisions-V100 27 | .fseventsd 28 | .Spotlight-V100 29 | .TemporaryItems 30 | .Trashes 31 | .VolumeIcon.icns 32 | .com.apple.timemachine.donotpresent 33 | -------------------------------------------------------------------------------- /.hadolint.yml: -------------------------------------------------------------------------------- 1 | format: tty 2 | trustedRegistries: 3 | - mcr.microsoft.com 4 | - ghcr.io 5 | - docker.io 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore these files: 2 | **/dist 3 | **/coverage 4 | **/.aws-sam 5 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | printWidth: 80 2 | semi: true 3 | singleQuote: true 4 | trailingComma: 'none' 5 | bracketSpacing: false 6 | arrowParens: 'avoid' 7 | endOfLine: auto 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Probot Server", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeExecutable": "yarn", 12 | "runtimeArgs": ["run", "start"], 13 | "cwd": "${workspaceRoot}/packages/server", 14 | "internalConsoleOptions": "openOnSessionStart", 15 | "outputCapture": "std", 16 | "sourceMaps": true 17 | }, 18 | { 19 | "type": "aws-sam", 20 | "request": "direct-invoke", 21 | "name": "Debug AWS Lambda", 22 | "invokeTarget": { 23 | "target": "template", 24 | "templatePath": "${workspaceRoot}/packages/aws/template.yml", 25 | "logicalId": "SecurityWatcher", 26 | "projectRoot": "${workspaceRoot}/packages/aws" 27 | }, 28 | "lambda": { 29 | "payload": {}, 30 | "environmentVariables": {} 31 | }, 32 | "sam": { 33 | "localArguments": [ 34 | "--container-env-vars", 35 | "${workspaceRoot}/packages/aws/.env.json" 36 | ] 37 | }, 38 | "preLaunchTask": "Setup Lambda Env" 39 | }, 40 | { 41 | "name": "Attach to Azure Function", 42 | "type": "node", 43 | "request": "attach", 44 | "port": 9229, 45 | "preLaunchTask": "func: host start" 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "jest.virtualFolders": [ 3 | { 4 | "name": "app", 5 | "rootPath": "./packages/app" 6 | }, 7 | { 8 | "name": "server", 9 | "rootPath": "./packages/server" 10 | }, 11 | { 12 | "name": "aws", 13 | "rootPath": "./packages/aws" 14 | } 15 | ], 16 | "jest.runMode": { 17 | "type": "on-demand", 18 | "deferred": true 19 | }, 20 | "search.exclude": { 21 | "**/.yarn": true, 22 | "**/.pnp.*": true 23 | }, 24 | "typescript.tsdk": ".yarn/sdks/typescript/lib", 25 | "eslint.nodePath": ".yarn/sdks", 26 | "eslint.validate": ["javascript", "typescript"], 27 | "eslint.enable": true, 28 | "eslint.options": {}, 29 | "eslint.useFlatConfig": true, 30 | "eslint.workingDirectories": [ 31 | { 32 | "mode": "auto" 33 | } 34 | ], 35 | "typescript.enablePromptUseWorkspaceTsdk": true, 36 | "jest.jestCommandLine": "yarn run test", 37 | "typescript.tsserver.log": "off", 38 | "typescript.tsserver.nodePath": "node", 39 | "js/ts.implicitProjectConfig.module": "ESNext", 40 | "typescript.tsserver.experimental.useVsCodeWatcher": false, 41 | "prettier.prettierPath": ".yarn/sdks/prettier/index.cjs", 42 | "[javascript]": { 43 | "editor.defaultFormatter": "esbenp.prettier-vscode" 44 | }, 45 | "[jsonc]": { 46 | "editor.defaultFormatter": "vscode.json-language-features" 47 | }, 48 | "[typescript]": { 49 | "editor.defaultFormatter": "esbenp.prettier-vscode" 50 | }, 51 | "[json]": { 52 | "editor.defaultFormatter": "esbenp.prettier-vscode" 53 | }, 54 | "[yaml]": { 55 | "editor.defaultFormatter": "esbenp.prettier-vscode" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Install Dependencies", 6 | "command": "yarn", 7 | "args": ["install"], 8 | "group": { 9 | "kind": "build", 10 | "isDefault": false 11 | } 12 | }, 13 | { 14 | "label": "Build", 15 | "command": "yarn", 16 | "args": ["run", "build"], 17 | "group": { 18 | "kind": "build", 19 | "isDefault": true 20 | }, 21 | "dependsOn": ["Install Dependencies"] 22 | }, 23 | { 24 | "label": "Setup Lambda Env", 25 | "options": { 26 | "cwd": "${workspaceRoot}/packages/aws" 27 | }, 28 | "command": "yarn", 29 | "args": ["run", "copyEnv"], 30 | "group": { 31 | "kind": "build", 32 | "isDefault": false 33 | } 34 | }, 35 | { 36 | "label": "Test", 37 | "command": "yarn", 38 | "args": ["run", "test"], 39 | "group": { 40 | "kind": "test", 41 | "isDefault": true 42 | }, 43 | "dependsOn": ["Build"] 44 | }, 45 | { 46 | "type": "func", 47 | "label": "func: host start", 48 | "options": { 49 | "cwd": "${workspaceRoot}/packages/azure" 50 | }, 51 | "command": "host start", 52 | "problemMatcher": "$func-node-watch", 53 | "isBackground": true, 54 | "dependsOn": ["func: build"] 55 | }, 56 | { 57 | "label": "func: build", 58 | "options": { 59 | "cwd": "${workspaceRoot}/packages/azure" 60 | }, 61 | "command": "yarn", 62 | "args": ["run", "build"], 63 | "isBackground": true 64 | }, 65 | { 66 | "label": "Run hadolint", 67 | "command": "hadolint", 68 | "args": ["--no-fail", "${workspaceFolder}/server/Dockerfile"], 69 | "problemMatcher": { 70 | "owner": "dockerfile", 71 | "fileLocation": ["absolute"], 72 | "pattern": { 73 | "regexp": "^([^:]+)\\:(\\d+) ((?:DL|SC)\\d{4}) ([^:]+): (.*)$", 74 | "file": 1, 75 | "line": 2, 76 | "code": 3, 77 | "severity": 4, 78 | "message": 5 79 | } 80 | } 81 | } 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | changesetIgnorePatterns: 2 | - .github/** 3 | - .yarn/** 4 | 5 | compressionLevel: mixed 6 | 7 | enableGlobalCache: true 8 | 9 | nodeLinker: pnp 10 | 11 | packageExtensions: 12 | probot@*: 13 | dependencies: 14 | smee-client: "*" 15 | 16 | patchFolder: ./.yarn/patches 17 | 18 | pnpEnableEsmLoader: true 19 | 20 | pnpUnpluggedFolder: ./.yarn/unplugged 21 | 22 | preferInteractive: true 23 | 24 | supportedArchitectures: 25 | cpu: 26 | - current 27 | - x64 28 | - arm64 29 | os: 30 | - current 31 | - darwin 32 | - linux 33 | - win32 34 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@github.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: https://github.com/advanced-security/probot-security-alerts/fork 4 | [pr]: https://github.com/advanced-security/probot-security-alerts/compare 5 | [code-of-conduct]: CODE_OF_CONDUCT.md 6 | 7 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 8 | 9 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE.md). 10 | 11 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 12 | 13 | ## Prerequisites for running and testing code 14 | 15 | There are no software installations required to be able to test your changes locally as part of the pull request (PR) submission process. Instead, a development container](https://docs.github.com/en/codespaces/setting-up-your-project-for-codespaces/adding-a-dev-container-configuration/introduction-to-dev-containers) is included which contains the required environment. The container is compatible with Visual Studio Code and GitHub Codespaces. To develop locally without a container, the project requires [Node.js 18](https://nodejs.org/en/download/). 16 | 17 | ## Submitting a pull request 18 | 19 | 1. [Fork][fork] and clone the repository 20 | 1. Configure and install the dependencies: `npm i` 21 | 1. Make sure the tests pass on your machine: `npm test` 22 | 1. Make sure linter passes on your machine: `npm run lint` 23 | 1. Lint any Dockerfile changes: `npm run lint:docker` 24 | 1. Create a new branch: `git checkout -b my-branch-name` 25 | 1. Make your change, add tests, and make sure the tests and linter still pass 26 | 1. Push to your fork and [submit a pull request][pr] 27 | 1. Pat your self on the back and wait for your pull request to be reviewed and merged. 28 | 29 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 30 | 31 | - Write tests. 32 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 33 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 34 | 35 | ## Resources 36 | 37 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 38 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 39 | - [GitHub Help](https://help.github.com) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 - 2023 GitHub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | GitHub takes the security of our software products and services seriously, including all of the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub). 4 | 5 | [Open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope) and therefore not eligible for bounty rewards. 6 | 7 | This repository is archived and deprecated. It is no longer being updated, supported, or maintained by GitHub staff. 8 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | The Probot Security Alert Watcher is archived and deprecated. It is no longer supported or maintained by GitHub staff, and we will not respond to support or community questions. Feel free to fork. 4 | -------------------------------------------------------------------------------- /docs/Developing.md: -------------------------------------------------------------------------------- 1 | # Developing and Contributing Code 2 | 3 | The README file contains tons of details about the project setup. I'm working in the branch `kenmuse/vNext`. I plan to refactor that into multiple documents in `/docs` since this project now has two distinct users: 4 | 5 | - Technical teams implementing Probot. That's who it was originally supporting and the primary audience. They tend to run the server component to set up the app (`yarn start`). Then they would like to run scripts to deploy to the cloud. After that, they update the App to point to the cloud deployment. 6 | - Administrative teams trying to deploy the application and test what it does. They prefer to follow docs to set up the App and to have simple paths to setting up the code in Azure, AWS, or a standalone container. The vision is that while they must manually create the App, they can rely on Releases and Packages to deploy a running instance. For environments that support it -- like Azure -- they can use a deployment link to provision and deploy the resources. 7 | 8 | Both groups benefit from images in `ghcr.io` and ZIP packages in Releases. They can reference those to quickly deploy from the repo without having to clone or customize the repo. 9 | 10 | The Dev Container has support for the Azure Functions Core Tools (`func`), but it has to build those for macOS since they haven't released Linux arm64 binaries yet. `func` can do a lot of the packaging work, but it doesn't handle creating resources. It also sets up SAM and the AWS CLI for AWS Lambda. Because it won't need to build anything, Codespaces tends to be faster to set up. 11 | 12 | The environment uses Yarn, and the dev container sets that up. Because it's using workspaces, each package is in a subfolder with its own `package.json` and scripts. The top-level `package.json` sets up the workspaces, provides some global scripts, and has a couple of top-level convenience scripts for testing/building everything. 13 | 14 | Running the standalone server (`yarn start` in the root folder or in `packages/server`) is the easiest way to generate all of the fields needed for deploying the app. It uses the manifest flow to configure the app, then writes a `.env` with the App registration details in `packages/server`. For local development with Azure, I have two helper scripts: 15 | 16 | - `yarn run storage` - Run this in a different terminal to start Azurite. Functions need a storage emulator for local development. 17 | - `yarn run copyEnv` - Copies the .env settings into the `local.settings.json` in `packages/azure` to emulator the App Settings that would get set in Azure. Both the `.env` and the `local.settings.json` are prevented from being checked in by `.gitignore`. 18 | 19 | For `packages/aws`, it's version of `yarn run copyEnv` does the same thing, creating a local JSON environment file for the SAM CLI to use. It doesn't need any emulators. 20 | 21 | The standalone server (`packages/server`) is self-configuring, aside from the need to put `GH_ORG` in an initial `.env` if you want it to create the App registration in your organization instead of a personal account. 22 | 23 | The `packages` setup is a convention. I'm using Yarn workspaces to manage this as a monorepo (since all of the components should release and version together). That layout is: 24 | - `app` - The core application and an agnostic handler that supports AWS and Lambda. This is setup as a composite TypeScript project so that it gets built into the components that need it. 25 | - `server` - A standalone application that hosts the Probot app (and adds support for using a signal to kill it). The current v1 codebase is the server and app components combined. 26 | - `azure` - The code specific to hosting app in Azure Functions 27 | - `aws` - The code specific to hosting app in AWS Lambda. Uses the AWS SAM tools to simplify the code/setup. 28 | 29 | The code needed some refactoring to support all of these changes. Probot 12 was missing a lot of events, so v1 had custom code to support the missing pieces. In this new version, Probot 13 is used (which eliminated most of the custom types and interfaces). Unfortunately, the Probot code for handling AWS Lambda and Azure code isn't compatible with v13 yet, so I needed a custom implementation (`packages/app/src/handler.ts`). 30 | 31 | The code moved from NPM to Yarn. That eliminated `node_modules` and made the package setup much faster. It changed a few behaviors along the way. 32 | 33 | Esbuild to compile the code to a single file. That avoids the need to deal with the fragile TypeScript runtimes. It also eliminates the need to deploy or install any other modules at runtime. The only runtime dependency is the generated file. For the server code, not even the `package.json` file is needed. Every package has a `yarn run build` script to compile the code and place it in `dist`. While the `packages/app` supports being built, it's not intended to be distributed that way. It's pulled into the other packages and consumed directly (essentially, common files). As a side effect, that means the packages have lots of `devDependencies`, but few (or no) `dependencies` in the `package.json`. An exception is the AWS code. That requires esbuild to be a listed dependency for the SAM CLI. 34 | 35 | Tests are run using Jest, and formatting uses Eslint. There's a few alpha and beta packages because several of the tools have open issues until their next major release. A lot of this is related to the ongoing move to ESM and some changes in each of the tools. VS Code also uses a Jest extension and has debugging configurations for the server, AWS Lambda, and Azure Functions. 36 | 37 | The next version of VS Code will update the Node runtime and allow it to fully support ESM. Because it doesn't, ESLint has to use `.cjs` files for VS Code to recognize the configurations. That explicitly forces it to stay with CommonJS. Once the new version is released, that code can move to ESM as well. 38 | 39 | There is a top-level Yarn script that also allows you to run all of the tests and linting across all of the packages (`yarn run test` in the root folder). There's also a `copyEnv` script used by the AWS and Azure packages to copy the settings from `.env` into the appropriate formats for testing those environments locally. 40 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | 4 | const currentDir = dirname(fileURLToPath(import.meta.url)); 5 | const defaultBuildSettings = { 6 | tsconfig: 'tsconfig.json', 7 | outExtension: { '.js': '.mjs' }, 8 | bundle: true, 9 | minify: true, 10 | platform: 'node', 11 | format: 'esm', 12 | target: 'node20.0', 13 | outdir: './dist/', 14 | treeShaking: true, 15 | absWorkingDir: currentDir, 16 | inject: [`${currentDir}/utils/cjs-shim.ts`] 17 | }; 18 | 19 | export default defaultBuildSettings; 20 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | 4 | export default tseslint.config( 5 | { 6 | ignores: [ 7 | '**/eslint.config.cjs', 8 | '**/jest.config.mjs', 9 | '**/esbuild.config.mjs', 10 | '**/.aws-sam/', 11 | '**/dist/', 12 | '**/coverage/', 13 | '**/.yarn/', 14 | '**/.pnp.cjs', 15 | '**/.pnp.loader.mjs' 16 | ] 17 | }, 18 | eslint.configs.recommended, 19 | ...tseslint.configs.stylistic, 20 | ...tseslint.configs.strict, 21 | { 22 | files: ['src/**/*.{ts,mts}', 'test/**/*.test.{ts,mts}'] 23 | } 24 | ); 25 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | const config = { 3 | preset: 'ts-jest/presets/default-esm', 4 | roots: ['/src/', '/test/'], 5 | testEnvironment: 'node', 6 | testRegex: '(/test/.*\\.(test|spec))\\.m?[tj]s$', 7 | resolver: 'ts-jest-resolver', 8 | extensionsToTreatAsEsm: ['.ts'], 9 | collectCoverage: true, 10 | collectCoverageFrom: ['./src/**'], 11 | coverageDirectory: './coverage', 12 | coverageThreshold: { 13 | global: { 14 | branches: 60, 15 | functions: 60, 16 | lines: 60, 17 | statements: 60 18 | } 19 | }, 20 | coveragePathIgnorePatterns: ['node_modules', '/src/index.ts'] 21 | }; 22 | 23 | export default config; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "security-alert-watcher", 3 | "version": "2.0.0", 4 | "private": true, 5 | "description": "Sample GH App which monitors and enforces rules for code scanning alerts", 6 | "author": "Ken Muse (https://www.kenmuse.com)", 7 | "license": "MIT", 8 | "type": "module", 9 | "homepage": "https://github.com/advanced-security/probot-codescan-alerts", 10 | "repository": "https://github.com/advanced-security/probot-codescan-alerts.git", 11 | "bugs": "https://github.com/advanced-security/probot-codescan-alerts/issues", 12 | "keywords": [ 13 | "probot", 14 | "github", 15 | "probot-app" 16 | ], 17 | "scripts": { 18 | "build": "yarn workspaces foreach -Apt run build", 19 | "test": "yarn workspaces foreach -Apt run test", 20 | "clean": "yarn workspaces foreach -Apt run clean", 21 | "lint": "yarn workspaces foreach -Apt run lint", 22 | "format:check": "yarnprettier --no-error-on-unmatched-pattern --config ${PROJECT_CWD}/.prettierrc.yml --check \"**/*.{js,json,mts,ts,yml,yaml}\"", 23 | "format": "yarn prettier --no-error-on-unmatched-pattern --config ${PROJECT_CWD}/.prettierrc.yml --write \"**/*.{js,json,mts,ts,yml,yaml}\"", 24 | "start": "yarn workspace @security-alert-watcher/server run start", 25 | "g:copyEnv": "yarn node scripts/copyEnv.mjs", 26 | "g:test": "yarn dlx jest --config=${PROJECT_CWD}/jest.config.mjs --rootDir=${INIT_CWD}", 27 | "g:clean": "rm -rf ${INIT_CWD}/dist && rm -rf ${INIT_CWD}/coverage", 28 | "g:format": "yarn prettier --no-error-on-unmatched-pattern --config ${PROJECT_CWD}/.prettierrc.yml --write \"${INIT_CWD}/**/*.{js,json,mts,ts,yml,yaml}\"", 29 | "g:format-check": "yarn prettier --no-error-on-unmatched-pattern --config ${PROJECT_CWD}/.prettierrc.yml --check \"${INIT_CWD}/**/*.{js,json,mts,ts,yml,yaml}\"", 30 | "audit": "yarn workspaces foreach -Apt npm audit", 31 | "version:major": "yarn version major && yarn workspaces foreach -A version major", 32 | "version:minor": "yarn version minor && yarn workspaces foreach -A version minor", 33 | "sync:find": "yarn syncpack list-mismatches --filter '^(?!@security-alert-watcher).*'", 34 | "sync": "yarn syncpack fix-mismatches", 35 | "reset-yarn": "rm -rf .yarn && rm .pnp.cjs && rm .pnp.loader.mjs && yarn install && yarn dlx @yarnpkg/sdks base" 36 | }, 37 | "devDependencies": { 38 | "@eslint/js": "^9.23.0", 39 | "@jest/core": "^29.7.0", 40 | "@jest/globals": "^29.7.0", 41 | "@octokit/auth-app": "^7.1.5", 42 | "@octokit/core": "^6.1.4", 43 | "@types/jest": "^29.5.14", 44 | "@types/node": "^22.13.14", 45 | "dotenv": "^16.4.7", 46 | "esbuild": "^0.25.2", 47 | "eslint": "^9.23.0", 48 | "prettier": "^3.5.3", 49 | "syncpack": "^13.0.3", 50 | "tslib": "^2.8.1", 51 | "typescript": "^5.8.2", 52 | "typescript-eslint": "^8.28.0" 53 | }, 54 | "engines": { 55 | "node": ">= 22.10.0" 56 | }, 57 | "packageManager": "yarn@4.8.1", 58 | "workspaces": [ 59 | "packages/*" 60 | ], 61 | "resolutions": { 62 | "esbuild": "^0.25.1" 63 | }, 64 | "dependenciesMeta": { 65 | "probot": { 66 | "unplugged": true 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/app/esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import { dirname } from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import config from '../../esbuild.config.mjs'; 5 | 6 | const currentDir = dirname(fileURLToPath(import.meta.url)); 7 | 8 | const settings = { ...config, ...{ 9 | entryPoints: ['./src/app.ts', './src/handler.ts'], 10 | absWorkingDir: currentDir 11 | }}; 12 | 13 | await esbuild.build(settings); 14 | -------------------------------------------------------------------------------- /packages/app/jest.config.mjs: -------------------------------------------------------------------------------- 1 | import config from '../../jest.config.mjs'; 2 | config.coveragePathIgnorePatterns.push('/src/config/process.d.ts'); 3 | export default config; 4 | -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@security-alert-watcher/app", 3 | "version": "2.0.0", 4 | "private": true, 5 | "description": "Sample GH App which monitors and enforces rules for code scanning alerts", 6 | "author": "Ken Muse (https://www.kenmuse.com)", 7 | "license": "MIT", 8 | "homepage": "https://github.com/advanced-security/probot-codescan-alerts", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/advanced-security/probot-codescan-alerts.git", 12 | "directory": "packages/app" 13 | }, 14 | "bugs": "https://github.com/advanced-security/probot-codescan-alerts/issues", 15 | "keywords": [ 16 | "probot", 17 | "github", 18 | "probot-app" 19 | ], 20 | "type": "module", 21 | "files": [ 22 | "dist/src/**" 23 | ], 24 | "exports": "./dist/src/index.js", 25 | "types": "./dist/src/index.d.ts", 26 | "scripts": { 27 | "build": "tsc", 28 | "clean": "yarn g:clean", 29 | "test": "NODE_OPTIONS='--experimental-vm-modules --no-warnings=ExperimentalWarning' yarn jest --config=${INIT_CWD}/jest.config.mjs --rootDir=${INIT_CWD}", 30 | "lint": "yarn eslint", 31 | "format": "yarn g:format", 32 | "format:check": "yarn g:format-check" 33 | }, 34 | "dependencies": { 35 | "probot": "^13.4.4" 36 | }, 37 | "devDependencies": { 38 | "@eslint/js": "^9.23.0", 39 | "@jest/core": "^29.7.0", 40 | "@jest/globals": "^29.7.0", 41 | "@octokit/request-error": "*", 42 | "@octokit/types": "*", 43 | "@octokit/webhooks-types": "*", 44 | "@types/jest": "^29.5.14", 45 | "@types/node": "^22.13.14", 46 | "@typescript-eslint/eslint-plugin": "^8.28.0", 47 | "@typescript-eslint/parser": "^8.28.0", 48 | "eslint": "^9.23.0", 49 | "eslint-config-prettier": "^9.1.0", 50 | "jest": "^29.7.0", 51 | "jest-circus": "^29.7.0", 52 | "nock": "^14.0.2", 53 | "pino": "*", 54 | "prettier": "^3.5.3", 55 | "ts-jest": "^29.3.0", 56 | "ts-jest-resolver": "^2.0.1", 57 | "tslib": "^2.8.1", 58 | "typescript": "^5.8.2", 59 | "typescript-eslint": "^8.28.0" 60 | }, 61 | "engines": { 62 | "node": ">= 22.10.0" 63 | }, 64 | "publishConfig": { 65 | "provenance": true 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/app/src/app.ts: -------------------------------------------------------------------------------- 1 | // For implementation details, see https://probot.github.io/docs/README/ 2 | import {Probot, ApplicationFunction, ApplicationFunctionOptions} from 'probot'; 3 | import codeScanningAlertDismissed from './events/codeScanningAlertDismissed.js'; 4 | import {dependabotAlertDismissed} from './events/dependabotAlertDismissed.js'; 5 | import secretScanningAlertDismissed from './events/secretScanningAlertDismissed.js'; 6 | import {CustomWebhookEventContext} from './events/types.js'; 7 | import {preparePrivateKey} from './config/index.js'; 8 | 9 | export const app: ApplicationFunction = probotApplicationFunction; 10 | 11 | // Ensure the private key (if provided) is in the proper format. 12 | preparePrivateKey(); 13 | 14 | function probotApplicationFunction( 15 | app: Probot, 16 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 17 | _options: ApplicationFunctionOptions 18 | ) { 19 | // Log the start 20 | app.log.info('Started monitoring process'); 21 | 22 | // Display any errors 23 | app.onError(async evt => { 24 | app.log.error(evt.message); 25 | }); 26 | 27 | // Log incoming events 28 | app.onAny(async context => { 29 | const ctx = context as CustomWebhookEventContext; 30 | const eventName = `${ctx.name}.${ctx.payload.action}`; 31 | app.log.info(`Received event: ${eventName}`); 32 | }); 33 | 34 | app.on('code_scanning_alert', async context => { 35 | app.log.info( 36 | `Processing code_scanning_alert with action ${context.payload.action}` 37 | ); 38 | }); 39 | 40 | // https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#installation 41 | app.on('installation', async context => { 42 | app.log.info( 43 | `Processing installation with action ${context.payload.action}` 44 | ); 45 | }); 46 | 47 | // Implement event handlers 48 | 49 | // https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#code_scanning_alert 50 | app.on('code_scanning_alert.closed_by_user', codeScanningAlertDismissed); 51 | 52 | app.on('secret_scanning_alert.resolved', secretScanningAlertDismissed); 53 | 54 | app.on('dependabot_alert.dismissed', dependabotAlertDismissed); 55 | } 56 | -------------------------------------------------------------------------------- /packages/app/src/config/index.ts: -------------------------------------------------------------------------------- 1 | import {type Config, toSeverity} from './types.js'; 2 | 3 | export const DEFAULT_APPROVING_TEAM = 'scan-managers'; 4 | 5 | // When this module is loaded, patch the private key variable 6 | // to replace escaped newlines and remove quotes. This prevents 7 | // errors from Octokit when the value is directly used. 8 | export function preparePrivateKey() { 9 | if (process.env.PRIVATE_KEY !== undefined) { 10 | process.env.PRIVATE_KEY = process.env.PRIVATE_KEY.replace( 11 | /\\n/g, 12 | '\n' 13 | ).replace(/"/g, ''); 14 | } 15 | } 16 | 17 | /** 18 | * Gets the configuration settings for the application. 19 | * @returns The configuration settings 20 | */ 21 | export function getConfiguration(): Config { 22 | preparePrivateKey(); 23 | const defaultApproverTeam = 24 | process.env.SECURITY_ALERT_CLOSE_TEAM || DEFAULT_APPROVING_TEAM; 25 | const dependabotApproverTeam = 26 | process.env.DEPENDABOT_APPROVER_TEAM || defaultApproverTeam; 27 | const dependabotMinimumSeverity = toSeverity(process.env.DEPENDABOT_SEVERITY); 28 | const codeScanningApproverTeam = 29 | process.env.CODE_SCANNING_APPROVER_TEAM || defaultApproverTeam; 30 | const codeScanningMinimumSeverity = toSeverity( 31 | process.env.CODE_SCANNING_SEVERITY 32 | ); 33 | const secretScanningApproverTeam = 34 | process.env.SECRET_SCANNING_APPROVER_TEAM || defaultApproverTeam; 35 | 36 | return { 37 | securityAlertCloseTeam: defaultApproverTeam, 38 | privateKey: process.env.PRIVATE_KEY, 39 | dependabotApproverTeam: dependabotApproverTeam, 40 | dependabotMinimumSeverity: dependabotMinimumSeverity, 41 | codeScanningApproverTeam: codeScanningApproverTeam, 42 | codeScanningMinimumSeverity: codeScanningMinimumSeverity, 43 | secretScanningApproverTeam: secretScanningApproverTeam 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /packages/app/src/config/process.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Define the configuration values that are available in the environment. 3 | */ 4 | export {}; 5 | declare global { 6 | namespace NodeJS { 7 | interface ProcessEnv { 8 | // ************************************************ 9 | // App configuration 10 | // ************************************************ 11 | 12 | SECURITY_ALERT_CLOSE_TEAM?: string; 13 | DEPENDABOT_APPROVER_TEAM?: string; 14 | DEPENDABOT_SEVERITY?: string; 15 | CODE_SCANNING_APPROVER_TEAM?: string; 16 | CODE_SCANNING_SEVERITY?: string; 17 | SECRET_SCANNING_APPROVER_TEAM?: string; 18 | 19 | // ************************************************ 20 | // AWS configuration 21 | // ************************************************ 22 | 23 | /** 24 | * The AWS session token for retrieving secrets from AWS Secret Manager 25 | */ 26 | AWS_SESSION_TOKEN?: string; 27 | /** 28 | * ARN reference to a secret in AWS Secret Manager (AWS only) 29 | * @example 'arn:aws:secretsmanager:us-east-1:123456789012:secret:my-secret-1234Ab' 30 | */ 31 | PRIVATE_KEY_ARN?: string; 32 | 33 | // ************************************************ 34 | // Probot configuration 35 | // ************************************************ 36 | 37 | /** 38 | * The App ID assigned to your GitHub App. 39 | * @example '1234' 40 | */ 41 | APP_ID?: string; 42 | /** 43 | * By default, logs are formatted for readability in development. You can 44 | * set this to `json` in order to disable the formatting. 45 | */ 46 | LOG_FORMAT?: 'json' | 'pretty'; 47 | /** 48 | * The verbosity of logs to show when running your app, which can be 49 | * `fatal`, `error`, `warn`, `info`, `debug`, `trace` or `silent`. 50 | * @default 'info' 51 | */ 52 | LOG_LEVEL?: 53 | | 'trace' 54 | | 'debug' 55 | | 'info' 56 | | 'warn' 57 | | 'error' 58 | | 'fatal' 59 | | 'silent'; 60 | /** 61 | * By default, when using the `json` format, the level printed in the log 62 | * records is an int (`10`, `20`, ..). This option tells the logger to 63 | * print level as a string: `{"level": "info"}`. Default `false` 64 | */ 65 | LOG_LEVEL_IN_STRING?: 'true' | 'false'; 66 | /** 67 | * Only relevant when `LOG_FORMAT` is set to `json`. Sets the json key for the log message. 68 | * @default 'msg' 69 | */ 70 | LOG_MESSAGE_KEY?: string; 71 | /** 72 | * The organization where you want to register the app in the app 73 | * creation manifest flow. If set, the app is registered for an 74 | * organization 75 | * (https://github.com/organizations/ORGANIZATION/settings/apps/new), if 76 | * not set, the GitHub app would be registered for the user account 77 | * (https://github.com/settings/apps/new). 78 | */ 79 | GH_ORG?: string; 80 | /** 81 | * The hostname of your GitHub Enterprise instance. 82 | * @example github.mycompany.com 83 | */ 84 | GHE_HOST?: string; 85 | /** 86 | * The protocol of your GitHub Enterprise instance. Defaults to HTTPS. 87 | * Do not change unless you are certain. 88 | * @default 'https' 89 | */ 90 | GHE_PROTOCOL?: string; 91 | /** 92 | * The contents of the private key for your GitHub App. If you're unable 93 | * to use multiline environment variables, use base64 encoding to 94 | * convert the key to a single line string. See the Deployment docs for 95 | * provider specific usage. 96 | */ 97 | PRIVATE_KEY?: string; 98 | /** 99 | * When using the `PRIVATE_KEY_PATH` environment variable, set it to the 100 | * path of the `.pem` file that you downloaded from your GitHub App registration. 101 | * @example 'path/to/key.pem' 102 | */ 103 | PRIVATE_KEY_PATH?: string; 104 | /** 105 | * The port to start the local server on. 106 | * @default '3000' 107 | */ 108 | PORT?: string; 109 | /** 110 | * The host to start the local server on. 111 | */ 112 | HOST?: string; 113 | /** 114 | * Set to a `redis://` url as connection option for 115 | * [ioredis](https://github.com/luin/ioredis#connect-to-redis) in order 116 | * to enable 117 | * [cluster support for request throttling](https://github.com/octokit/plugin-throttling.js#clustering). 118 | * @example 'redis://:secret@redis-123.redislabs.com:12345/0' 119 | */ 120 | REDIS_URL?: string; 121 | /** 122 | * Set to a [Sentry](https://sentry.io/) DSN to report all errors thrown 123 | * by your app. 124 | * @example 'https://1234abcd@sentry.io/12345' 125 | */ 126 | SENTRY_DSN?: string; 127 | /** 128 | * The URL path which will receive webhooks. 129 | * @default '/api/github/webhooks' 130 | */ 131 | WEBHOOK_PATH?: string; 132 | /** 133 | * Allows your local development environment to receive GitHub webhook 134 | * events. Go to https://smee.io/new to get started. 135 | * @example 'https://smee.io/your-custom-url' 136 | */ 137 | WEBHOOK_PROXY_URL?: string; 138 | /** 139 | * **Required** 140 | * The webhook secret used when creating a GitHub App. 'development' is 141 | * used as a default, but the value in `.env` needs to match the value 142 | * configured in your App settings on GitHub. Note: GitHub marks this 143 | * value as optional, but for optimal security it's required for Probot 144 | * apps. 145 | * 146 | * @example 'development' 147 | * @default 'development' 148 | */ 149 | WEBHOOK_SECRET?: string; 150 | /** 151 | * The node environment settings 152 | */ 153 | NODE_ENV?: 'development' | 'production' | 'test' | undefined; 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /packages/app/src/config/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interface for retrieving strongly-typed application 3 | * configuration settings. 4 | */ 5 | export interface Config { 6 | /** The team responsible for closing security alerts */ 7 | securityAlertCloseTeam: string; 8 | privateKey: string | undefined; 9 | dependabotApproverTeam: string; 10 | dependabotMinimumSeverity: Severity; 11 | codeScanningApproverTeam: string; 12 | codeScanningMinimumSeverity: Severity; 13 | secretScanningApproverTeam: string; 14 | } 15 | 16 | /** The alert severiy level */ 17 | export enum Severity { 18 | ALL = 0, 19 | NOTE = 20, 20 | WARNING = 40, 21 | ERROR = 60, 22 | LOW = 100, 23 | MEDIUM = 200, 24 | HIGH = 300, 25 | CRITICAL = 400, 26 | UNKNOWN = 900, 27 | NONE = 1000 28 | } 29 | 30 | /** 31 | * Converts a string value to a severity level. 32 | * @param value The string value to convert 33 | * @param defaultSeverity The default severity level to return if the value is invalid 34 | * @returns The severity level, or ALL if an invalid value is provided 35 | */ 36 | export function toSeverity( 37 | value?: string, 38 | defaultSeverity = Severity.ALL 39 | ): Severity { 40 | return ( 41 | Severity[(value ?? 'ALL').toUpperCase() as keyof typeof Severity] ?? 42 | defaultSeverity 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /packages/app/src/events/approvingTeam.ts: -------------------------------------------------------------------------------- 1 | import {type OctokitContext} from './types.js'; 2 | import {RequestError} from '@octokit/request-error'; 3 | 4 | /** 5 | * Returns a value indicating if the user is a member of the approving 6 | * team in the organization. 7 | * @param context the event context 8 | * @param approvingTeamName the name of the team required to approve the request 9 | * @param owner the repository owner 10 | * @param user the user to evaluate 11 | * @returns true if the user is either an owner of the organization or 12 | * a member of the approving team; otherwise, false. 13 | */ 14 | export async function isUserInApproverTeam( 15 | context: OctokitContext, 16 | approvingTeamName: string, 17 | owner: string, 18 | user: string | undefined 19 | ): Promise { 20 | if (owner && user && context) { 21 | try { 22 | const memberships = await context.octokit.teams.getMembershipForUserInOrg( 23 | { 24 | org: owner, 25 | team_slug: approvingTeamName, 26 | username: user 27 | } 28 | ); 29 | 30 | const role = memberships.data.role; 31 | context.log.info( 32 | JSON.stringify( 33 | `The user ${user} has the role "${role}" in the team "${approvingTeamName}".` 34 | ) 35 | ); 36 | // The role will be "maintainer" if the user is an organization owner 37 | // (whether or not they are explicitly in the team). The role will be "member" 38 | // if the user is explicitly included in the team. If the code has reached this 39 | // step, the user is in one of these two roles in the team. 40 | return true; 41 | } catch (e) { 42 | // A 404 status is returned if the user is not in the team. If there's an error 43 | // resolving the user or a 404, default to not allowing the user to probeed 44 | if (e instanceof RequestError && e.status === 404) { 45 | context.log.info( 46 | `The user ${user} is not part of the team "${approvingTeamName}"` 47 | ); 48 | } else { 49 | context.log.error( 50 | e, 51 | `Unexpected error checking if user ${user} is in the team "${approvingTeamName}"` 52 | ); 53 | } 54 | 55 | return false; 56 | } 57 | } 58 | 59 | return false; 60 | } 61 | -------------------------------------------------------------------------------- /packages/app/src/events/codeScanningAlertDismissed.ts: -------------------------------------------------------------------------------- 1 | import {Context} from 'probot'; 2 | import {isUserInApproverTeam} from './approvingTeam.js'; 3 | import {getConfiguration} from '../config/index.js'; 4 | import {Severity, toSeverity} from '../config/types.js'; 5 | import {CodeScanningSecurityRule} from './types.js'; 6 | 7 | /** 8 | * Handles the code scanning alert event 9 | * @param context the event context 10 | */ 11 | export default async function codeScanningAlertDismissed( 12 | context: Context<'code_scanning_alert'> 13 | ) { 14 | context.log.info('Code scanning alert event received.'); 15 | 16 | const owner = context.payload.repository.owner.login; 17 | const user = context.payload.alert.dismissed_by?.login; 18 | const config = getConfiguration(); 19 | const approver = config.codeScanningApproverTeam; 20 | 21 | const minimumSeverity = config.codeScanningMinimumSeverity; 22 | const rule = context.payload.alert.rule as CodeScanningSecurityRule; 23 | const alertSeverity = toSeverity( 24 | rule.security_severity_level, 25 | Severity.UNKNOWN 26 | ); 27 | if (alertSeverity < minimumSeverity) { 28 | context.log.info( 29 | `Alert close request allowed. Severity '${Severity[alertSeverity]}' is below minimum severity '${Severity[minimumSeverity]}'.` 30 | ); 31 | return; 32 | } 33 | 34 | const isMemberApproved = await isUserInApproverTeam( 35 | context, 36 | approver, 37 | owner, 38 | user 39 | ); 40 | 41 | if (isMemberApproved) { 42 | context.log.info('Alert close request approved.'); 43 | } else { 44 | context.log.info('Alert close request not approved. Re-opening the alert.'); 45 | 46 | const repo = context.payload.repository.name; 47 | const alertNumber = context.payload.alert.number; 48 | 49 | await context.octokit.codeScanning.updateAlert({ 50 | owner, 51 | repo, 52 | alert_number: alertNumber, 53 | state: 'open' 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/app/src/events/dependabotAlertDismissed.ts: -------------------------------------------------------------------------------- 1 | import {isUserInApproverTeam} from './approvingTeam.js'; 2 | import {Context} from 'probot'; 3 | import {getConfiguration} from '../config/index.js'; 4 | import {Severity, toSeverity} from '../config/types.js'; 5 | 6 | /** 7 | * Handles the code scanning alert event 8 | * @param context the event context 9 | */ 10 | export async function dependabotAlertDismissed( 11 | context: Context<'dependabot_alert.dismissed'> 12 | ) { 13 | context.log.info('Dependabot alert event received.'); 14 | const owner = context.payload.repository.owner.login; 15 | const user = context.payload.alert.dismissed_by?.login; 16 | const config = getConfiguration(); 17 | const approver = config.dependabotApproverTeam; 18 | const minimumSeverity = config.dependabotMinimumSeverity; 19 | const alertSeverity = Math.max( 20 | toSeverity( 21 | context.payload.alert.security_advisory.severity, 22 | Severity.UNKNOWN 23 | ), 24 | toSeverity( 25 | context.payload.alert.security_vulnerability.severity, 26 | Severity.UNKNOWN 27 | ) 28 | ); 29 | 30 | if (alertSeverity < minimumSeverity) { 31 | context.log.info( 32 | `Alert close request allowed. Severity '${Severity[alertSeverity]}' is below minimum severity '${Severity[minimumSeverity]}'.` 33 | ); 34 | return; 35 | } 36 | const isMemberApproved = await isUserInApproverTeam( 37 | context, 38 | approver, 39 | owner, 40 | user 41 | ); 42 | 43 | if (isMemberApproved) { 44 | context.log.info('Alert close request approved.'); 45 | } else { 46 | context.log.info('Alert close request not approved. Re-opening the alert.'); 47 | 48 | const repo = context.payload.repository.name; 49 | const alertNumber = context.payload.alert.number; 50 | 51 | await context.octokit.dependabot.updateAlert({ 52 | owner, 53 | repo, 54 | alert_number: alertNumber, 55 | state: 'open' 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/app/src/events/secretScanningAlertDismissed.ts: -------------------------------------------------------------------------------- 1 | import {Context} from 'probot'; 2 | import {isUserInApproverTeam} from './approvingTeam.js'; 3 | import {getConfiguration} from '../config/index.js'; 4 | 5 | /** 6 | * These resolutions indicate changes to a custom-defined secret. These 7 | * events do not require special approval. 8 | */ 9 | const CUSTOM_PATTERN_RESOLUTIONS = ['pattern_edited', 'pattern_deleted']; 10 | 11 | /** 12 | * Handles the secret scanning alert event 13 | * @param context the event context 14 | */ 15 | export default async function secretScanningAlertDismissed( 16 | context: Context<'secret_scanning_alert'> 17 | ) { 18 | context.log.info('Secret scanning alert event received.'); 19 | const owner = context.payload.repository.owner.login; 20 | const user = context.payload.alert?.resolved_by?.login; 21 | const config = getConfiguration(); 22 | const approver = config.secretScanningApproverTeam; 23 | const isMemberApproved = await isUserInApproverTeam( 24 | context, 25 | approver, 26 | owner, 27 | user 28 | ); 29 | 30 | const resolution = context.payload.alert?.resolution as string; 31 | const closedByCustomPattern = CUSTOM_PATTERN_RESOLUTIONS.includes(resolution); 32 | 33 | if (isMemberApproved || closedByCustomPattern) { 34 | context.log.info('Alert close request approved.'); 35 | if (closedByCustomPattern) { 36 | context.log.info(`Closed by custom pattern change: ${resolution}`); 37 | } 38 | } else { 39 | context.log.info('Alert close request not approved. Re-opening the alert.'); 40 | 41 | const repo = context.payload.repository.name; 42 | const alertNumber = context.payload.alert.number; 43 | 44 | await context.octokit.secretScanning.updateAlert({ 45 | owner, 46 | repo, 47 | alert_number: alertNumber, 48 | state: 'open' 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/app/src/events/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module contains generic resource and event type definitions 3 | * to make it easier to compose new webhook and API definitions when 4 | * required. 5 | */ 6 | 7 | import {ProbotOctokit, Logger} from 'probot'; 8 | 9 | /** 10 | * The supported signal types (see https://nodejs.org/api/process.html#signal-events) 11 | * for triggering a halt. 12 | */ 13 | export type SignalType = 'SIGINT' | 'SIGTERM'; 14 | 15 | /** 16 | * Interface to avoid TS2590 when loading the reviewing team 17 | */ 18 | export interface OctokitContext { 19 | octokit: InstanceType; 20 | log: Logger; 21 | } 22 | 23 | /** 24 | * Represents a GitHub resource 25 | */ 26 | export interface GitHubResource { 27 | /** The numberic identifier */ 28 | id: number; 29 | 30 | /** The node identifier */ 31 | node_id: string; 32 | } 33 | 34 | /** 35 | * A resource representing a user/organization/bot that 36 | * can own another resource 37 | */ 38 | export interface OwnerResource extends GitHubResource { 39 | /** The name of the owner */ 40 | login: string; 41 | 42 | /** Indicates whether the owner is a site admin */ 43 | site_admin: boolean; 44 | 45 | /** The type of user */ 46 | type: 'Bot' | 'User' | 'Organization'; 47 | } 48 | 49 | /** A resource representing an organization */ 50 | export interface OrganizationResource extends GitHubResource { 51 | /** The organization name */ 52 | name: string; 53 | } 54 | 55 | /** Resource representing a repository */ 56 | export interface RepositoryResource extends GitHubResource { 57 | /** Name of the repository */ 58 | name: string; 59 | 60 | /** Repository owner and name */ 61 | full_name: string; 62 | 63 | /** The user or organization that owns the repository */ 64 | owner: OwnerResource; 65 | } 66 | 67 | /** A generic definition for a web hook event */ 68 | export interface WebhookEvent { 69 | /** The action name */ 70 | action: string; 71 | sender: any; // eslint-disable-line @typescript-eslint/no-explicit-any 72 | installation: GitHubResource; 73 | } 74 | 75 | /** An event generated by or targeting an organization */ 76 | export interface OrganizationWebhookEvent extends WebhookEvent { 77 | organization: OrganizationResource; 78 | } 79 | 80 | /** An event generated by or targeting a repository */ 81 | export interface RepositoryWebhookEvent extends WebhookEvent { 82 | repository: RepositoryResource; 83 | organization?: OrganizationResource; 84 | } 85 | 86 | /** An event raised by a Dependabot alert */ 87 | export interface DependabotAlertWebhookEvent extends RepositoryWebhookEvent { 88 | alert: { 89 | dismissed_by?: OwnerResource; 90 | number: number; 91 | }; 92 | } 93 | 94 | /** 95 | * Context for processing manually defined event types. 96 | */ 97 | export interface CustomWebhookEventContext< 98 | T extends WebhookEvent = WebhookEvent 99 | > { 100 | id: string; 101 | name: string; 102 | octokit: InstanceType; 103 | log: Logger; 104 | payload: T; 105 | } 106 | 107 | /** 108 | * Context for code scanning alert rules 109 | */ 110 | export interface CodeScanningSecurityRule { 111 | id: string; 112 | severity: string; 113 | description: string; 114 | name: string; 115 | security_severity_level: string; 116 | } 117 | -------------------------------------------------------------------------------- /packages/app/src/handler.ts: -------------------------------------------------------------------------------- 1 | import * as bot from 'probot'; 2 | import {WebhookEventName} from '@octokit/webhooks-types'; 3 | 4 | // Minimum required headers. We are forcing x-hub-signature-256 to be present, even though it's optional 5 | // if no secret is configured. If we make this optional, then we will need to skip the signature verification below if 6 | // the secret is not configured. 7 | const requiredHeaders = [ 8 | 'x-github-event', 9 | 'x-github-delivery', 10 | 'x-hub-signature-256' 11 | ]; 12 | 13 | /** 14 | * Manages the Probot instance and Lambda function. 15 | */ 16 | export class ProbotHandler { 17 | /** The probot instance */ 18 | private instance: bot.Probot; 19 | 20 | /** 21 | * Creates an instance of the handler 22 | * @param instance the Probot instance 23 | */ 24 | constructor(instance: bot.Probot) { 25 | this.instance = instance; 26 | } 27 | 28 | /** 29 | * Factory method for creating a handler instance 30 | * @param instance the probot instance 31 | * @param application the application definition 32 | * @returns an instance of the handler 33 | */ 34 | public static async create( 35 | instance: bot.Probot, 36 | application: bot.ApplicationFunction 37 | ) { 38 | await instance.load(application); 39 | const processor = new ProbotHandler(instance); 40 | return processor; 41 | } 42 | 43 | /** 44 | * Serverless implementation for processing the webhook event 45 | * @param event the event instance 46 | * @returns the results of the request 47 | */ 48 | public async process( 49 | event: WebhookEventRequest 50 | ): Promise { 51 | if ( 52 | !event || 53 | event.body === undefined || 54 | event.body === '' || 55 | !event.headers || 56 | Object.keys(event.headers).length === 0 57 | ) { 58 | return ProbotHandler.buildResponse(400, 'Missing event body or headers'); 59 | } 60 | 61 | const entries = Object.entries(event.headers).map(([key, value]) => [ 62 | key.toLowerCase(), 63 | value ?? '' 64 | ]); 65 | const headers: Record = Object.fromEntries(entries); 66 | 67 | if (!requiredHeaders.every(header => headers[header])) { 68 | return ProbotHandler.buildResponse(400, 'Missing required headers'); 69 | } 70 | 71 | try { 72 | await this.instance.webhooks.verifyAndReceive({ 73 | id: headers['x-github-delivery'], 74 | name: headers['x-github-event'] as WebhookEventName, 75 | signature: headers['x-hub-signature-256'], 76 | payload: event.body 77 | }); 78 | } catch (error: unknown) { 79 | return ProbotHandler.buildResponse(400, ProbotHandler.getMessage(error)); 80 | } 81 | 82 | return ProbotHandler.buildResponse(200, {ok: true}); 83 | } 84 | 85 | /** 86 | * Gets the error message string 87 | * @param error The error instance 88 | * @returns A string message 89 | */ 90 | /* istanbul ignore next */ 91 | private static getMessage(error: unknown) { 92 | return error instanceof Error ? error.message : String(error); 93 | } 94 | 95 | /** 96 | * Builds a response object with the specified status and body. 97 | * If the body is an object, it will be converted to a JSON string. 98 | * If the body is a string, it will be wrapped in an object with a "message" property before converting to a JSON string. 99 | * @param status The status code of the response. 100 | * @param body The body of the response, which can be an object or a string. 101 | * @returns The response object with the specified status and body. 102 | */ 103 | private static buildResponse(status: number, body: object | string) { 104 | return { 105 | status, 106 | body: JSON.stringify(typeof body === 'object' ? body : {message: body}) 107 | }; 108 | } 109 | } 110 | 111 | /** 112 | * Represents an HTTP webhook request 113 | */ 114 | export interface WebhookEventRequest { 115 | body: string | undefined; 116 | headers: Record | Record; 117 | } 118 | 119 | /** 120 | * Represents an HTTP webhook response 121 | */ 122 | export interface WebhookEventResponse { 123 | status: number; 124 | body: string; 125 | } 126 | -------------------------------------------------------------------------------- /packages/app/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Provides the module exports 3 | */ 4 | export {app} from './app.js'; 5 | export { 6 | ProbotHandler, 7 | type WebhookEventRequest, 8 | type WebhookEventResponse 9 | } from './handler.js'; 10 | -------------------------------------------------------------------------------- /packages/app/test/app.test.ts: -------------------------------------------------------------------------------- 1 | import {jest} from '@jest/globals'; 2 | import { 3 | mockGitHubApiRequests, 4 | getTestableProbot, 5 | resetNetworkMonitoring 6 | } from './utils/helpers.js'; 7 | 8 | import installation_repositories_event from './fixtures/installation_repositories/added.json'; 9 | import installation_created_event from './fixtures/installation/created.json'; 10 | import installation_new_permissions_accepted_event from './fixtures/installation/new_permissions_accepted.json'; 11 | 12 | describe('When running the probot app', () => { 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | let probot: any; // Must use any type to avoid strong requirements for mock 15 | 16 | beforeEach(() => { 17 | probot = getTestableProbot(); 18 | jest.clearAllMocks(); 19 | }); 20 | 21 | test('receives installation_repositories message without calling additional GitHub APIs', async () => { 22 | const mock = mockGitHubApiRequests().toNock(); 23 | await probot.receive({ 24 | name: 'installation_repositories', 25 | payload: installation_repositories_event.payload 26 | }); 27 | expect(mock.pendingMocks()).toStrictEqual([]); 28 | }); 29 | 30 | test('receives installation created message without calling additional GitHub APIs', async () => { 31 | const mock = mockGitHubApiRequests().toNock(); 32 | await probot.receive({ 33 | name: 'installation', 34 | payload: installation_created_event.payload 35 | }); 36 | expect(mock.pendingMocks()).toStrictEqual([]); 37 | }); 38 | 39 | test('receives installation new permissions message without calling additional GitHub APIs', async () => { 40 | const mock = mockGitHubApiRequests().toNock(); 41 | await probot.receive({ 42 | name: 'installation', 43 | payload: installation_new_permissions_accepted_event.payload 44 | }); 45 | expect(mock.pendingMocks()).toStrictEqual([]); 46 | }); 47 | 48 | test('logs `code_scanning_alert` received', async () => { 49 | const mock = mockGitHubApiRequests().toNock(); 50 | const infolog = jest.fn(); 51 | probot.log.info = infolog; 52 | await probot.receive({ 53 | name: 'code_scanning_alert', 54 | payload: {action: 'test'} 55 | }); 56 | expect(mock.pendingMocks()).toStrictEqual([]); 57 | expect(infolog.mock.calls[0][0]).toContain('code_scanning_alert'); 58 | expect(infolog.mock.calls[0][0]).toContain('test'); 59 | }); 60 | 61 | test('receives `onError`', async () => { 62 | expect.hasAssertions(); 63 | const mock = mockGitHubApiRequests().toNock(); 64 | const errorlog = jest 65 | .spyOn(probot.log, 'error') 66 | .mockImplementation((...args: unknown[]) => { 67 | expect(args[0]).toMatch(/Error: Invalid input/); 68 | }); 69 | 70 | probot.webhooks.on('push', () => { 71 | throw new Error('Invalid input'); 72 | }); 73 | probot.webhooks 74 | .receive({ 75 | id: 0, 76 | name: 'push', 77 | payload: {} 78 | }) 79 | .catch(() => { 80 | expect(errorlog).toHaveBeenCalled(); 81 | }); 82 | 83 | expect(mock.pendingMocks()).toStrictEqual([]); 84 | }); 85 | 86 | afterEach(() => { 87 | resetNetworkMonitoring(); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /packages/app/test/config/config.test.ts: -------------------------------------------------------------------------------- 1 | import {Severity, toSeverity} from '../../src/config/types.js'; 2 | 3 | describe('The severity configuration', () => { 4 | test.each` 5 | threshold | value | expected 6 | ${'low'} | ${'WARN'} | ${false} 7 | ${'High'} | ${'LOW'} | ${false} 8 | ${'warn'} | ${'MEDIUM'} | ${true} 9 | ${'NOTE'} | ${'ERROR'} | ${true} 10 | ${'error'} | ${'NOTE'} | ${false} 11 | ${'none'} | ${'CRITICAL'} | ${false} 12 | ${'lOw'} | ${'NOTE'} | ${false} 13 | ${'MED'} | ${'CRITICAL'} | ${true} 14 | ${'all'} | ${'HIGH'} | ${true} 15 | ${'medium'} | ${'HIGH'} | ${true} 16 | ${'critical'} | ${'ERROR'} | ${false} 17 | ${'dog'} | ${'NOTE'} | ${true} 18 | ${null} | ${'CRITICAL'} | ${true} 19 | ${undefined} | ${'HIGH'} | ${true} 20 | `( 21 | 'Setting the threshold to `$threshold` will match the severity `$value`: $expected', 22 | async ({threshold, value, expected}) => { 23 | // Act 24 | const result = toSeverity(value); 25 | 26 | // Assert 27 | if (expected) { 28 | expect(result).toBeGreaterThanOrEqual(toSeverity(threshold)); 29 | } else { 30 | expect(result).not.toBeGreaterThanOrEqual(toSeverity(threshold)); 31 | } 32 | } 33 | ); 34 | 35 | test.each` 36 | value | expected 37 | ${'low'} | ${'LOW'} 38 | ${'High'} | ${'HIGH'} 39 | ${'warn'} | ${'ALL'} 40 | ${'warning'} | ${'WARNING'} 41 | ${'NOTE'} | ${'NOTE'} 42 | ${'error'} | ${'ERROR'} 43 | ${'LOW'} | ${'LOW'} 44 | ${'lOw'} | ${'LOW'} 45 | ${'MED'} | ${'ALL'} 46 | ${'all'} | ${'ALL'} 47 | ${'medium'} | ${'MEDIUM'} 48 | ${'critical'} | ${'CRITICAL'} 49 | ${'dog'} | ${'ALL'} 50 | ${null} | ${'ALL'} 51 | ${undefined} | ${'ALL'} 52 | `( 53 | 'should return $expected for the setting `$value`', 54 | async ({value, expected}) => { 55 | // Act 56 | const result = toSeverity(value); 57 | 58 | // Assert 59 | expect(Severity[result]).toEqual(expected); 60 | expect(result).toEqual(toSeverity(expected)); 61 | } 62 | ); 63 | }); 64 | -------------------------------------------------------------------------------- /packages/app/test/config/index.test.ts: -------------------------------------------------------------------------------- 1 | import {preparePrivateKey, getConfiguration} from '../../src/config/index.js'; 2 | import {getPrivateKey} from '../utils/helpers.js'; 3 | 4 | describe('When a private key is provided', () => { 5 | const OLD_ENV = process.env; 6 | let privateKey = ''; 7 | 8 | beforeEach(() => { 9 | process.env = {...OLD_ENV}; 10 | privateKey = getPrivateKey(); 11 | }); 12 | 13 | afterEach(() => { 14 | process.env = OLD_ENV; 15 | }); 16 | 17 | test(`extra quotes are removed`, async () => { 18 | // Arrange 19 | const key = `"${privateKey}"`; 20 | process.env.PRIVATE_KEY = key; 21 | 22 | // Act 23 | preparePrivateKey(); 24 | 25 | // Assert 26 | expect(process.env.PRIVATE_KEY).toEqual(expect.not.stringContaining(`"`)); 27 | }); 28 | 29 | test(`new line escapes are removed`, async () => { 30 | // Arrange 31 | const key = privateKey.replace(/\n/g, '\\n'); 32 | process.env.PRIVATE_KEY = key; 33 | 34 | // Act 35 | preparePrivateKey(); 36 | 37 | // Assert 38 | expect(process.env.PRIVATE_KEY).toEqual(expect.not.stringContaining(`\\n`)); 39 | }); 40 | }); 41 | 42 | describe('The configuration settings', () => { 43 | const OLD_ENV = process.env; 44 | const DEFAULT_TEAM = 'scan-managers'; 45 | 46 | beforeEach(() => { 47 | process.env = {...OLD_ENV}; 48 | }); 49 | 50 | afterEach(() => { 51 | process.env = OLD_ENV; 52 | }); 53 | 54 | test(`The process specified security alert team is provided`, () => { 55 | process.env.SECURITY_ALERT_CLOSE_TEAM = 'test-team'; 56 | const config = getConfiguration(); 57 | expect(config.securityAlertCloseTeam).toEqual('test-team'); 58 | }); 59 | 60 | test(`Default security alert team is 'scan-managers'`, () => { 61 | const config = getConfiguration(); 62 | expect(config.securityAlertCloseTeam).toEqual(DEFAULT_TEAM); 63 | }); 64 | 65 | test('No default private key is provided', () => { 66 | const config = getConfiguration(); 67 | expect(config.privateKey).toEqual(undefined); 68 | }); 69 | 70 | test.each` 71 | defaultTeam | configuredTeam | result 72 | ${null} | ${null} | ${DEFAULT_TEAM} 73 | ${undefined} | ${undefined} | ${DEFAULT_TEAM} 74 | ${null} | ${'super-users'} | ${'super-users'} 75 | ${'everyone'} | ${null} | ${'everyone'} 76 | ${'everyone'} | ${'super-users'} | ${'super-users'} 77 | ${'scans'} | ${DEFAULT_TEAM} | ${DEFAULT_TEAM} 78 | `( 79 | 'specifying `$configuredTeam` for code scanning approvers with default team `$defaultTeam` will use $result', 80 | async ({defaultTeam, configuredTeam, result}) => { 81 | process.env.SECURITY_ALERT_CLOSE_TEAM = defaultTeam; 82 | process.env.CODE_SCANNING_APPROVER_TEAM = configuredTeam; 83 | const config = getConfiguration(); 84 | expect(config.codeScanningApproverTeam).toEqual(result); 85 | } 86 | ); 87 | 88 | test.each` 89 | defaultTeam | configuredTeam | result 90 | ${null} | ${null} | ${DEFAULT_TEAM} 91 | ${undefined} | ${undefined} | ${DEFAULT_TEAM} 92 | ${null} | ${'super-users'} | ${'super-users'} 93 | ${'everyone'} | ${null} | ${'everyone'} 94 | ${'everyone'} | ${'super-users'} | ${'super-users'} 95 | ${'scans'} | ${DEFAULT_TEAM} | ${DEFAULT_TEAM} 96 | `( 97 | 'specifying `$configuredTeam` for dependabot scanning approvers with default team `$defaultTeam` will match $result', 98 | async ({defaultTeam, configuredTeam, result}) => { 99 | process.env.SECURITY_ALERT_CLOSE_TEAM = defaultTeam; 100 | process.env.DEPENDABOT_APPROVER_TEAM = configuredTeam; 101 | const config = getConfiguration(); 102 | expect(config.dependabotApproverTeam).toEqual(result); 103 | } 104 | ); 105 | test.each` 106 | defaultTeam | configuredTeam | result 107 | ${null} | ${null} | ${DEFAULT_TEAM} 108 | ${undefined} | ${undefined} | ${DEFAULT_TEAM} 109 | ${null} | ${'super-users'} | ${'super-users'} 110 | ${'everyone'} | ${null} | ${'everyone'} 111 | ${'everyone'} | ${'super-users'} | ${'super-users'} 112 | ${'scans'} | ${DEFAULT_TEAM} | ${DEFAULT_TEAM} 113 | `( 114 | 'specifying `$configuredTeam` for secret scanning approvers with default team `$defaultTeam` will use $result', 115 | async ({defaultTeam, configuredTeam, result}) => { 116 | process.env.SECURITY_ALERT_CLOSE_TEAM = defaultTeam; 117 | process.env.SECRET_SCANNING_APPROVER_TEAM = configuredTeam; 118 | const config = getConfiguration(); 119 | expect(config.secretScanningApproverTeam).toEqual(result); 120 | } 121 | ); 122 | }); 123 | -------------------------------------------------------------------------------- /packages/app/test/events/approvingTeam.test.ts: -------------------------------------------------------------------------------- 1 | import {jest} from '@jest/globals'; 2 | import {ProbotOctokit} from 'probot'; 3 | import pino from 'pino'; 4 | import {OctokitResponse} from '@octokit/types'; 5 | import {isUserInApproverTeam} from '../../src/events/approvingTeam.js'; 6 | import {OctokitContext} from '../../src/events/types.js'; 7 | import {DEFAULT_APPROVING_TEAM} from '../../src/config/index.js'; 8 | 9 | describe('When evaluating isUserInApproverTeam', () => { 10 | let context: OctokitContext; 11 | 12 | beforeEach(() => { 13 | context = { 14 | octokit: new ProbotOctokit(), 15 | log: pino(jest.fn()) 16 | }; 17 | context.log.info = jest.fn(); 18 | }); 19 | 20 | test.each` 21 | user | owner | nullctx | result 22 | ${null} | ${'owner'} | ${false} | ${false} 23 | ${'user'} | ${'owner'} | ${false} | ${true} 24 | ${null} | ${null} | ${false} | ${false} 25 | ${'user'} | ${null} | ${false} | ${false} 26 | ${null} | ${null} | ${true} | ${false} 27 | ${'user'} | ${null} | ${true} | ${false} 28 | ${null} | ${'owner'} | ${true} | ${false} 29 | ${'user'} | ${'owner'} | ${true} | ${false} 30 | `( 31 | 'should return $result for user:$user owner:$owner null-context:$nullctx', 32 | async ({user, owner, nullctx, result}) => { 33 | const requestContext = nullctx ? null : context; 34 | const success: OctokitResponse< 35 | { 36 | url: string; 37 | role: 'maintainer' | 'member'; 38 | state: 'active' | 'pending'; 39 | }, 40 | 200 41 | > = { 42 | headers: {}, 43 | status: 200, 44 | url: '...', 45 | data: { 46 | url: '...', 47 | role: 'maintainer', 48 | state: 'active' 49 | } 50 | }; 51 | const mock = jest 52 | .spyOn(context.octokit.teams, 'getMembershipForUserInOrg') 53 | .mockImplementation(() => { 54 | if (result) { 55 | return Promise.resolve(success); 56 | } else { 57 | throw Error('TEST-FAIL'); 58 | } 59 | }); 60 | const output = await isUserInApproverTeam( 61 | requestContext as OctokitContext, 62 | DEFAULT_APPROVING_TEAM, 63 | owner as string, 64 | user as string 65 | ); 66 | expect(output).toEqual(result); 67 | expect(mock).toBeCalledTimes(result ? 1 : 0); 68 | } 69 | ); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/app/test/events/codeScanningAlertDismissed.test.ts: -------------------------------------------------------------------------------- 1 | import {jest} from '@jest/globals'; 2 | import payload from './../fixtures/code_scanning_alert/closed_by_user.json'; 3 | import {Severity} from '../../src/config/types.js'; 4 | import {getConfigurableMockServices} from '../utils/services.js'; 5 | 6 | const PAYLOAD_FIXTURE = {name: 'code_scanning_alert.closed_by_user', payload}; 7 | 8 | describe('When code scanning alerts are received', () => { 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | let config: any; 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | let api: any; 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | let probot: any; 15 | 16 | beforeEach(async () => { 17 | const services = await getConfigurableMockServices(); 18 | api = services.api; 19 | config = services.config; 20 | probot = services.probot; 21 | }); 22 | 23 | afterEach(() => { 24 | jest.resetModules(); 25 | jest.clearAllMocks(); 26 | }); 27 | 28 | test.each(['maintainer', 'member'])( 29 | `ignores alerts closed by a %s in the approving team`, 30 | async (role: string) => { 31 | const mock = api 32 | .mockGitHubApiRequests() 33 | .canRetrieveAccessToken() 34 | .isInApprovingTeam(role) 35 | .toNock(); 36 | 37 | await probot.receive(PAYLOAD_FIXTURE); 38 | expect(mock.pendingMocks()).toStrictEqual([]); 39 | } 40 | ); 41 | 42 | test('opens alerts closed by non-member of the approving team', async () => { 43 | const mock = api 44 | .mockGitHubApiRequests() 45 | .canRetrieveAccessToken() 46 | .isNotInApprovingTeam() 47 | .withAlertState('code-scanning', 'open') 48 | .toNock(); 49 | 50 | await probot.receive(PAYLOAD_FIXTURE); 51 | 52 | expect(mock.pendingMocks()).toStrictEqual([]); 53 | }); 54 | 55 | test('opens alerts that are above a threshold', async () => { 56 | const originalConfig = config.getConfiguration(); 57 | const configuration = jest 58 | .spyOn(config, 'getConfiguration') 59 | .mockImplementation(() => { 60 | return { 61 | ...originalConfig, 62 | ...{codeScanningMinimumSeverity: Severity.LOW} 63 | }; 64 | }); 65 | const mock = api 66 | .mockGitHubApiRequests() 67 | .canRetrieveAccessToken() 68 | .isNotInApprovingTeam() 69 | .withAlertState('code-scanning', 'open') 70 | .toNock(); 71 | 72 | // Payload is medium security 73 | await probot.receive(PAYLOAD_FIXTURE); 74 | 75 | expect(mock.pendingMocks()).toStrictEqual([]); 76 | expect(configuration).toHaveBeenCalled(); 77 | }); 78 | 79 | test('allows alerts that are below a threshold', async () => { 80 | const originalConfig = config.getConfiguration(); 81 | const configuration = jest 82 | .spyOn(config, 'getConfiguration') 83 | .mockReturnValueOnce({ 84 | ...originalConfig, 85 | ...{codeScanningMinimumSeverity: Severity.HIGH} 86 | }); 87 | 88 | const mock = api.mockGitHubApiRequests().toNock(); 89 | 90 | // Payload is medium security 91 | await probot.receive(PAYLOAD_FIXTURE); 92 | 93 | expect(mock.pendingMocks()).toStrictEqual([]); 94 | expect(configuration).toHaveBeenCalled(); 95 | }); 96 | 97 | test('allows alerts when threshold is NONE', async () => { 98 | const originalConfig = config.getConfiguration(); 99 | const configuration = jest 100 | .spyOn(config, 'getConfiguration') 101 | .mockReturnValueOnce({ 102 | ...originalConfig, 103 | ...{codeScanningMinimumSeverity: Severity.NONE} 104 | }); 105 | 106 | const mock = api.mockGitHubApiRequests().toNock(); 107 | 108 | await probot.receive(PAYLOAD_FIXTURE); 109 | 110 | expect(mock.pendingMocks()).toStrictEqual([]); 111 | expect(configuration).toHaveBeenCalled(); 112 | }); 113 | 114 | test('opens alerts if membership request returns a 500 error', async () => { 115 | const mock = api 116 | .mockGitHubApiRequests() 117 | .canRetrieveAccessToken() 118 | .errorRetrievingTeamMembership(500) 119 | .withAlertState('code-scanning', 'open') 120 | .toNock(); 121 | 122 | await probot.receive(PAYLOAD_FIXTURE); 123 | 124 | expect(mock.pendingMocks()).toStrictEqual([]); 125 | }); 126 | 127 | afterEach(async () => { 128 | api.resetNetworkMonitoring(); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /packages/app/test/events/dependabotAlertDismissed.test.ts: -------------------------------------------------------------------------------- 1 | import {jest} from '@jest/globals'; 2 | import event from '../fixtures/dependabot_alert/dismissed.json'; 3 | import {Severity} from '../../src/config/types.js'; 4 | import {getConfigurableMockServices} from '../utils/services.js'; 5 | 6 | const PAYLOAD_FIXTURE = {name: 'dependabot_alert', payload: event.payload}; 7 | 8 | describe('When Dependabot alerts are received', () => { 9 | // Use the any type to avoid issues with additional fields in the payload that Probot cannot recognize 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | let config: any; 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | let api: any; 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | let probot: any; 16 | 17 | beforeEach(async () => { 18 | const services = await getConfigurableMockServices(); 19 | api = services.api; 20 | config = services.config; 21 | probot = services.probot; 22 | }); 23 | 24 | afterEach(() => { 25 | jest.resetModules(); 26 | jest.clearAllMocks(); 27 | }); 28 | 29 | test.each(['maintainer', 'member'])( 30 | `ignores alerts closed by a %s in the approving team`, 31 | async (role: string) => { 32 | const mock = api 33 | .mockGitHubApiRequests() 34 | .canRetrieveAccessToken() 35 | .isInApprovingTeam(role) 36 | .toNock(); 37 | 38 | // Receive a webhook event 39 | await probot.receive(PAYLOAD_FIXTURE); 40 | 41 | expect(mock.pendingMocks()).toStrictEqual([]); 42 | } 43 | ); 44 | 45 | test(`opens alerts closed by non-member of the approving team`, async () => { 46 | const mock = api 47 | .mockGitHubApiRequests() 48 | .canRetrieveAccessToken() 49 | .isNotInApprovingTeam() 50 | .withAlertState('dependabot', 'open') 51 | .toNock(); 52 | 53 | // Receive a webhook event 54 | await probot.receive(PAYLOAD_FIXTURE); 55 | 56 | expect(mock.pendingMocks()).toStrictEqual([]); 57 | }); 58 | 59 | test('allows alerts when threshold is NONE', async () => { 60 | const originalConfig = config.getConfiguration(); 61 | const configuration = jest 62 | .spyOn(config, 'getConfiguration') 63 | .mockReturnValueOnce({ 64 | ...originalConfig, 65 | ...{dependabotMinimumSeverity: Severity.NONE} 66 | }); 67 | const mock = api.mockGitHubApiRequests().toNock(); 68 | await probot.receive(PAYLOAD_FIXTURE); 69 | 70 | expect(mock.pendingMocks()).toStrictEqual([]); 71 | expect(configuration).toHaveBeenCalled(); 72 | }); 73 | 74 | test('opens alerts if membership request returns a 500 error', async () => { 75 | const mock = api 76 | .mockGitHubApiRequests() 77 | .canRetrieveAccessToken() 78 | .errorRetrievingTeamMembership(500) 79 | .withAlertState('dependabot', 'open') 80 | .toNock(); 81 | 82 | await probot.receive(PAYLOAD_FIXTURE); 83 | 84 | expect(mock.pendingMocks()).toStrictEqual([]); 85 | }); 86 | 87 | afterEach(() => { 88 | api.resetNetworkMonitoring(); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/app/test/events/secretScanningAlertDismissed.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | mockGitHubApiRequests, 3 | getTestableProbot, 4 | resetNetworkMonitoring 5 | } from '../utils/helpers.js'; 6 | 7 | import event_wont_fix from './../fixtures/secret_scanning_alert/resolved.wont_fix.json'; 8 | 9 | import event_pattern_edited from './../fixtures/secret_scanning_alert/resolved.pattern_edited.json'; 10 | 11 | import event_pattern_deleted from './../fixtures/secret_scanning_alert/resolved.pattern_deleted.json'; 12 | 13 | // The alerts which must be ignored 14 | const IGNORED_SECRET_ALERTS = [event_pattern_deleted, event_pattern_edited]; 15 | 16 | describe('When secret scanning alerts are received', () => { 17 | // Use the any type to avoid issues with additional fields in the payload that Probot cannot recognize 18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 | let probot: any; 20 | 21 | beforeEach(() => { 22 | probot = getTestableProbot(); 23 | }); 24 | 25 | test.each(['maintainer', 'member'])( 26 | `ignores alerts closed by a %s in the approving team`, 27 | async (role: string) => { 28 | const mock = mockGitHubApiRequests() 29 | .canRetrieveAccessToken() 30 | .isInApprovingTeam(role) 31 | .toNock(); 32 | 33 | await probot.receive({ 34 | name: 'secret_scanning_alert', 35 | payload: event_wont_fix.payload 36 | }); 37 | 38 | expect(mock.pendingMocks()).toStrictEqual([]); 39 | } 40 | ); 41 | 42 | test('opens alerts closed by non-member of the approving team', async () => { 43 | const mock = mockGitHubApiRequests() 44 | .canRetrieveAccessToken() 45 | .isNotInApprovingTeam() 46 | .withAlertState('secret-scanning', 'open') 47 | .toNock(); 48 | 49 | // Receive a webhook event 50 | await probot.receive({ 51 | name: 'secret_scanning_alert', 52 | payload: event_wont_fix.payload 53 | }); 54 | 55 | expect(mock.pendingMocks()).toStrictEqual([]); 56 | }); 57 | 58 | test('opens alerts if membership request returns a 500 error', async () => { 59 | const mock = mockGitHubApiRequests() 60 | .canRetrieveAccessToken() 61 | .errorRetrievingTeamMembership(500) 62 | .withAlertState('secret-scanning', 'open') 63 | .toNock(); 64 | 65 | await probot.receive({ 66 | name: 'secret_scanning_alert', 67 | payload: event_wont_fix.payload 68 | }); 69 | 70 | expect(mock.pendingMocks()).toStrictEqual([]); 71 | }); 72 | 73 | test.each( 74 | IGNORED_SECRET_ALERTS.map(t => [t.payload.alert.resolution, t.payload]) 75 | )('ignores custom pattern resolution %s', async (event_type, payload) => { 76 | const mock = mockGitHubApiRequests().canRetrieveAccessToken().toNock(); 77 | expect(event_type).toBeDefined(); 78 | // Receive a webhook event 79 | await probot.receive({name: 'secret_scanning_alert', payload: payload}); 80 | 81 | expect(mock.pendingMocks()).toStrictEqual([]); 82 | }); 83 | 84 | afterEach(() => { 85 | resetNetworkMonitoring(); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /packages/app/test/fixtures/installation/created.json: -------------------------------------------------------------------------------- 1 | { 2 | "event": "installation", 3 | "payload": { 4 | "action": "created", 5 | "installation": { 6 | "id": 10000004, 7 | "account": { 8 | "login": "_orgname", 9 | "id": 100000002, 10 | "node_id": "O_OrgNodeId==", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/100000002?v=4", 12 | "gravatar_id": "", 13 | "url": "https://api.github.com/users/_orgname", 14 | "html_url": "https://github.com/_orgname", 15 | "followers_url": "https://api.github.com/users/_orgname/followers", 16 | "following_url": "https://api.github.com/users/_orgname/following{/other_user}", 17 | "gists_url": "https://api.github.com/users/_orgname/gists{/gist_id}", 18 | "starred_url": "https://api.github.com/users/_orgname/starred{/owner}{/repo}", 19 | "subscriptions_url": "https://api.github.com/users/_orgname/subscriptions", 20 | "organizations_url": "https://api.github.com/users/_orgname/orgs", 21 | "repos_url": "https://api.github.com/users/_orgname/repos", 22 | "events_url": "https://api.github.com/users/_orgname/events{/privacy}", 23 | "received_events_url": "https://api.github.com/users/_orgname/received_events", 24 | "type": "Organization", 25 | "site_admin": false 26 | }, 27 | "repository_selection": "selected", 28 | "access_tokens_url": "https://api.github.com/app/installations/10000003/access_tokens", 29 | "repositories_url": "https://api.github.com/installation/repositories", 30 | "html_url": "https://github.com/organizations/_orgname/settings/installations/10000003", 31 | "app_id": 10000004, 32 | "app_slug": "security-alert-watcher", 33 | "target_id": 100000002, 34 | "target_type": "Organization", 35 | "permissions": { 36 | "members": "read", 37 | "metadata": "read", 38 | "security_events": "write", 39 | "vulnerability_alerts": "write", 40 | "secret_scanning_alerts": "write" 41 | }, 42 | "events": ["code_scanning_alert", "secret_scanning_alert"], 43 | "created_at": "2023-02-11T19:24:12.000-05:00", 44 | "updated_at": "2023-02-11T19:24:12.000-05:00", 45 | "single_file_name": null, 46 | "has_multiple_single_files": false, 47 | "single_file_paths": [], 48 | "suspended_by": null, 49 | "suspended_at": null 50 | }, 51 | "repositories": [ 52 | { 53 | "id": 100000001, 54 | "node_id": "R_RepoNodeId==", 55 | "name": "_myrepo", 56 | "full_name": "_orgname/_myrepo", 57 | "private": true 58 | } 59 | ], 60 | "requester": null, 61 | "sender": { 62 | "login": "_magicuser", 63 | "id": 100000003, 64 | "node_id": "MDUserNodeID==", 65 | "avatar_url": "https://avatars.githubusercontent.com/u/100000003?v=4", 66 | "gravatar_id": "", 67 | "url": "https://api.github.com/users/_magicuser", 68 | "html_url": "https://github.com/_magicuser", 69 | "followers_url": "https://api.github.com/users/_magicuser/followers", 70 | "following_url": "https://api.github.com/users/_magicuser/following{/other_user}", 71 | "gists_url": "https://api.github.com/users/_magicuser/gists{/gist_id}", 72 | "starred_url": "https://api.github.com/users/_magicuser/starred{/owner}{/repo}", 73 | "subscriptions_url": "https://api.github.com/users/_magicuser/subscriptions", 74 | "organizations_url": "https://api.github.com/users/_magicuser/orgs", 75 | "repos_url": "https://api.github.com/users/_magicuser/repos", 76 | "events_url": "https://api.github.com/users/_magicuser/events{/privacy}", 77 | "received_events_url": "https://api.github.com/users/_magicuser/received_events", 78 | "type": "User", 79 | "site_admin": true 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/app/test/fixtures/installation/new_permissions_accepted.json: -------------------------------------------------------------------------------- 1 | { 2 | "event": "installation", 3 | "payload": { 4 | "action": "new_permissions_accepted", 5 | "installation": { 6 | "id": 10000004, 7 | "account": { 8 | "login": "_orgname", 9 | "id": 100000002, 10 | "node_id": "O_OrgNodeId==", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/100000002?v=4", 12 | "gravatar_id": "", 13 | "url": "https://api.github.com/users/_orgname", 14 | "html_url": "https://github.com/_orgname", 15 | "followers_url": "https://api.github.com/users/_orgname/followers", 16 | "following_url": "https://api.github.com/users/_orgname/following{/other_user}", 17 | "gists_url": "https://api.github.com/users/_orgname/gists{/gist_id}", 18 | "starred_url": "https://api.github.com/users/_orgname/starred{/owner}{/repo}", 19 | "subscriptions_url": "https://api.github.com/users/_orgname/subscriptions", 20 | "organizations_url": "https://api.github.com/users/_orgname/orgs", 21 | "repos_url": "https://api.github.com/users/_orgname/repos", 22 | "events_url": "https://api.github.com/users/_orgname/events{/privacy}", 23 | "received_events_url": "https://api.github.com/users/_orgname/received_events", 24 | "type": "Organization", 25 | "site_admin": false 26 | }, 27 | "repository_selection": "selected", 28 | "access_tokens_url": "https://api.github.com/app/installations/10000003/access_tokens", 29 | "repositories_url": "https://api.github.com/installation/repositories", 30 | "html_url": "https://github.com/organizations/_orgname/settings/installations/10000003", 31 | "app_id": 10000004, 32 | "app_slug": "security-alert-watcher", 33 | "target_id": 100000002, 34 | "target_type": "Organization", 35 | "permissions": { 36 | "members": "read", 37 | "metadata": "read", 38 | "security_events": "write", 39 | "vulnerability_alerts": "write", 40 | "secret_scanning_alerts": "write" 41 | }, 42 | "events": ["dependabot_alert", "secret_scanning_alert"], 43 | "created_at": "2023-02-11T19:24:12.000-05:00", 44 | "updated_at": "2023-02-11T19:32:56.000-05:00", 45 | "single_file_name": null, 46 | "has_multiple_single_files": false, 47 | "single_file_paths": [], 48 | "suspended_by": null, 49 | "suspended_at": null 50 | }, 51 | "sender": { 52 | "login": "_orgname", 53 | "id": 100000002, 54 | "node_id": "O_OrgNodeId==", 55 | "avatar_url": "https://avatars.githubusercontent.com/u/100000002?v=4", 56 | "gravatar_id": "", 57 | "url": "https://api.github.com/users/_orgname", 58 | "html_url": "https://github.com/_orgname", 59 | "followers_url": "https://api.github.com/users/_orgname/followers", 60 | "following_url": "https://api.github.com/users/_orgname/following{/other_user}", 61 | "gists_url": "https://api.github.com/users/_orgname/gists{/gist_id}", 62 | "starred_url": "https://api.github.com/users/_orgname/starred{/owner}{/repo}", 63 | "subscriptions_url": "https://api.github.com/users/_orgname/subscriptions", 64 | "organizations_url": "https://api.github.com/users/_orgname/orgs", 65 | "repos_url": "https://api.github.com/users/_orgname/repos", 66 | "events_url": "https://api.github.com/users/_orgname/events{/privacy}", 67 | "received_events_url": "https://api.github.com/users/_orgname/received_events", 68 | "type": "Organization", 69 | "site_admin": false 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/app/test/fixtures/installation_repositories/added.json: -------------------------------------------------------------------------------- 1 | { 2 | "event": "installation_repositories", 3 | "payload": { 4 | "action": "added", 5 | "installation": { 6 | "id": 10000004, 7 | "account": { 8 | "login": "_orgname", 9 | "id": 100000002, 10 | "node_id": "O_OrgNodeId==", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/100000002?v=4", 12 | "gravatar_id": "", 13 | "url": "https://api.github.com/users/_orgname", 14 | "html_url": "https://github.com/_orgname", 15 | "followers_url": "https://api.github.com/users/_orgname/followers", 16 | "following_url": "https://api.github.com/users/_orgname/following{/other_user}", 17 | "gists_url": "https://api.github.com/users/_orgname/gists{/gist_id}", 18 | "starred_url": "https://api.github.com/users/_orgname/starred{/owner}{/repo}", 19 | "subscriptions_url": "https://api.github.com/users/_orgname/subscriptions", 20 | "organizations_url": "https://api.github.com/users/_orgname/orgs", 21 | "repos_url": "https://api.github.com/users/_orgname/repos", 22 | "events_url": "https://api.github.com/users/_orgname/events{/privacy}", 23 | "received_events_url": "https://api.github.com/users/_orgname/received_events", 24 | "type": "Organization", 25 | "site_admin": false 26 | }, 27 | "repository_selection": "selected", 28 | "access_tokens_url": "https://api.github.com/app/installations/10000003/access_tokens", 29 | "repositories_url": "https://api.github.com/installation/repositories", 30 | "html_url": "https://github.com/organizations/_orgname/settings/installations/10000003", 31 | "app_id": 10000004, 32 | "app_slug": "security-alert-watcher", 33 | "target_id": 100000002, 34 | "target_type": "Organization", 35 | "permissions": { 36 | "members": "read", 37 | "metadata": "read", 38 | "security_events": "write", 39 | "vulnerability_alerts": "write", 40 | "secret_scanning_alerts": "write" 41 | }, 42 | "events": [ 43 | "code_scanning_alert", 44 | "dependabot_alert", 45 | "secret_scanning_alert" 46 | ], 47 | "created_at": "2023-02-08T17:06:09.000-05:00", 48 | "updated_at": "2023-02-08T17:13:09.000-05:00", 49 | "single_file_name": null, 50 | "has_multiple_single_files": false, 51 | "single_file_paths": [], 52 | "suspended_by": null, 53 | "suspended_at": null 54 | }, 55 | "repository_selection": "selected", 56 | "repositories_added": [ 57 | { 58 | "id": 100000001, 59 | "node_id": "R_RepoNodeId==", 60 | "name": "_myrepo", 61 | "full_name": "_orgname/_myrepo", 62 | "private": true 63 | } 64 | ], 65 | "repositories_removed": [], 66 | "requester": null, 67 | "sender": { 68 | "login": "_magicuser", 69 | "id": 100000003, 70 | "node_id": "MDUserNodeID==", 71 | "avatar_url": "https://avatars.githubusercontent.com/u/100000003?v=4", 72 | "gravatar_id": "", 73 | "url": "https://api.github.com/users/_magicuser", 74 | "html_url": "https://github.com/_magicuser", 75 | "followers_url": "https://api.github.com/users/_magicuser/followers", 76 | "following_url": "https://api.github.com/users/_magicuser/following{/other_user}", 77 | "gists_url": "https://api.github.com/users/_magicuser/gists{/gist_id}", 78 | "starred_url": "https://api.github.com/users/_magicuser/starred{/owner}{/repo}", 79 | "subscriptions_url": "https://api.github.com/users/_magicuser/subscriptions", 80 | "organizations_url": "https://api.github.com/users/_magicuser/orgs", 81 | "repos_url": "https://api.github.com/users/_magicuser/repos", 82 | "events_url": "https://api.github.com/users/_magicuser/events{/privacy}", 83 | "received_events_url": "https://api.github.com/users/_magicuser/received_events", 84 | "type": "User", 85 | "site_admin": true 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /packages/app/test/fixtures/mock-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAli7V49NdZe+XYC1pLaHM0te8kiDmZBJ1u2HJHN8GdbROB6NO 3 | VpC3xK7NxQn6xpvZ9ux20NvcDvGle+DOptZztBH+np6h2jZQ1/kD1yG1eQvVH4th 4 | /9oqHuIjmIfO8lIe4Hyd5Fw5xHkGqVETTGR+0c7kdZIlHmkOregUGtMYZRUi4YG+ 5 | q0w+uFemiHpGKXbeCIAvkq7aIkisEzvPWfSyYdA6WJHpxFk7tD7D8VkzABLVRHCq 6 | AuyqPG39BhGZcGLXx5rGK56kDBJkyTR1t3DkHpwX+JKNG5UYNwOG4LcQj1fteeta 7 | TdkYUMjIyWbanlMYyC+dq7B5fe7el99jXQ1gXwIDAQABAoIBADKfiPOpzKLOtzzx 8 | MbHzB0LO+75aHq7+1faayJrVxqyoYWELuB1P3NIMhknzyjdmU3t7S7WtVqkm5Twz 9 | lBUC1q+NHUHEgRQ4GNokExpSP4SU63sdlaQTmv0cBxmkNarS6ZuMBgDy4XoLvaYX 10 | MSUf/uukDLhg0ehFS3BteVFtdJyllhDdTenF1Nb1rAeN4egt8XLsE5NQDr1szFEG 11 | xH5lb+8EDtzgsGpeIddWR64xP0lDIKSZWst/toYKWiwjaY9uZCfAhvYQ1RsO7L/t 12 | sERmpYgh+rAZUh/Lr98EI8BPSPhzFcSHmtqzzejvC5zrZPHcUimz0CGA3YBiLoJX 13 | V1OrxmECgYEAxkd8gpmVP+LEWB3lqpSvJaXcGkbzcDb9m0OPzHUAJDZtiIIf0UmO 14 | nvL68/mzbCHSj+yFjZeG1rsrAVrOzrfDCuXjAv+JkEtEx0DIevU1u60lGnevOeky 15 | r8Be7pmymFB9/gzQAd5ezIlTv/COgoO986a3h1yfhzrrzbqSiivw308CgYEAwecI 16 | aZZwqH3GifR+0+Z1B48cezA5tC8LZt5yObGzUfxKTWy30d7lxe9N59t0KUVt/QL5 17 | qVkd7mqGzsUMyxUN2U2HVnFTWfUFMhkn/OnCnayhILs8UlCTD2Xxoy1KbQH/9FIr 18 | xf0pbMNJLXeGfyRt/8H+BzSZKBw9opJBWE4gqfECgYBp9FdvvryHuBkt8UQCRJPX 19 | rWsRy6pY47nf11mnazpZH5Cmqspv3zvMapF6AIxFk0leyYiQolFWvAv+HFV5F6+t 20 | Si1mM8GCDwbA5zh6pEBDewHhw+UqMBh63HSeUhmi1RiOwrAA36CO8i+D2Pt+eQHv 21 | ir52IiPJcs4BUNrv5Q1BdwKBgBHgVNw3LGe8QMOTMOYkRwHNZdjNl2RPOgPf2jQL 22 | d/bFBayhq0jD/fcDmvEXQFxVtFAxKAc+2g2S8J67d/R5Gm/AQAvuIrsWZcY6n38n 23 | pfOXaLt1x5fnKcevpFlg4Y2vM4O416RHNLx8PJDehh3Oo/2CSwMrDDuwbtZAGZok 24 | icphAoGBAI74Tisfn+aeCZMrO8KxaWS5r2CD1KVzddEMRKlJvSKTY+dOCtJ+XKj1 25 | OsZdcDvDC5GtgcywHsYeOWHldgDWY1S8Z/PUo4eK9qBXYBXp3JEZQ1dqzFdz+Txi 26 | rBn2WsFLsxV9j2/ugm0PqWVBcU2bPUCwvaRu3SOms2teaLwGCkhr 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /packages/app/test/handler.test.ts: -------------------------------------------------------------------------------- 1 | import {jest} from '@jest/globals'; 2 | import {Probot} from 'probot'; 3 | import { 4 | mockGitHubApiRequests, 5 | getDefaultProbotOptions, 6 | resetNetworkMonitoring 7 | } from './utils/helpers.js'; 8 | import {ProbotHandler, WebhookEventRequest} from '../src/handler.js'; 9 | import {app} from '../src/index.js'; 10 | 11 | import installation_repositories_event from './fixtures/installation_repositories/added.json'; 12 | 13 | // Dummy values enough to pass the tests, but NOT enough to pass signature verification 14 | const mininumHeaders = { 15 | 'x-github-event': 'installation_repositories', 16 | 'x-github-delivery': '3f4d4f4d-3d3d-3d3d-3d3d-3d3d3d3d3d3d', 17 | 'x-hub-signature-256': 'sha256=dummysha256value' 18 | }; 19 | 20 | describe('The serverless handler', () => { 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | let probot: any; // Must use any type to avoid strong requirements for mock 23 | 24 | beforeEach(() => { 25 | const options = getDefaultProbotOptions(); 26 | probot = new Probot(options); 27 | jest.clearAllMocks(); 28 | }); 29 | 30 | afterEach(() => { 31 | resetNetworkMonitoring(); 32 | }); 33 | 34 | test('handler returns error if the signature is missing', async () => { 35 | const mock = mockGitHubApiRequests().toNock(); 36 | const handler = await ProbotHandler.create(probot, app); 37 | const result = await handler.process({ 38 | body: JSON.stringify(installation_repositories_event.payload), 39 | headers: {test: 'true'} 40 | }); 41 | expect(result.status).toStrictEqual(400); 42 | expect(mock.pendingMocks()).toStrictEqual([]); 43 | }); 44 | 45 | test('handler returns error if the event is empty', async () => { 46 | const mock = mockGitHubApiRequests().toNock(); 47 | const handler = await ProbotHandler.create(probot, app); 48 | const result = await handler.process({} as WebhookEventRequest); 49 | expect(result.status).toStrictEqual(400); 50 | expect(mock.pendingMocks()).toStrictEqual([]); 51 | }); 52 | 53 | test('handler returns error if the event body is empty', async () => { 54 | const mock = mockGitHubApiRequests().toNock(); 55 | const handler = await ProbotHandler.create(probot, app); 56 | const result = await handler.process({body: '', headers: {test: 'true'}}); 57 | expect(result.status).toStrictEqual(400); 58 | expect(mock.pendingMocks()).toStrictEqual([]); 59 | }); 60 | 61 | test('handler returns error if the event body is undefined', async () => { 62 | const mock = mockGitHubApiRequests().toNock(); 63 | const handler = await ProbotHandler.create(probot, app); 64 | const result = await handler.process({ 65 | body: undefined, 66 | headers: {test: 'true'} 67 | }); 68 | expect(result.status).toStrictEqual(400); 69 | expect(mock.pendingMocks()).toStrictEqual([]); 70 | }); 71 | 72 | test('handler supports undefined header values', async () => { 73 | const mock = mockGitHubApiRequests().toNock(); 74 | const handler = await ProbotHandler.create(probot, app); 75 | const result = await handler.process({ 76 | body: 'hello', 77 | headers: {test: undefined} 78 | }); 79 | expect(result.status).toStrictEqual(400); 80 | expect(mock.pendingMocks()).toStrictEqual([]); 81 | }); 82 | 83 | test('handler returns error if does not receive required headers', async () => { 84 | const mock = mockGitHubApiRequests().toNock(); 85 | const errorMessage = JSON.stringify({message: 'Missing required headers'}); 86 | const handler = await ProbotHandler.create(probot, app); 87 | probot.webhooks.verifyAndReceive = jest.fn().mockImplementation(() => { 88 | throw errorMessage; 89 | }); 90 | const result = await handler.process({ 91 | body: 'hello', 92 | headers: {test: 'true'} 93 | }); 94 | expect(result.status).toStrictEqual(400); 95 | expect(result.body).toStrictEqual(errorMessage); 96 | expect(mock.pendingMocks()).toStrictEqual([]); 97 | }); 98 | 99 | test('handler catches unexpected error types and returns the string', async () => { 100 | const mock = mockGitHubApiRequests().toNock(); 101 | const testMessage = 'This is an error'; 102 | const handler = await ProbotHandler.create(probot, app); 103 | probot.webhooks.verifyAndReceive = jest.fn().mockImplementation(() => { 104 | throw testMessage; 105 | }); 106 | const result = await handler.process({ 107 | body: 'hello', 108 | headers: mininumHeaders 109 | }); 110 | expect(result.status).toStrictEqual(400); 111 | expect(result.body).toStrictEqual(JSON.stringify({message: testMessage})); 112 | expect(mock.pendingMocks()).toStrictEqual([]); 113 | }); 114 | 115 | test('handler returns error if the event body is undefined', async () => { 116 | const mock = mockGitHubApiRequests().toNock(); 117 | const errorMessage = JSON.stringify({ 118 | message: 'Missing event body or headers' 119 | }); 120 | const handler = await ProbotHandler.create(probot, app); 121 | const result = await handler.process({ 122 | body: undefined, 123 | headers: {test: 'true'} 124 | }); 125 | expect(result.status).toStrictEqual(400); 126 | expect(result.body).toStrictEqual(errorMessage); 127 | expect(mock.pendingMocks()).toStrictEqual([]); 128 | }); 129 | 130 | test('embedded probot receives messages to be processed', async () => { 131 | const mock = mockGitHubApiRequests().toNock(); 132 | probot.webhooks.verifyAndReceive = jest 133 | .fn() 134 | .mockImplementation(() => Promise.resolve()); 135 | const handler = await ProbotHandler.create(probot, app); 136 | const result = await handler.process({ 137 | body: JSON.stringify(installation_repositories_event.payload), 138 | headers: mininumHeaders 139 | }); 140 | expect(result.status).toEqual(200); 141 | expect(mock.pendingMocks()).toStrictEqual([]); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /packages/app/test/utils/services.ts: -------------------------------------------------------------------------------- 1 | import {jest} from '@jest/globals'; 2 | 3 | // Dynamically loaded import to access the original configuration 4 | const configOriginal = await import('../../src/config/index.js'); 5 | 6 | /** 7 | * Helper function to ensure that the services loaded by 8 | * helpers are using the mocked configuration. If the helpers 9 | * are loaded before the configuration is mocked, the original 10 | * configuration implementation will be used since ESM module loads 11 | * are read-only. 12 | */ 13 | async function getApiMocks() { 14 | const helpers = await import('../utils/helpers.js'); 15 | return helpers; 16 | } 17 | 18 | /** 19 | * Gets the mocked services and probot instance used for testing 20 | * with support for mocking the configuration used by those services. 21 | * @returns the mocked testing service components 22 | */ 23 | export async function getConfigurableMockServices() { 24 | // Unstable_mockModule is used due to a bug/limitation in jest.mock 25 | // that prevents the ESM modules from properly loading and being mocked. 26 | // This usage is centralized here so that it can eventually be replaced 27 | // with a stable implementation (once available). 28 | jest.unstable_mockModule('../../src/config/index.js', async () => { 29 | return { 30 | ...(configOriginal ?? {}), 31 | getConfiguration: jest.fn(configOriginal.getConfiguration) 32 | }; 33 | }); 34 | const config = await import('../../src/config/index.js'); 35 | const api = await getApiMocks(); 36 | const probot = api.getTestableProbot(); 37 | return { 38 | config, 39 | api, 40 | probot 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /packages/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationMap": true, 6 | "outDir": "./dist", 7 | "composite": true, 8 | "sourceMap": true 9 | }, 10 | "include": ["src/**/*.ts"], 11 | "exclude": ["test/**/*", "esbuild.config.mjs"], 12 | "compileOnSave": false 13 | } 14 | -------------------------------------------------------------------------------- /packages/aws/esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import { dirname } from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import config from '../../esbuild.config.mjs'; 5 | 6 | const currentDir = dirname(fileURLToPath(import.meta.url)); 7 | 8 | const settings = { ...config, ...{ 9 | entryPoints: ['./src/index.ts'], 10 | absWorkingDir: currentDir 11 | }}; 12 | 13 | await esbuild.build(settings); 14 | -------------------------------------------------------------------------------- /packages/aws/jest.config.mjs: -------------------------------------------------------------------------------- 1 | import {default as config} from '../../jest.config.mjs'; 2 | 3 | const ignored = config.coveragePathIgnorePatterns; 4 | 5 | // Include index.ts in coverage since it's not just an entry point 6 | config.coveragePathIgnorePatterns = ignored.filter((pattern) => !pattern.includes('/src/index.ts')); 7 | export default config; 8 | -------------------------------------------------------------------------------- /packages/aws/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@security-alert-watcher/aws-lambda", 3 | "version": "2.0.0", 4 | "private": true, 5 | "description": "Sample GH App which monitors and enforces rules for code scanning alerts", 6 | "author": "Ken Muse (https://www.kenmuse.com)", 7 | "license": "MIT", 8 | "homepage": "https://github.com/advanced-security/probot-codescan-alerts", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/advanced-security/probot-codescan-alerts.git", 12 | "directory": "packages/aws" 13 | }, 14 | "bugs": "https://github.com/advanced-security/probot-codescan-alerts/issues", 15 | "keywords": [ 16 | "aws", 17 | "lambda", 18 | "probot", 19 | "github", 20 | "probot-app" 21 | ], 22 | "type": "module", 23 | "files": [ 24 | "dist/**" 25 | ], 26 | "exports": { 27 | ".": { 28 | "import": "./dist/index.mjs" 29 | } 30 | }, 31 | "scripts": { 32 | "build": "yarn run prebuild && sam build --no-cached", 33 | "build:container": "yarn run prebuild && sam build --use-container --no-cached --parallel", 34 | "clean": "yarn g:clean && rm -rf .aws-sam", 35 | "format": "yarn g:format", 36 | "format:check": "yarn g:format-check", 37 | "lint": "yarn eslint && sam validate --lint", 38 | "test": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings=ExperimentalWarning ${NODE_OPTIONS}\" yarn jest --config=${INIT_CWD}/jest.config.mjs --rootDir=${INIT_CWD} --detectOpenHandles", 39 | "deploy": "sam deploy --guided", 40 | "package": "yarn run build && mkdir ${INIT_CWD}/publish && cd ${INIT_CWD}/.aws-sam/build && zip -r ${INIT_CWD}/publish/package.zip .", 41 | "start": "sam local start-lambda", 42 | "copyEnv": "yarn g:copyEnv", 43 | "prebuild": "yarn node esbuild.config.mjs" 44 | }, 45 | "dependencies": { 46 | "esbuild": "^0.25.2", 47 | "probot": "^13.4.4" 48 | }, 49 | "devDependencies": { 50 | "@aws-sdk/client-lambda": "^3.777.0", 51 | "@eslint/js": "^9.23.0", 52 | "@jest/environment": "^29.7.0", 53 | "@jest/globals": "^29.7.0", 54 | "@jest/types": "^29.6.3", 55 | "@types/aws-lambda": "^8.10.148", 56 | "@types/jest": "^29.5.14", 57 | "@types/node": "^22.13.14", 58 | "@typescript-eslint/eslint-plugin": "^8.28.0", 59 | "@typescript-eslint/parser": "^8.28.0", 60 | "aws-sdk": "^2.1692.0", 61 | "axios": "^1.8.4", 62 | "eslint": "^9.23.0", 63 | "jest": "^29.7.0", 64 | "jest-circus": "^29.7.0", 65 | "jest-environment-node": "^29.7.0", 66 | "mockserver-client": "^5.15.0", 67 | "nock": "^14.0.2", 68 | "ts-jest": "^29.3.0", 69 | "ts-jest-resolver": "^2.0.1", 70 | "tslib": "^2.8.1", 71 | "typescript": "^5.8.2", 72 | "typescript-eslint": "^8.28.0", 73 | "yaml": "^2.7.1" 74 | }, 75 | "engines": { 76 | "node": ">= 22.10.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/aws/samconfig.toml: -------------------------------------------------------------------------------- 1 | version = 0.1 2 | [default.deploy.parameters] 3 | stack_name = "security-watcher" 4 | resolve_s3 = true 5 | s3_prefix = "security-watcher" 6 | capabilities = "CAPABILITY_IAM" 7 | parameter_overrides = "" 8 | image_repositories = [] 9 | disable_rollback = false 10 | -------------------------------------------------------------------------------- /packages/aws/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as bot from 'probot'; 2 | import {app, ProbotHandler, WebhookEventRequest} from '../../app/src/index.js'; 3 | import { 4 | APIGatewayProxyEventV2, 5 | APIGatewayProxyStructuredResultV2, 6 | Context 7 | } from 'aws-lambda'; 8 | import {default as node_process} from 'node:process'; 9 | import axios from 'axios'; 10 | import type {} from '../../app/src/config/process.js'; 11 | 12 | // Create an instance of the Probot application and handler 13 | // This ensures a single instance will be created for processing events 14 | let botHandler: Promise | undefined; 15 | 16 | /** 17 | * Configures the handler instance. Private method exported 18 | * to support testing. 19 | */ 20 | export function setHandler(handler: Promise) { 21 | botHandler = handler; 22 | } 23 | 24 | /** 25 | * The Lambda function entry point 26 | */ 27 | export async function process( 28 | event: APIGatewayProxyEventV2, 29 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 30 | _context: Context 31 | ): Promise { 32 | logDebug('Acquire instance'); 33 | const processor = await getHandlerInstance(); 34 | logDebug('Processing request'); 35 | const response = await processor.process(event as WebhookEventRequest); 36 | logDebug(`Returning status ${response.status}`); 37 | return { 38 | statusCode: response.status, 39 | body: response.body 40 | }; 41 | } 42 | 43 | // The public implementation, which delegates requests to the handler instance 44 | export const securityWatcher = process; 45 | 46 | /** 47 | * Logs a debug-level message unless running under Jest. 48 | * @param message The message to log 49 | */ 50 | function logDebug(message: string) { 51 | /*istanbul ignore next: no need to test the logging */ 52 | if (node_process.env.NODE_ENV !== 'test') { 53 | console.debug(message); 54 | } 55 | } 56 | 57 | /** 58 | * Logs an error-level message unless running under Jest. 59 | * @param message The message to log 60 | */ 61 | function logError(message: string) { 62 | console.error(message); 63 | } 64 | 65 | /** 66 | * Retrieves the PEM file from the environment or AWS Secrets Manager. 67 | * @returns the PEM file contents or undefined if there is no value configured 68 | */ 69 | export async function retrievePemSecret() { 70 | logDebug('Retrieving PEM from Secrets Manager'); 71 | const secret = await retrieveAwsSecret(node_process.env.PRIVATE_KEY_ARN); 72 | const result = secret ?? node_process.env.PRIVATE_KEY; 73 | return result; 74 | } 75 | 76 | /** 77 | * Creates a custom environment object for configuring Probot that includes 78 | * the private key for the GitHub App from AWS Secrets Manager. 79 | * @returns the modified environment 80 | */ 81 | async function createLocalEnv() { 82 | const currentEnv = node_process.env; 83 | const updatedEnv: Partial = { 84 | ...currentEnv, 85 | PRIVATE_KEY: await retrievePemSecret() 86 | }; 87 | 88 | return updatedEnv; 89 | } 90 | 91 | /** 92 | * Retrieves the PEM file secret from AWS secret manager (if available). 93 | * @param arn The ARN for the secrets resource 94 | * @returns the secret, or null if the secret could not be retrieved 95 | */ 96 | async function retrieveAwsSecret( 97 | arn: string | undefined 98 | ): Promise { 99 | const PARAMETERS_SECRETS_EXTENSION_HTTP_PORT = 2773; 100 | logDebug('Retrieving secret from Secrets Manager'); 101 | if (arn) { 102 | logDebug('ARN provided for retrieving secret'); 103 | const token = node_process.env.AWS_SESSION_TOKEN; 104 | if (token) { 105 | logDebug('Retrieving secret from cache'); 106 | const headers = { 107 | 'X-Aws-Parameters-Secrets-Token': token 108 | }; 109 | 110 | const http = axios.create({ 111 | baseURL: `http://localhost:${PARAMETERS_SECRETS_EXTENSION_HTTP_PORT}`, 112 | timeout: 5000 113 | }); 114 | 115 | try { 116 | // Avoid leaking handles by allowing time for axios to prepare calls 117 | await Promise.resolve(node_process.nextTick(() => { /* Do nothing */ })); 118 | 119 | const response = await http.get(`/secretsmanager/get?secretId=${arn}`, { 120 | headers: headers 121 | }); 122 | 123 | if (response.status == 200) { 124 | logDebug('Successfully retrieved PEM from secrets'); 125 | return response.data.SecretString; 126 | } 127 | } catch (error) { 128 | /* istanbul ignore next: no value in testing conversion of message to string */ 129 | const message = error instanceof Error ? error.message : String(error); 130 | logError(`Error retrieving secret: ${message}`); 131 | } 132 | } 133 | } 134 | 135 | // Avoid leaking handles by allowing axios to do error handling cleanup 136 | await Promise.resolve(node_process.nextTick(() => { /* Do nothing */ })); 137 | 138 | logDebug('Unable to retrieve PEM from Secrets Manager.'); 139 | return null; 140 | } 141 | 142 | /** 143 | * Initializes the handler instance as a singleton. This ensures 144 | * the Probot application is only created once per Lambda instance 145 | * For testing, it ensures that the application isn't loaded 146 | * automatically as part of the module import. 147 | */ 148 | async function getHandlerInstance() { 149 | /* istanbul ignore next: no integration test to confirm */ 150 | if (!botHandler) { 151 | const probotOptions = { 152 | overrides: {}, 153 | defaults: {}, 154 | env: await createLocalEnv() 155 | }; 156 | botHandler = ProbotHandler.create(bot.createProbot(probotOptions), app); 157 | } 158 | return await botHandler; 159 | } 160 | -------------------------------------------------------------------------------- /packages/aws/template.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: Security Alert Watcher GitHub App 4 | 5 | ################################ 6 | # Parameters 7 | ################################ 8 | 9 | Parameters: 10 | githubAppId: 11 | Description: The App ID of your GitHub app 12 | Type: String 13 | githubWebhookSecret: 14 | Description: The webhook secret of your GitHub app 15 | Type: String 16 | githubPrivateKey: 17 | Description: The private key of your GitHub app 18 | Type: String 19 | NoEcho: true 20 | githubOrg: 21 | Description: The organization where the app is registered 22 | Type: String 23 | githubHost: 24 | Description: The GitHub host name to use (api.github.com) 25 | Type: String 26 | Default: api.github.com 27 | githubProtocol: 28 | Description: The GitHub API protocol (https) 29 | Type: String 30 | Default: https 31 | AllowedValues: 32 | - http 33 | - https 34 | logLevel: 35 | Description: The logging level 36 | Type: String 37 | Default: info 38 | AllowedValues: 39 | - fatal 40 | - error 41 | - warn 42 | - info 43 | - debug 44 | 45 | ################################ 46 | # Conditions 47 | ################################ 48 | 49 | Conditions: 50 | IsGHEC: 51 | !Or [ 52 | !Equals [!Ref githubHost, 'api.github.com'], 53 | !Equals [!Ref githubHost, ''] 54 | ] 55 | 56 | ################################ 57 | # Mappings 58 | ################################ 59 | 60 | # See Ref: 61 | # https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html#ps-integration-lambda-extensions-add 62 | 63 | Mappings: 64 | RegionToExtensionMap: 65 | us-east-1: 66 | 'Arn': 'arn:aws:lambda:us-east-1:177933569100:layer:AWS-Parameters-and-Secrets-Lambda-Extension:11' 67 | us-east-2: 68 | 'Arn': 'arn:aws:lambda:us-east-2:590474943231:layer:AWS-Parameters-and-Secrets-Lambda-Extension:11' 69 | us-west-1: 70 | 'Arn': 'arn:aws:lambda:us-west-1:997803712105:layer:AWS-Parameters-and-Secrets-Lambda-Extension:11' 71 | us-west-2: 72 | 'Arn': 'arn:aws:lambda:us-west-2:345057560386:layer:AWS-Parameters-and-Secrets-Lambda-Extension:11' 73 | ca-central-1: 74 | 'Arn': 'arn:aws:lambda:ca-central-1:200266452380:layer:AWS-Parameters-and-Secrets-Lambda-Extension:11' 75 | ca-west-1: 76 | 'Arn': 'arn:aws:lambda:ca-west-1:243964427225:layer:AWS-Parameters-and-Secrets-Lambda-Extension:1' 77 | ap-southeast-4: 78 | 'Arn': 'arn:aws:lambda:ap-southeast-4:090732460067:layer:AWS-Parameters-and-Secrets-Lambda-Extension:1' 79 | sa-east-1: 80 | 'Arn': 'arn:aws:lambda:sa-east-1:933737806257:layer:AWS-Parameters-and-Secrets-Lambda-Extension:11' 81 | 82 | ################################ 83 | # Global configurations 84 | ################################ 85 | 86 | Globals: 87 | Function: 88 | Tracing: Active 89 | LoggingConfig: 90 | LogFormat: JSON 91 | Timeout: 10 92 | Api: 93 | TracingEnabled: true 94 | 95 | ################################ 96 | # Resources 97 | ################################ 98 | 99 | Resources: 100 | SecurityWatcher: 101 | DependsOn: 102 | - SecurityWatcherLogGroup 103 | Type: AWS::Serverless::Function 104 | Properties: 105 | FunctionName: !Sub ${AWS::StackName}-webhook 106 | Description: Security watcher 107 | CodeUri: . 108 | Handler: index.securityWatcher 109 | FunctionUrlConfig: 110 | AuthType: NONE 111 | Runtime: nodejs20.x 112 | PackageType: Zip 113 | MemorySize: 256 114 | Timeout: 15 115 | Events: 116 | Api: 117 | Type: Api 118 | Properties: 119 | Path: / 120 | Method: POST 121 | 122 | Environment: 123 | Variables: 124 | APP_ID: !Ref githubAppId 125 | WEBHOOK_SECRET: !Ref githubWebhookSecret 126 | PRIVATE_KEY: !Ref githubPrivateKey 127 | PRIVATE_KEY_ARN: !Ref PemSecretKey 128 | GH_ORG: !Ref githubOrg 129 | NODE_ENV: production 130 | GHE_HOST: !If [IsGHEC, '', !Ref githubHost] 131 | GHE_PROTOCOL: !If [IsGHEC, '', !Ref githubProtocol] 132 | LOG_LEVEL: !Ref logLevel 133 | 134 | Policies: 135 | - AWSSecretsManagerGetSecretValuePolicy: 136 | SecretArn: !Ref PemSecretKey 137 | 138 | Layers: 139 | - !FindInMap [RegionToExtensionMap, !Ref 'AWS::Region', Arn] 140 | 141 | Metadata: 142 | BuildMethod: esbuild 143 | BuildProperties: 144 | Minify: true 145 | Sourcemap: false 146 | Target: 'node20' # ES2022 support 147 | Format: esm 148 | OutExtension: 149 | - .js=.mjs 150 | #Banner: 151 | # - js=import { createRequire } from 'module'; const require = createRequire(import.meta.url); 152 | EntryPoints: 153 | - dist/index.mjs 154 | PemSecretKey: 155 | Type: AWS::SecretsManager::Secret 156 | Properties: 157 | Description: !Sub 'PEM key for ${AWS::StackName}-webhook' 158 | SecretString: !Ref githubPrivateKey 159 | 160 | SecurityWatcherLogGroup: 161 | Type: AWS::Logs::LogGroup 162 | Properties: 163 | LogGroupName: !Sub '/aws/lambda/${AWS::StackName}-webhook' 164 | RetentionInDays: 1 165 | Outputs: 166 | WebhookUrl: 167 | Description: 'Webhook Endpoint URL' 168 | Value: !Sub 'https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/' 169 | LambdaUrl: 170 | Description: 'Direct Lambda Endpoint URL' 171 | Value: !GetAtt SecurityWatcherUrl.FunctionUrl 172 | -------------------------------------------------------------------------------- /packages/aws/test/fixtures/env.emulator.json: -------------------------------------------------------------------------------- 1 | { 2 | "SecurityWatcher": { 3 | "GH_ORG": "_orgname", 4 | "APP_ID": 123, 5 | "PRIVATE_KEY": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAli7V49NdZe+XYC1pLaHM0te8kiDmZBJ1u2HJHN8GdbROB6NO\nVpC3xK7NxQn6xpvZ9ux20NvcDvGle+DOptZztBH+np6h2jZQ1/kD1yG1eQvVH4th\n/9oqHuIjmIfO8lIe4Hyd5Fw5xHkGqVETTGR+0c7kdZIlHmkOregUGtMYZRUi4YG+\nq0w+uFemiHpGKXbeCIAvkq7aIkisEzvPWfSyYdA6WJHpxFk7tD7D8VkzABLVRHCq\nAuyqPG39BhGZcGLXx5rGK56kDBJkyTR1t3DkHpwX+JKNG5UYNwOG4LcQj1fteeta\nTdkYUMjIyWbanlMYyC+dq7B5fe7el99jXQ1gXwIDAQABAoIBADKfiPOpzKLOtzzx\nMbHzB0LO+75aHq7+1faayJrVxqyoYWELuB1P3NIMhknzyjdmU3t7S7WtVqkm5Twz\nlBUC1q+NHUHEgRQ4GNokExpSP4SU63sdlaQTmv0cBxmkNarS6ZuMBgDy4XoLvaYX\nMSUf/uukDLhg0ehFS3BteVFtdJyllhDdTenF1Nb1rAeN4egt8XLsE5NQDr1szFEG\nxH5lb+8EDtzgsGpeIddWR64xP0lDIKSZWst/toYKWiwjaY9uZCfAhvYQ1RsO7L/t\nsERmpYgh+rAZUh/Lr98EI8BPSPhzFcSHmtqzzejvC5zrZPHcUimz0CGA3YBiLoJX\nV1OrxmECgYEAxkd8gpmVP+LEWB3lqpSvJaXcGkbzcDb9m0OPzHUAJDZtiIIf0UmO\nnvL68/mzbCHSj+yFjZeG1rsrAVrOzrfDCuXjAv+JkEtEx0DIevU1u60lGnevOeky\nr8Be7pmymFB9/gzQAd5ezIlTv/COgoO986a3h1yfhzrrzbqSiivw308CgYEAwecI\naZZwqH3GifR+0+Z1B48cezA5tC8LZt5yObGzUfxKTWy30d7lxe9N59t0KUVt/QL5\nqVkd7mqGzsUMyxUN2U2HVnFTWfUFMhkn/OnCnayhILs8UlCTD2Xxoy1KbQH/9FIr\nxf0pbMNJLXeGfyRt/8H+BzSZKBw9opJBWE4gqfECgYBp9FdvvryHuBkt8UQCRJPX\nrWsRy6pY47nf11mnazpZH5Cmqspv3zvMapF6AIxFk0leyYiQolFWvAv+HFV5F6+t\nSi1mM8GCDwbA5zh6pEBDewHhw+UqMBh63HSeUhmi1RiOwrAA36CO8i+D2Pt+eQHv\nir52IiPJcs4BUNrv5Q1BdwKBgBHgVNw3LGe8QMOTMOYkRwHNZdjNl2RPOgPf2jQL\nd/bFBayhq0jD/fcDmvEXQFxVtFAxKAc+2g2S8J67d/R5Gm/AQAvuIrsWZcY6n38n\npfOXaLt1x5fnKcevpFlg4Y2vM4O416RHNLx8PJDehh3Oo/2CSwMrDDuwbtZAGZok\nicphAoGBAI74Tisfn+aeCZMrO8KxaWS5r2CD1KVzddEMRKlJvSKTY+dOCtJ+XKj1\nOsZdcDvDC5GtgcywHsYeOWHldgDWY1S8Z/PUo4eK9qBXYBXp3JEZQ1dqzFdz+Txi\nrBn2WsFLsxV9j2/ugm0PqWVBcU2bPUCwvaRu3SOms2teaLwGCkhr\n-----END RSA PRIVATE KEY-----\n", 6 | "WEBHOOK_SECRET": "itsASecret", 7 | "GHE_HOST": "host.docker.internal:5555", 8 | "GHE_PROTOCOL": "http", 9 | "LOG_LEVEL": "debug" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/aws/test/fixtures/event-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "routeKey": "$default", 4 | "rawPath": "/path/to/resource", 5 | "rawQueryString": "parameter1=value1¶meter1=value2¶meter2=value", 6 | "cookies": ["cookie1", "cookie2"], 7 | "headers": { 8 | "Header1": "value1", 9 | "Header2": "value1,value2" 10 | }, 11 | "queryStringParameters": { 12 | "parameter1": "value1,value2", 13 | "parameter2": "value" 14 | }, 15 | "requestContext": { 16 | "accountId": "123456789012", 17 | "apiId": "api-id", 18 | "authentication": { 19 | "clientCert": { 20 | "clientCertPem": "CERT_CONTENT", 21 | "subjectDN": "www.example.com", 22 | "issuerDN": "Example issuer", 23 | "serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1", 24 | "validity": { 25 | "notBefore": "May 28 12:30:02 2019 GMT", 26 | "notAfter": "Aug 5 09:36:04 2021 GMT" 27 | } 28 | } 29 | }, 30 | "authorizer": { 31 | "jwt": { 32 | "claims": { 33 | "claim1": "value1", 34 | "claim2": "value2" 35 | }, 36 | "scopes": ["scope1", "scope2"] 37 | } 38 | }, 39 | "domainName": "id.execute-api.us-east-1.amazonaws.com", 40 | "domainPrefix": "id", 41 | "http": { 42 | "method": "POST", 43 | "path": "/path/to/resource", 44 | "protocol": "HTTP/1.1", 45 | "sourceIp": "192.168.0.1/32", 46 | "userAgent": "agent" 47 | }, 48 | "requestId": "id", 49 | "routeKey": "$default", 50 | "stage": "$default", 51 | "time": "12/Mar/2020:19:03:58 +0000", 52 | "timeEpoch": 1583348638390 53 | }, 54 | "body": "eyJ0ZXN0IjoiYm9keSJ9", 55 | "pathParameters": { 56 | "parameter1": "value1" 57 | }, 58 | "isBase64Encoded": true, 59 | "stageVariables": { 60 | "stageVariable1": "value1", 61 | "stageVariable2": "value2" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/aws/test/fixtures/event.headers.txt: -------------------------------------------------------------------------------- 1 | X-GitHub-Hook-ID: 123456 2 | X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958 3 | X-GitHub-Event: code_scanning_alert 4 | X-Hub-Signature-256: sha256=562f3369788dc3700c295246e9c0095f94018d0aceb88b90c9540ab7d61dc22d 5 | X-GitHub-Hook-Installation-Target-ID: 79929171 6 | X-GitHub-Hook-Installation-Target-Type: repository 7 | Content-Type: application/json 8 | -------------------------------------------------------------------------------- /packages/aws/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import {createAlertClosedFixtureMessage} from './utils/fixtures.js'; 2 | import nock from 'nock'; 3 | import {jest} from '@jest/globals'; 4 | 5 | /** 6 | * Verifies Lambda processing behavior using mocked handler. 7 | */ 8 | describe('AWS Lambda mocked handler', () => { 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | let api: any; 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | let lambda: any; 13 | 14 | beforeEach(async () => { 15 | api = await import('../../app/test/utils/helpers.js'); 16 | const {app, ProbotHandler} = await import('../../app/src/index.js'); 17 | const probot = api.getTestableProbot(); 18 | const handler = ProbotHandler.create(probot, app); 19 | lambda = await import('../src/index.js'); 20 | lambda.setHandler(handler); 21 | }); 22 | 23 | afterEach(async () => { 24 | api.resetNetworkMonitoring(); 25 | jest.resetAllMocks(); 26 | }); 27 | 28 | test('Can receive message', async () => { 29 | const mock = api 30 | .mockGitHubApiRequests() 31 | .canRetrieveAccessToken() 32 | .isNotInApprovingTeam() 33 | .withAlertState('code-scanning', 'open') 34 | .toNock() 35 | .persist(); 36 | 37 | const payload = await createAlertClosedFixtureMessage(); 38 | 39 | const response = await lambda.process(payload, payload.requestContext); 40 | expect(mock.pendingMocks()).toStrictEqual([]); 41 | expect(response.statusCode).toBe(200); 42 | }); 43 | }); 44 | 45 | /** 46 | * Verifies code paths using the default handler 47 | */ 48 | describe('AWS Lambda default handler', () => { 49 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 50 | let lambda: any; 51 | 52 | beforeEach(async () => { 53 | lambda = await import('../src/index.js'); 54 | 55 | // Ensure the handler is reset between tests 56 | lambda.setHandler(undefined); 57 | }); 58 | 59 | /** 60 | * Test failure case to ensure a handler is created. 61 | */ 62 | test('Handler is created', async () => { 63 | const payload = await createAlertClosedFixtureMessage(); 64 | 65 | // Clear the payload to ensure that no API calls are made if the next 66 | // line fails. 67 | payload.body = undefined; 68 | 69 | // This message would be the result of the default handler being created 70 | // without the necessary environment variables. It indicates the default 71 | // handler was created by the code path without exporting that variable. 72 | expect(lambda.process(payload, payload.requestContext)).rejects.toThrow( 73 | '[@octokit/auth-app] appId option is required' 74 | ); 75 | }); 76 | }); 77 | 78 | /** 79 | * Verifies the process for retrieving the PEM secret 80 | **/ 81 | describe('Lambda Secret handling', () => { 82 | let originalEnvironment: NodeJS.ProcessEnv; 83 | let retrievePemSecret: () => Promise; 84 | const SECRET = '---BEGIN PRIVATE KEY---'; 85 | const SECRET_TOKEN = 'SECRET_TOKEN'; 86 | const SECRET_ARN = 'arn:test'; 87 | 88 | beforeEach(async () => { 89 | originalEnvironment = {...process.env}; 90 | process.env = { 91 | NODE_ENV: 'test' 92 | }; 93 | const handler = await import('../src/index.js'); 94 | retrievePemSecret = handler.retrievePemSecret; 95 | nock.disableNetConnect(); 96 | }); 97 | 98 | afterEach(() => { 99 | process.env = originalEnvironment; 100 | nock.cleanAll(); 101 | nock.enableNetConnect(); 102 | jest.resetAllMocks(); 103 | jest.resetModules(); 104 | }); 105 | 106 | test('Secrets are loaded from PEM secret', async () => { 107 | process.env.PRIVATE_KEY = SECRET; 108 | const result = await retrievePemSecret(); 109 | expect(result).toEqual(SECRET); 110 | }); 111 | 112 | test('No secret is returned without the env settings', async () => { 113 | const result = await retrievePemSecret(); 114 | expect(result).toBeUndefined(); 115 | }); 116 | 117 | test('Secrets are loaded from the parameter cache service', async () => { 118 | process.env.PRIVATE_KEY_ARN = SECRET_ARN; 119 | process.env.AWS_SESSION_TOKEN = SECRET_TOKEN; 120 | 121 | const api = nock('http://localhost:2773', { 122 | reqheaders: { 123 | 'X-Aws-Parameters-Secrets-Token': (value: string) => 124 | value === SECRET_TOKEN 125 | } 126 | }); 127 | const scope = api 128 | .get('/secretsmanager/get?secretId=arn:test') 129 | .reply(200, {SecretString: SECRET}); 130 | 131 | const result = await retrievePemSecret(); 132 | expect(result).toEqual(SECRET); 133 | expect(scope.isDone()).toBe(true); 134 | }); 135 | 136 | test('Service error returns empty string', async () => { 137 | process.env.PRIVATE_KEY_ARN = SECRET_ARN; 138 | process.env.AWS_SESSION_TOKEN = SECRET_TOKEN; 139 | const errorLog = jest.spyOn(console, 'error').mockImplementation(() => { 140 | /* Do nothing */ 141 | }); 142 | const api = nock('http://localhost:2773', { 143 | reqheaders: { 144 | 'X-Aws-Parameters-Secrets-Token': (value: string) => 145 | value === SECRET_TOKEN 146 | } 147 | }); 148 | 149 | const scope = api 150 | .get('/secretsmanager/get?secretId=arn:test') 151 | .reply(400, {message: 'Something went wrong'}); 152 | 153 | const result = await retrievePemSecret(); 154 | expect(result).toBeUndefined(); 155 | expect(scope.isDone()).toBe(true); 156 | expect(errorLog.mock.calls[0][0]).toContain( 157 | 'status code 400' 158 | ); 159 | }); 160 | 161 | test('Errors calling parameter cache service are logged', async () => { 162 | process.env.PRIVATE_KEY_ARN = SECRET_ARN; 163 | process.env.AWS_SESSION_TOKEN = SECRET_TOKEN; 164 | 165 | // No nocks are set up, so the request will fail 166 | // because of nock disconnecting the network in beforeEach 167 | const errorLog = jest.spyOn(console, 'error').mockImplementation(() => { 168 | /* Do nothing */ 169 | }); 170 | const result = await retrievePemSecret(); 171 | expect(result).toBeUndefined(); 172 | expect(errorLog.mock.calls[0][0]).toContain( 173 | 'Nock: Disallowed net connect for "localhost:2773' 174 | ); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /packages/aws/test/integration.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LambdaClient, 3 | InvokeCommand, 4 | InvocationType, 5 | LogType, 6 | InvokeCommandInput 7 | } from '@aws-sdk/client-lambda'; 8 | 9 | import axios from 'axios'; 10 | import { existsSync } from 'node:fs'; 11 | import { dirname, resolve } from 'node:path'; 12 | import { fileURLToPath } from 'node:url'; 13 | 14 | import * as emulator from './utils/emulator.js'; 15 | import * as mockserver from './utils/mockserver.js'; 16 | import * as fixtures from './utils/fixtures.js'; 17 | 18 | const packageRootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); 19 | 20 | /** 21 | * Ensures the SAM build outputs have been created. These 22 | * are required for the emulator to run. 23 | * @returns true if the build output exists 24 | */ 25 | function samBuildExists() { 26 | const file = resolve(packageRootDir, '.aws-sam', 'build.toml'); 27 | return existsSync(file); 28 | } 29 | 30 | /** 31 | * Ensures the dist directory build outputs have been created. 32 | * @returns true if the build output exists 33 | */ 34 | function distBuildExists() { 35 | const file = resolve(packageRootDir, 'dist', 'index.mjs'); 36 | return existsSync(file); 37 | } 38 | 39 | /** 40 | * Stops the integration tests by throwing an error if 41 | * the build outputs do not exist. The builds are not created 42 | * with each run to minimize the time it takes to run the tests. 43 | * This leaves some responsibility on the developer to ensure 44 | * the builds are up to date when testing locally. 45 | */ 46 | function ensureBuildExists() { 47 | if (!samBuildExists() || !distBuildExists()) { 48 | throw new Error( 49 | `AWS builds do not exist in ${packageRootDir}. Run "yarn build" before running the integration tests.` 50 | ); 51 | } 52 | } 53 | 54 | /** 55 | * Integration tests using Lambda emulator. 56 | */ 57 | describe('Integration: AWS Lambda emulator', () => { 58 | let emulatorProcess: emulator.Emulator; 59 | let apiServer: mockserver.MockServer; 60 | 61 | beforeAll(async () => { 62 | ensureBuildExists(); 63 | const results = await Promise.all([ 64 | mockserver.start(), 65 | emulator.startAwsLambdaEmulator( 66 | mockserver.MockServerSettings.CONTAINER_HOST, 67 | mockserver.MockServerSettings.DEFAULT_PORT 68 | ) 69 | ]); 70 | apiServer = results[0]; 71 | emulatorProcess = results[1]; 72 | }, emulator.EmulatorTimeouts.TEST_START + mockserver.MockServerTimeouts.TEST_START); 73 | 74 | afterAll(async () => { 75 | if (emulatorProcess) { 76 | await emulatorProcess.stop(); 77 | } 78 | if (apiServer) { 79 | await apiServer.stop(); 80 | } 81 | }, emulator.EmulatorTimeouts.TEST_STOP + mockserver.MockServerTimeouts.TEST_STOP); 82 | 83 | test('Can invoke Lambda', async () => { 84 | const client = await mockserver.getMockClientForFixture(apiServer.port); 85 | const lambda = new LambdaClient(emulator.LambdaEmulatorClientSettings); 86 | const payload = await fixtures.createLocalAlertClosedFixtureMessage( 87 | mockserver.MockServerSettings.CONTAINER_HOST, 88 | apiServer.port 89 | ); 90 | const params: InvokeCommandInput = { 91 | FunctionName: 'SecurityWatcher', 92 | InvocationType: InvocationType.RequestResponse, 93 | Payload: JSON.stringify(payload), 94 | LogType: LogType.None 95 | }; 96 | const command = new InvokeCommand(params); 97 | try { 98 | const result = await lambda.send(command); 99 | expect(result.StatusCode).toBe(200); 100 | } catch (e) { 101 | console.log(JSON.stringify(e, null, 2)); 102 | } 103 | expect(await mockserver.mockedApiCallsAreInvoked(client)).toBeNull(); 104 | }, 35000); 105 | }); 106 | 107 | /** 108 | * Integration tests using API Gateway emulator 109 | */ 110 | describe('Integration: AWS API Gateway emulator', () => { 111 | let emulatorProcess: emulator.Emulator; 112 | let apiServer: mockserver.MockServer; 113 | 114 | beforeAll(async () => { 115 | ensureBuildExists(); 116 | const apiPort = mockserver.MockServerSettings.DEFAULT_PORT + 1; 117 | const results = await Promise.all([ 118 | mockserver.start(apiPort), 119 | emulator.startAwsApiGatewayEmulator( 120 | mockserver.MockServerSettings.CONTAINER_HOST, 121 | apiPort 122 | ) 123 | ]); 124 | apiServer = results[0]; 125 | emulatorProcess = results[1]; 126 | }, emulator.EmulatorTimeouts.TEST_START + mockserver.MockServerTimeouts.TEST_START); 127 | 128 | afterAll(async () => { 129 | if (emulatorProcess) { 130 | await emulatorProcess.stop(); 131 | } 132 | if (apiServer) { 133 | await apiServer.stop(); 134 | } 135 | }, emulator.EmulatorTimeouts.TEST_STOP + mockserver.MockServerTimeouts.TEST_STOP); 136 | 137 | test( 138 | 'Can invoke Lambda via API Gateway', 139 | async () => { 140 | const client = await mockserver.getMockClientForFixture(apiServer.port); 141 | 142 | const message = await fixtures.createLocalAlertClosedFixtureMessage( 143 | mockserver.MockServerSettings.CONTAINER_HOST, 144 | apiServer.port 145 | ); 146 | const http = axios.create({ 147 | baseURL: emulator.AwsEmulatorSettings.AWS_API_EMULATOR, 148 | timeout: emulator.EmulatorTimeouts.CLIENT_RESPONSE 149 | }); 150 | 151 | try { 152 | const response = await http.post('/', message.body, { 153 | headers: message.headers 154 | }); 155 | 156 | // Message should be successfully accepted by the emulated gateway 157 | expect(response.status).toBe(200); 158 | 159 | // And expected API server calls should have been made 160 | expect(await mockserver.mockedApiCallsAreInvoked(client)).toBeNull(); 161 | } catch (e) { 162 | // Used to prevent circular serialization of the error until Jest 30 releases 163 | throw new Error(`Error invoking Lambda via API Gateway: ${e}`); 164 | } 165 | }, 166 | emulator.EmulatorTimeouts.TEST_START + 167 | mockserver.MockServerTimeouts.TEST_START 168 | ); 169 | }); 170 | -------------------------------------------------------------------------------- /packages/aws/test/utils/.env.emulator.json: -------------------------------------------------------------------------------- 1 | { 2 | "SecurityWatcher": { 3 | "GH_ORG": "_orgname", 4 | "APP_ID": "123", 5 | "PRIVATE_KEY": "-----BEGIN RSA PRIVATE KEY-----\\nMIIEowIBAAKCAQEAli7V49NdZe+XYC1pLaHM0te8kiDmZBJ1u2HJHN8GdbROB6NO\\nVpC3xK7NxQn6xpvZ9ux20NvcDvGle+DOptZztBH+np6h2jZQ1/kD1yG1eQvVH4th\\n/9oqHuIjmIfO8lIe4Hyd5Fw5xHkGqVETTGR+0c7kdZIlHmkOregUGtMYZRUi4YG+\\nq0w+uFemiHpGKXbeCIAvkq7aIkisEzvPWfSyYdA6WJHpxFk7tD7D8VkzABLVRHCq\\nAuyqPG39BhGZcGLXx5rGK56kDBJkyTR1t3DkHpwX+JKNG5UYNwOG4LcQj1fteeta\\nTdkYUMjIyWbanlMYyC+dq7B5fe7el99jXQ1gXwIDAQABAoIBADKfiPOpzKLOtzzx\\nMbHzB0LO+75aHq7+1faayJrVxqyoYWELuB1P3NIMhknzyjdmU3t7S7WtVqkm5Twz\\nlBUC1q+NHUHEgRQ4GNokExpSP4SU63sdlaQTmv0cBxmkNarS6ZuMBgDy4XoLvaYX\\nMSUf/uukDLhg0ehFS3BteVFtdJyllhDdTenF1Nb1rAeN4egt8XLsE5NQDr1szFEG\\nxH5lb+8EDtzgsGpeIddWR64xP0lDIKSZWst/toYKWiwjaY9uZCfAhvYQ1RsO7L/t\\nsERmpYgh+rAZUh/Lr98EI8BPSPhzFcSHmtqzzejvC5zrZPHcUimz0CGA3YBiLoJX\\nV1OrxmECgYEAxkd8gpmVP+LEWB3lqpSvJaXcGkbzcDb9m0OPzHUAJDZtiIIf0UmO\\nnvL68/mzbCHSj+yFjZeG1rsrAVrOzrfDCuXjAv+JkEtEx0DIevU1u60lGnevOeky\\nr8Be7pmymFB9/gzQAd5ezIlTv/COgoO986a3h1yfhzrrzbqSiivw308CgYEAwecI\\naZZwqH3GifR+0+Z1B48cezA5tC8LZt5yObGzUfxKTWy30d7lxe9N59t0KUVt/QL5\\nqVkd7mqGzsUMyxUN2U2HVnFTWfUFMhkn/OnCnayhILs8UlCTD2Xxoy1KbQH/9FIr\\nxf0pbMNJLXeGfyRt/8H+BzSZKBw9opJBWE4gqfECgYBp9FdvvryHuBkt8UQCRJPX\\nrWsRy6pY47nf11mnazpZH5Cmqspv3zvMapF6AIxFk0leyYiQolFWvAv+HFV5F6+t\\nSi1mM8GCDwbA5zh6pEBDewHhw+UqMBh63HSeUhmi1RiOwrAA36CO8i+D2Pt+eQHv\\nir52IiPJcs4BUNrv5Q1BdwKBgBHgVNw3LGe8QMOTMOYkRwHNZdjNl2RPOgPf2jQL\\nd/bFBayhq0jD/fcDmvEXQFxVtFAxKAc+2g2S8J67d/R5Gm/AQAvuIrsWZcY6n38n\\npfOXaLt1x5fnKcevpFlg4Y2vM4O416RHNLx8PJDehh3Oo/2CSwMrDDuwbtZAGZok\\nicphAoGBAI74Tisfn+aeCZMrO8KxaWS5r2CD1KVzddEMRKlJvSKTY+dOCtJ+XKj1\\nOsZdcDvDC5GtgcywHsYeOWHldgDWY1S8Z/PUo4eK9qBXYBXp3JEZQ1dqzFdz+Txi\\nrBn2WsFLsxV9j2/ugm0PqWVBcU2bPUCwvaRu3SOms2teaLwGCkhr\\n-----END RSA PRIVATE KEY-----\\n", 6 | "WEBHOOK_SECRET": "itsASecret", 7 | "GHE_HOST": "host.docker.internal:5555", 8 | "GHE_PROTOCOL": "http", 9 | "LOG_LEVEL": "debug" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/aws/test/utils/fixtures.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import {createHmac, BinaryLike} from 'node:crypto'; 3 | import type {APIGatewayProxyEventV2} from 'aws-lambda'; 4 | import fixture from '../../../app/test/fixtures/code_scanning_alert/closed_by_user.json'; 5 | import template from '../fixtures/event-template.json'; 6 | import {WEBHOOK_SECRET} from '../../../app/test/utils/helpers.js'; 7 | 8 | /** 9 | * Signs the payload using the secret and returns the signature. 10 | * @param secret The secret to use for signing 11 | * @param payload The payload to signa 12 | * @returns The HMAC (SHA-256) signature 13 | */ 14 | async function sign(secret: BinaryLike, payload: string) { 15 | return createHmac('sha256', secret).update(payload).digest('hex'); 16 | } 17 | 18 | /** 19 | * Creates a message payload for the alert closed fixture. 20 | * @param host The host to use for the message body 21 | * @param port The port to use for the message body 22 | * @returns The message payload for a Lambda API Gateway 23 | */ 24 | export async function createLocalAlertClosedFixtureMessage( 25 | host: string, 26 | port: number 27 | ) { 28 | const message = await createMessage(getLocalCallMockFixture(host, port)); 29 | return message; 30 | } 31 | 32 | /** 33 | * Creates a message payload for the alert closed fixture. 34 | * @returns The message payload for a Lambda API Gateway 35 | */ 36 | export async function createAlertClosedFixtureMessage() { 37 | const message = await createMessage(fixture); 38 | return message; 39 | } 40 | 41 | /** 42 | * Creates a message payload for the given fixture. 43 | * @param fixture The fixture to use for the message body 44 | * @param secret The secret to use for signing 45 | * @returns The message payload 46 | */ 47 | export async function createMessage( 48 | fixture: object | string, 49 | secret: string = WEBHOOK_SECRET 50 | ) { 51 | const contents = 52 | typeof fixture === 'string' ? fixture : JSON.stringify(fixture); 53 | const sig = await sign(secret, contents); 54 | 55 | const payload = { 56 | ...template, 57 | body: contents, 58 | headers: { 59 | 'X-GitHub-Hook-ID': '123456', 60 | 'X-GitHub-Delivery': '72d3162e-cc78-11e3-81ab-4c9367dc0958', 61 | 'X-GitHub-Event': 'code_scanning_alert', 62 | 'X-Hub-Signature-256': `sha256=${sig}`, 63 | 'X-GitHub-Hook-Installation-Target-ID': '79929171', 64 | 'X-GitHub-Hook-Installation-Target-Type': 'repository', 65 | 'Content-Type': 'application/json' 66 | }, 67 | version: '2.0', 68 | rawPath: '/', 69 | rawQueryString: '', 70 | cookies: [], 71 | queryStringParameters: {}, 72 | pathParameters: {}, 73 | stageVariables: {}, 74 | isBase64Encoded: false 75 | } as APIGatewayProxyEventV2; 76 | return payload; 77 | } 78 | 79 | /** 80 | * Gets a mock that sends GitHub API calls to the specified host and port. 81 | * @param host the target server 82 | * @param port the target port 83 | * @returns a message payload for the alert closed fixture with the host and port 84 | */ 85 | function getLocalCallMockFixture(host: string, port: number) { 86 | const fixtureData = JSON.stringify(fixture).replaceAll( 87 | 'https://api.github.com', 88 | `http://${host}:${port}/api/v3` 89 | ); 90 | return fixtureData; 91 | } 92 | -------------------------------------------------------------------------------- /packages/aws/test/utils/mockserver.ts: -------------------------------------------------------------------------------- 1 | import {MockServerClient} from 'mockserver-client/mockServerClient'; 2 | import { 3 | startProcess, 4 | stopProcess, 5 | DefaultSpawnOptions, 6 | DockerWarningMessages, 7 | ManagedProcess 8 | } from './spawn'; 9 | import { 10 | ChildProcessWithoutNullStreams, 11 | execSync, 12 | ExecSyncOptions 13 | } from 'node:child_process'; 14 | 15 | /** 16 | * Configuration settings for the mock server 17 | */ 18 | export const MockServerSettings = { 19 | /** The port to use for the mock server */ 20 | DEFAULT_PORT: 5555 as number, 21 | /** The host name to use when accessing the server from another container */ 22 | CONTAINER_HOST: 'host.docker.internal', 23 | /** The hostname to use when accessing the server from within the host environment */ 24 | MOCKSERVER_HOST: 'localhost' 25 | } as const; 26 | 27 | /** 28 | * Timeout settings for the mock server 29 | */ 30 | export const MockServerTimeouts = { 31 | /** The time to wait for a test to start */ 32 | TEST_START: 40000, 33 | /** The time to wait for a test to stop */ 34 | TEST_STOP: 15000, 35 | 36 | /** The time to wait for container to download and start listening */ 37 | WAIT_TO_ABORT: 30000, 38 | 39 | /** Additional time to wait for the listening port to become available after the process reports being ready */ 40 | WAIT_TO_RESOLVE: 500 41 | }; 42 | 43 | /** 44 | * Configures mock server to respond to the sample fixture 45 | * @returns the mock client 46 | */ 47 | export async function getMockClientForFixture( 48 | port = MockServerSettings.DEFAULT_PORT 49 | ) { 50 | const {mockServerClient} = await import('mockserver-client'); 51 | const client: MockServerClient = mockServerClient( 52 | MockServerSettings.MOCKSERVER_HOST, 53 | port 54 | ); 55 | // Remove any mocks that already exist 56 | client.reset(); 57 | await client.mockAnyResponse({ 58 | httpRequest: { 59 | method: 'GET', 60 | path: '/api/v3/orgs/_orgname/teams/scan-managers/memberships/_magicuser' 61 | }, 62 | httpResponse: { 63 | statusCode: 404 64 | } 65 | }); 66 | await client.mockAnyResponse({ 67 | httpRequest: { 68 | method: 'PATCH', 69 | path: '/api/v3/repos/_orgname/_myrepo/code-scanning/alerts/1' 70 | }, 71 | httpResponse: { 72 | statusCode: 200 73 | } 74 | }); 75 | await client.mockAnyResponse({ 76 | httpRequest: { 77 | method: 'POST', 78 | path: '/api/v3/app/installations/10000004/access_tokens' 79 | }, 80 | httpResponse: { 81 | statusCode: 200, 82 | body: { 83 | token: 'test', 84 | permissions: { 85 | security_events: 'read' 86 | } 87 | } 88 | } 89 | }); 90 | 91 | return client; 92 | } 93 | 94 | /** 95 | * Ensures the mocked API calls are invoked in the expected order 96 | * @param client the mock server client 97 | * @returns null if the calls are invoked in the expected order, otherwise an error message 98 | */ 99 | export async function mockedApiCallsAreInvoked(client: MockServerClient) { 100 | try { 101 | await client.verifySequence( 102 | { 103 | path: '/api/v3/app/installations/10000004/access_tokens', 104 | method: 'POST' 105 | }, 106 | { 107 | path: '/api/v3/orgs/_orgname/teams/scan-managers/memberships/_magicuser', 108 | method: 'GET' 109 | }, 110 | { 111 | path: '/api/v3/repos/_orgname/_myrepo/code-scanning/alerts/1', 112 | method: 'PATCH' 113 | } 114 | ); 115 | } catch (e) { 116 | return String(e); 117 | } 118 | 119 | return null; 120 | } 121 | 122 | /** 123 | * Starts the Mock Server. 124 | * @param port The port to use for the server 125 | */ 126 | export async function start( 127 | port = MockServerSettings.DEFAULT_PORT 128 | ): Promise { 129 | return await new MockServerImpl(port).start(); 130 | } 131 | 132 | /** 133 | * Interface for interacting with the mock server. 134 | */ 135 | export interface MockServer extends ManagedProcess { 136 | /** The port the server is running on */ 137 | port: number; 138 | /** Stops the server */ 139 | stop(): Promise; 140 | } 141 | 142 | /** 143 | * Messages used to indicate the server has started. 144 | */ 145 | const StartMessages = ['started on port: 1080'] as const; 146 | 147 | /** 148 | * Messages on stderr to ignore when waiting for the server to start. 149 | */ 150 | const IgnoredMessages = [ 151 | 'does not match the detected host platform', 152 | ...DockerWarningMessages 153 | ] as const; 154 | 155 | /** 156 | * Implementation of the Mock Server using Docker rather than 157 | * the mockserver-node package. This is because the mockserver-node 158 | * package does not seem to be getting continued updates. 159 | */ 160 | class MockServerImpl implements MockServer { 161 | /** The port the server is running on */ 162 | port: number; 163 | 164 | /** The underlying process instance */ 165 | private process?: ChildProcessWithoutNullStreams; 166 | 167 | private readonly containerName: string; 168 | 169 | /** 170 | * Creates an instance of the class 171 | * @param port the port to use for the server 172 | */ 173 | constructor(port = MockServerSettings.DEFAULT_PORT) { 174 | this.port = port; 175 | this.containerName = `mockserver${this.port}`; 176 | } 177 | 178 | /** 179 | * Starts the server. 180 | */ 181 | async start() { 182 | if (this.process) { 183 | throw new Error('Mock server process already started'); 184 | } 185 | 186 | const args = [ 187 | 'run', 188 | '--rm', 189 | '-p', 190 | `${this.port}:1080`, 191 | '--name', 192 | this.containerName, 193 | 'mockserver/mockserver' 194 | ]; 195 | 196 | this.process = await startProcess( 197 | 'docker', 198 | args, 199 | DefaultSpawnOptions, 200 | MockServerTimeouts.WAIT_TO_ABORT, 201 | MockServerTimeouts.WAIT_TO_RESOLVE, 202 | StartMessages, 203 | IgnoredMessages 204 | ); 205 | return this; 206 | } 207 | 208 | /** 209 | * Stops the server. 210 | */ 211 | public async stop() { 212 | if (!this.process) { 213 | return; 214 | } 215 | 216 | const args = ['kill', this.containerName]; 217 | const options: ExecSyncOptions = { 218 | stdio: 'pipe', 219 | cwd: process.cwd(), 220 | env: process.env, 221 | timeout: 30000 222 | }; 223 | execSync(`docker ${args.join(' ')}`, options); 224 | await stopProcess(this.process); 225 | this.process = undefined; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /packages/aws/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "incremental": false, 5 | "removeComments": true, 6 | "sourceMap": true, 7 | "sourceRoot": "./src", 8 | "declaration": true, 9 | "declarationMap": true, 10 | "outDir": "./dist", 11 | "paths": { 12 | "@security-alert-watcher/app": ["../app/src"] 13 | } 14 | }, 15 | "include": ["src", "test"], 16 | "exclude": ["node_modules", "test/**/*", "esbuild.config.mjs"], 17 | "compileOnSave": false, 18 | "references": [ 19 | { 20 | "path": "../app" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /packages/azure/.funcignore: -------------------------------------------------------------------------------- 1 | .git* 2 | .vscode 3 | *.js.map 4 | *.ts 5 | Dockerfile 6 | Dockerfile.dockerignore 7 | esbuild.config.mjs 8 | getting_started.md 9 | local.settings.json 10 | node_modules 11 | scripts 12 | src 13 | test 14 | tsconfig.json 15 | .env 16 | publish 17 | iac 18 | setup 19 | -------------------------------------------------------------------------------- /packages/azure/.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | obj 3 | csx 4 | .vs 5 | edge 6 | Publish 7 | 8 | *.user 9 | *.suo 10 | *.cscfg 11 | *.Cache 12 | project.lock.json 13 | 14 | /packages 15 | /TestResults 16 | 17 | /tools/NuGet.exe 18 | /App_Data 19 | /secrets 20 | /data 21 | .secrets 22 | appsettings.json 23 | local.settings.json 24 | 25 | node_modules 26 | dist 27 | publish 28 | 29 | # Local python packages 30 | .python_packages/ 31 | 32 | # Python Environments 33 | .env 34 | .venv 35 | env/ 36 | venv/ 37 | ENV/ 38 | env.bak/ 39 | venv.bak/ 40 | 41 | # Byte-compiled / optimized / DLL files 42 | __pycache__/ 43 | *.py[cod] 44 | *$py.class 45 | 46 | # Azurite artifacts 47 | __blobstorage__ 48 | __queuestorage__ 49 | __azurite_db*__.json 50 | -------------------------------------------------------------------------------- /packages/azure/Dockerfile: -------------------------------------------------------------------------------- 1 | # To enable ssh & remote debugging on app service change the base image to the one below 2 | # FROM mcr.microsoft.com/azure-functions/node:4-node18-appservice 3 | FROM mcr.microsoft.com/azure-functions/node:4-node22 4 | 5 | ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ 6 | AzureFunctionsJobHost__Logging__Console__IsEnabled=true 7 | 8 | COPY --link . /home/site/wwwroot 9 | -------------------------------------------------------------------------------- /packages/azure/Dockerfile.dockerignore: -------------------------------------------------------------------------------- 1 | .funcignore 2 | .gitignore 3 | Dockerfile 4 | Dockerfile.dockerignore 5 | esbuild.config.mjs 6 | local.settings.json 7 | src 8 | tsconfig.json 9 | -------------------------------------------------------------------------------- /packages/azure/esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import { dirname } from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import config from '../../esbuild.config.mjs'; 5 | 6 | const currentDir = dirname(fileURLToPath(import.meta.url)); 7 | const settings = { ...config, ...{ 8 | entryPoints: ['./src/index.mts'], 9 | absWorkingDir: currentDir, 10 | external: ['@azure/functions-core'] 11 | }}; 12 | 13 | await esbuild.build(settings); 14 | -------------------------------------------------------------------------------- /packages/azure/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | }, 11 | "extensionBundle": { 12 | "id": "Microsoft.Azure.Functions.ExtensionBundle", 13 | "version": "[4.*, 5.0.0)" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/azure/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@security-alert-watcher/azure-function", 3 | "version": "2.0.0", 4 | "private": true, 5 | "description": "Sample GH App which monitors and enforces rules for code scanning alerts", 6 | "author": "Ken Muse (https://www.kenmuse.com)", 7 | "license": "MIT", 8 | "homepage": "https://github.com/advanced-security/probot-codescan-alerts", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/advanced-security/probot-codescan-alerts.git", 12 | "directory": "packages/azure" 13 | }, 14 | "bugs": "https://github.com/advanced-security/probot-codescan-alerts/issues", 15 | "keywords": [ 16 | "azure", 17 | "functions", 18 | "probot", 19 | "github", 20 | "probot-app" 21 | ], 22 | "type": "module", 23 | "files": [ 24 | "dist/**" 25 | ], 26 | "main": "dist/index.mjs", 27 | "scripts": { 28 | "build": "yarn node esbuild.config.mjs", 29 | "build:container": "yarn run build && docker build -t security-watcher-azfxn .", 30 | "build:package": "yarn run build && mkdir -p publish && ${AZURE_FUNC_TOOLS_DIR}/func pack -o publish/package", 31 | "clean": "yarn g:clean && rm -rf publish", 32 | "lint": "yarn eslint", 33 | "storage": "yarn azurite --inMemoryPersistence", 34 | "copyEnv": "yarn g:copyEnv", 35 | "format": "yarn g:format", 36 | "start": "func start", 37 | "format:check": "yarn g:format-check" 38 | }, 39 | "devDependencies": { 40 | "@azure/functions": "^4.7.0", 41 | "@eslint/js": "^9.23.0", 42 | "@types/node": "^22.13.14", 43 | "@typescript-eslint/eslint-plugin": "^8.28.0", 44 | "@typescript-eslint/parser": "^8.28.0", 45 | "azure-functions-core-tools": "^4.0.7030", 46 | "azurite": "^3.34.0", 47 | "esbuild": "^0.25.2", 48 | "eslint": "^9.23.0", 49 | "probot": "^13.4.4", 50 | "tslib": "^2.8.1", 51 | "typescript": "^5.8.2", 52 | "typescript-eslint": "^8.28.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/azure/setup/_common.sh: -------------------------------------------------------------------------------- 1 | function validateRequirements() 2 | { 3 | if ! command -v az &> /dev/null 4 | then 5 | echo "Azure CLI not found. You need to install Azure CLI (see https://aka.ms/azcli)" 6 | exit 7 | fi 8 | 9 | if ! command -v jq &> /dev/null 10 | then 11 | echo "JQ not found. You need to install JQ (see https://stedolan.github.io/jq)" 12 | exit 13 | fi 14 | 15 | if ! command -v curl &> /dev/null 16 | then 17 | echo "Curl not found. You need to install Curl (see https://curl.se)" 18 | exit 19 | fi 20 | } 21 | 22 | function validAzureLogin() 23 | { 24 | if ! az account show --query id > /dev/null 25 | then 26 | echo "You need to login to Azure CLI (see https://aka.ms/azcli)" 27 | exit 28 | fi 29 | } 30 | 31 | function printSubscription() 32 | { 33 | local subscription 34 | subscription=$(az account show --query name --output tsv) 35 | user=$(az account show --query user.name --output tsv) 36 | echo -e "Hello $user using Azure Subscription: [$subscription]\n" 37 | } 38 | 39 | function get_env_file_path() 40 | { 41 | local base_path 42 | base_path=$(get_base_path) 43 | echo -n "${base_path}/packages/server/.env" 44 | } 45 | 46 | function get_base_path() 47 | { 48 | local base_path 49 | base_path=$(cd "$(dirname "$0")/../../.." && pwd) 50 | echo -n "${base_path}" 51 | } 52 | 53 | function validateFunctionName() { 54 | 55 | local input="$1" 56 | 57 | if [ ${#input} -lt 2 ] || [ ${#input} -gt 60 ]; then 58 | return 1 59 | fi 60 | 61 | # Regex to check if the string is alphanumeric, allows hyphens but not at the start or end 62 | if [[ $input =~ ^[a-zA-Z0-9]+([-][a-zA-Z0-9]+)*$ ]]; then 63 | return 0 64 | else 65 | return 1 66 | fi 67 | } 68 | 69 | function loadEnvFile() { 70 | 71 | if [ -z ${SKIP_ENV_FILE+x} ]; then 72 | 73 | env_file=$(get_env_file_path) 74 | if [ -f "$env_file" ]; then 75 | echo -e "\nLoading .env file from $env_file" 76 | source "$env_file" 77 | if [ -z ${APP_ID+x} ]; then 78 | echo "Warning: .env file is missing APP_ID Use --app-id to override it" 79 | fi 80 | if [ -z ${PRIVATE_KEY+x} ]; then 81 | echo "Warning: .env file is missing PRIVATE_KEY Use --key to override it" 82 | fi 83 | if [ -z ${WEBHOOK_SECRET+x} ]; then 84 | echo "Warning: .env file is missing WEBHOOK_SECRET Use --webhook-secret to override it" 85 | fi 86 | 87 | echo "" 88 | else 89 | echo -e "Warning .env file not found at $env_file You will have to pass all parameters GitHub App related parameters manually." 90 | fi 91 | fi 92 | 93 | } 94 | -------------------------------------------------------------------------------- /packages/azure/setup/deploy-function.sh: -------------------------------------------------------------------------------- 1 | #!/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | PrintUsage() 6 | { 7 | COMMAND=${COMMAND_NAME:-$(basename "$0")} 8 | 9 | cat <&2 49 | exit 1 50 | ;; 51 | *) # preserve positional arguments 52 | PARAMS="$PARAMS $1" 53 | shift 54 | ;; 55 | esac 56 | done 57 | 58 | 59 | function validateParameters() 60 | { 61 | missing_params=false 62 | 63 | if [ -z ${RG+x} ]; then 64 | echo "Resource Group is required" 65 | missing_params=true 66 | fi 67 | 68 | if [ -z ${APP_NAME+x} ]; then 69 | echo "Function App Name is required (must be globally unique)" 70 | missing_params=true 71 | fi 72 | 73 | # fail if there are missing parameters 74 | if [ "$missing_params" = true ]; then 75 | echo -e "\nMissing or invalid parameters (list above).\n" 76 | PrintUsage 77 | fi 78 | } 79 | 80 | 81 | # get base directory 82 | scripts_path=$(dirname "$0") 83 | source "${scripts_path}/_common.sh" 84 | base_path=$(get_base_path) 85 | 86 | azure_base_path="${base_path}/packages/azure" 87 | 88 | ########### BEGIN 89 | validateRequirements 90 | validateParameters 91 | validAzureLogin 92 | 93 | printSubscription 94 | 95 | echo -e "\nBuilding and packaging code in $azure_base_path\n" 96 | 97 | # Build Code 98 | cd "$azure_base_path" && yarn run build:package 99 | 100 | # deploy code 101 | package_zip="${azure_base_path}/publish/package.zip" 102 | echo -e "\nDeploying code from [$package_zip] to [$APP_NAME] in RG [$RG]\n" 103 | 104 | az functionapp deployment source config-zip --resource-group "$RG" --name "$APP_NAME" --src "$package_zip" 105 | 106 | echo -e "\nSuccess: Code deployed successfully\n" 107 | 108 | echo -e "Tips:" 109 | echo -e " - To view the function app default hostname, run the following command:" 110 | echo -e " az functionapp show --name \"$APP_NAME\" --resource-group \"$RG\" --query 'defaultHostName' -o tsv" 111 | echo -e " - To view the function app logs, run the following command:" 112 | echo -e " az webapp log tail --name \"$APP_NAME\" --resource-group \"$RG\"" 113 | 114 | -------------------------------------------------------------------------------- /packages/azure/setup/update-app-webhookurl.sh: -------------------------------------------------------------------------------- 1 | #!/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | PrintUsage() 6 | { 7 | COMMAND=${COMMAND_NAME:-$(basename "$0")} 8 | 9 | cat <&2 74 | exit 1 75 | ;; 76 | *) # preserve positional arguments 77 | PARAMS="$PARAMS $1" 78 | shift 79 | ;; 80 | esac 81 | done 82 | 83 | function validateParameters() 84 | { 85 | missing_params=false 86 | if [ -z ${APP_ID+x} ]; then 87 | echo "GitHub Application ID is a required parameter. Use --app-id to specify it" 88 | missing_params=true 89 | fi 90 | 91 | if [ -z ${PRIVATE_KEY+x} ]; then 92 | echo "Private Key is a required parameter. Use --key or -k to specify it" 93 | missing_params=true 94 | fi 95 | 96 | if [ -z ${RG+x} ]; then 97 | echo "Resource Group is a required parameter. Use --resource-group or -g to specify it" 98 | missing_params=true 99 | fi 100 | 101 | if [ -z ${APP_NAME+x} ]; then 102 | echo "Function App Name is a required parameter (must be globally unique). Use --function-name to specify it" 103 | missing_params=true 104 | fi 105 | 106 | # fail if there are missing parameters 107 | if [ "$missing_params" = true ]; then 108 | echo -e "\nMissing or invalid parameters (list above).\n" 109 | PrintUsage 110 | fi 111 | } 112 | 113 | ########### BEGIN 114 | validateParameters 115 | validateRequirements 116 | validAzureLogin 117 | 118 | printSubscription 119 | 120 | function_hostname=$(az functionapp show --name "$APP_NAME" --resource-group "$RG" --query "defaultHostName" -o tsv) 121 | webhook_url="https://${function_hostname}/api/securityWatcher" 122 | 123 | echo "Updating GitHub App Webhook URL to $webhook_url" 124 | 125 | APP_ID=$APP_ID PRIVATE_KEY=$PRIVATE_KEY yarn node ${base_path}/scripts/updateAppWebHookUrl.mjs "$webhook_url" 126 | -------------------------------------------------------------------------------- /packages/azure/src/index.mts: -------------------------------------------------------------------------------- 1 | import { 2 | app, 3 | HttpRequest, 4 | HttpResponseInit, 5 | InvocationContext 6 | } from '@azure/functions'; 7 | import * as bot from 'probot'; 8 | import { 9 | app as probotApp, 10 | ProbotHandler, 11 | WebhookEventRequest 12 | } from '../../app/src/index.js'; 13 | 14 | // Create an instance of the Probot application and handler 15 | // This ensures a single instance will be created for processing events 16 | const botHandler = ProbotHandler.create(bot.createProbot(), probotApp); 17 | 18 | export async function securityWatcher( 19 | request: HttpRequest, 20 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 21 | _context: InvocationContext 22 | ): Promise { 23 | const processor = await botHandler; 24 | const event: WebhookEventRequest = { 25 | headers: Object.fromEntries(request.headers), 26 | body: await request.text() 27 | }; 28 | const resp = await processor.process(event); 29 | return {body: resp.body, status: resp.status}; 30 | } 31 | 32 | // Register the function name 33 | app.http('securityWatcher', { 34 | methods: ['POST'], 35 | authLevel: 'anonymous', 36 | handler: securityWatcher 37 | }); 38 | 39 | app.http('checkConfig', { 40 | methods: ['GET'], 41 | authLevel: 'anonymous', 42 | handler: async function ( 43 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 44 | _request: HttpRequest, 45 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 46 | _context: InvocationContext 47 | ): Promise { 48 | const requiredEnvVars = ['APP_ID', 'PRIVATE_KEY', 'WEBHOOK_SECRET']; 49 | const missingEnvVars = requiredEnvVars.filter( 50 | envVar => !process.env[envVar] 51 | ); 52 | 53 | return { 54 | status: 200, 55 | body: JSON.stringify({ 56 | config: 57 | missingEnvVars.length > 0 ? `NOK ${missingEnvVars.length}` : 'OK' 58 | }) 59 | }; 60 | } 61 | }); 62 | -------------------------------------------------------------------------------- /packages/azure/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "strict": false 10 | }, 11 | "include": ["src/"], 12 | "exclude": ["node_modules", "test/**/*", "esbuild.config.mjs"], 13 | "ts-node": { 14 | "transpileOnly": true, 15 | "esm": true 16 | }, 17 | "references": [ 18 | { 19 | "path": "../app" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /packages/server/.env.example: -------------------------------------------------------------------------------- 1 | # The ID of your GitHub App 2 | APP_ID= 3 | WEBHOOK_SECRET= 4 | 5 | # Optional organization for the App registration. 6 | GH_ORG= 7 | 8 | # Use `trace` to get verbose logging or `info` to show less 9 | LOG_LEVEL=debug 10 | 11 | # Go to https://smee.io/new and set this to the URL that you are redirected to. 12 | # or start the server application to let this populate automatically 13 | WEBHOOK_PROXY_URL= 14 | -------------------------------------------------------------------------------- /packages/server/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.4 2 | 3 | ARG NODE_VERSION=22 4 | ARG OS_BASE=alpine3.20 5 | ARG APP_ROOT=/opt/probot 6 | 7 | FROM node:${NODE_VERSION}-${OS_BASE} as base 8 | 9 | FROM base as buildbase 10 | WORKDIR /app 11 | RUN corepack enable \ 12 | && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 yarn set version berry 13 | 14 | FROM buildbase as codebase 15 | COPY --link . . 16 | 17 | FROM codebase as build 18 | RUN --mount=type=cache,target=/root/.yarn/berry/cache \ 19 | yarn install \ 20 | && yarn workspace @security-alert-watcher/app test \ 21 | && yarn workspace @security-alert-watcher/server test \ 22 | && yarn workspace @security-alert-watcher/app build \ 23 | && yarn workspace @security-alert-watcher/server build 24 | 25 | FROM base 26 | ARG APP_ROOT 27 | ARG NODE_VERSION 28 | ARG OS_BASE 29 | 30 | LABEL org.opencontainers.image.title "Probot Security Alerts" 31 | LABEL org.opencontainers.image.description "Probot-based GitHub App which listens and responds to security alert notifications" 32 | LABEL org.opencontainers.image.licenses "MIT" 33 | LABEL org.opencontainers.image.base.name "docker.io/node:${NODE_VERSION}-${OS_BASE}" 34 | 35 | ENV NODE_ENV=production 36 | ENV PORT=80 37 | EXPOSE ${PORT} 38 | HEALTHCHECK CMD wget --server-response --timeout=10 --tries=3 --spider "http://localhost:${PORT}/health" || exit 1 39 | 40 | WORKDIR ${APP_ROOT} 41 | COPY --link --chown=1000:1000 --from=build /app/packages/server/dist/ . 42 | 43 | ENTRYPOINT ["node", "index.mjs"] 44 | USER node 45 | -------------------------------------------------------------------------------- /packages/server/Dockerfile.dockerignore: -------------------------------------------------------------------------------- 1 | # This restricts certain files from being copied into 2 | # the build target for the multi-stage build. The main 3 | # image selectively includes a subset of files. 4 | 5 | **/.devcontainer 6 | **/.dockerignore 7 | **/.editorconfig 8 | **/.env 9 | **/.env.example 10 | **/.git 11 | **/.gitattributes 12 | **/.github 13 | **/.gitignore 14 | **/.pnp.cjs 15 | **/.pnp.loader.mjs 16 | **/.vscode 17 | **/.yarn 18 | **/*.example 19 | **/*.log 20 | **/*.md 21 | **/aws 22 | **/azure 23 | **/coverage 24 | **/dist 25 | **/Dockerfile* 26 | **/LICENSE 27 | **/node_modules 28 | -------------------------------------------------------------------------------- /packages/server/app.yml: -------------------------------------------------------------------------------- 1 | # This is a GitHub App Manifest. These settings will be used by default when 2 | # initially configuring your GitHub App. 3 | # 4 | # NOTE: changing this file will not update your GitHub App settings. 5 | # You must visit github.com/settings/apps/your-app-name to edit them. 6 | # 7 | # Read more about configuring your GitHub App: 8 | # https://probot.github.io/docs/development/#configuring-a-github-app 9 | # 10 | # Read more about GitHub App Manifests: 11 | # https://developer.github.com/apps/building-github-apps/creating-github-apps-from-a-manifest/ 12 | # 13 | # Read about code scanning alerts 14 | # # https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#code_scanning_alert 15 | # 16 | # The list of events the GitHub App subscribes to. 17 | # Uncomment the event names below to enable them. 18 | default_events: 19 | - code_scanning_alert 20 | # - check_run 21 | # - check_suite 22 | # - commit_comment 23 | # - create 24 | # - delete 25 | - dependabot_alert 26 | # - deployment 27 | # - deployment_status 28 | # - fork 29 | # - gollum 30 | # - issue_comment 31 | # - issues 32 | # - label 33 | # - milestone 34 | # - member 35 | # - membership 36 | # - org_block 37 | # - organization 38 | # - page_build 39 | # - project 40 | # - project_card 41 | # - project_column 42 | # - public 43 | # - pull_request 44 | # - pull_request_review 45 | # - pull_request_review_comment 46 | # - push 47 | # - release 48 | # - repository 49 | # - repository_import 50 | - secret_scanning_alert 51 | # - status 52 | # - team 53 | # - team_add 54 | # - watch 55 | 56 | # The set of permissions needed by the GitHub App. The format of the object uses 57 | # the permission name for the key (for example, issues) and the access type for 58 | # the value (for example, write). 59 | # Valid values are `read`, `write`, and `none` 60 | default_permissions: 61 | # Repository creation, deletion, settings, teams, and collaborators. 62 | # https://developer.github.com/v3/apps/permissions/#permission-on-administration 63 | # administration: read 64 | 65 | # Checks on code. 66 | # https://developer.github.com/v3/apps/permissions/#permission-on-checks 67 | # checks: read 68 | 69 | # Repository contents, commits, branches, downloads, releases, and merges. 70 | # https://developer.github.com/v3/apps/permissions/#permission-on-contents 71 | # contents: read 72 | 73 | # Deployments and deployment statuses. 74 | # https://developer.github.com/v3/apps/permissions/#permission-on-deployments 75 | # deployments: read 76 | 77 | # Issues and related comments, assignees, labels, and milestones. 78 | # https://developer.github.com/v3/apps/permissions/#permission-on-issues 79 | # issues: write 80 | 81 | # Search repositories, list collaborators, and access repository metadata. 82 | # https://developer.github.com/v3/apps/permissions/#metadata-permissions 83 | metadata: read 84 | 85 | # Retrieve Pages statuses, configuration, and builds, as well as create new builds. 86 | # https://developer.github.com/v3/apps/permissions/#permission-on-pages 87 | # pages: read 88 | 89 | # Pull requests and related comments, assignees, labels, milestones, and merges. 90 | # https://developer.github.com/v3/apps/permissions/#permission-on-pull-requests 91 | # pull_requests: read 92 | 93 | # Manage the post-receive hooks for a repository. 94 | # https://developer.github.com/v3/apps/permissions/#permission-on-repository-hooks 95 | # repository_hooks: read 96 | 97 | # Manage repository projects, columns, and cards. 98 | # https://developer.github.com/v3/apps/permissions/#permission-on-repository-projects 99 | # repository_projects: read 100 | 101 | # Retrieve security vulnerability alerts. 102 | # https://developer.github.com/v4/object/repositoryvulnerabilityalert/ 103 | vulnerability_alerts: write 104 | 105 | # Commit statuses. 106 | # https://developer.github.com/v3/apps/permissions/#permission-on-statuses 107 | # statuses: read 108 | 109 | # Organization members and teams. 110 | # https://developer.github.com/v3/apps/permissions/#permission-on-members 111 | # members: read 112 | 113 | # View and manage users blocked by the organization. 114 | # https://developer.github.com/v3/apps/permissions/#permission-on-organization-user-blocking 115 | # organization_user_blocking: read 116 | 117 | # Manage organization projects, columns, and cards. 118 | # https://developer.github.com/v3/apps/permissions/#permission-on-organization-projects 119 | # organization_projects: read 120 | 121 | # Manage team discussions and related comments. 122 | # https://developer.github.com/v3/apps/permissions/#permission-on-team-discussions 123 | # team_discussions: read 124 | 125 | # Manage the post-receive hooks for an organization. 126 | # https://developer.github.com/v3/apps/permissions/#permission-on-organization-hooks 127 | # organization_hooks: read 128 | 129 | # Get notified of, and update, content references. 130 | # https://developer.github.com/v3/apps/permissions/ 131 | # organization_administration: read 132 | 133 | # The level of permission to grant the access token to view and manage security events like code scanning alerts 134 | # See https://octokit.github.io/rest.js/v18#code-scanning-get-alert 135 | # https://docs.github.com/en/rest/dependabot/alerts?apiVersion=2022-11-28#update-a-dependabot-alert 136 | security_events: write 137 | 138 | # https://docs.github.com/en/rest/secret-scanning?apiVersion=2022-11-28#update-a-secret-scanning-alert 139 | secret_scanning_alerts: write 140 | 141 | # https://octokit.github.io/rest.js/v18#secret_scanning_alerts 142 | # https://docs.github.com/en/rest/overview/permissions-required-for-github-apps#permission-on-members 143 | # teams-add-or-update-membership-for-user-in-org 144 | members: read 145 | 146 | # The name of the GitHub App. Defaults to the name specified in package.json 147 | name: Security Alert Watcher 148 | 149 | # The homepage of your GitHub App. 150 | # url: https://example.com/ 151 | 152 | # A description of the GitHub App. 153 | description: Sample Probot application to monitor and act on code scanning alerts. 154 | 155 | # Set to true when your GitHub App is available to the public or false when it is only accessible to the owner of the app. 156 | # Default: true 157 | public: false 158 | -------------------------------------------------------------------------------- /packages/server/esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import fs from 'fs'; 3 | import {createRequire} from 'module'; 4 | import {dirname, join} from 'path'; 5 | import {fileURLToPath} from 'url'; 6 | import config from '../../esbuild.config.mjs'; 7 | 8 | var copyProbotPlugin = { 9 | name: 'copy-probot-static', 10 | setup(build) { 11 | build.onEnd(result => { 12 | const require = createRequire(import.meta.url); 13 | const srcFile = require.resolve('probot/static/primer.css'); 14 | const src = dirname(srcFile); 15 | const dest = join( 16 | build.initialOptions.absWorkingDir, 17 | build.initialOptions.outdir, 18 | 'static' 19 | ); 20 | fs.cpSync(src, dest, { 21 | dereference: true, 22 | errorOnExist: false, 23 | force: true, 24 | preserveTimestamps: true, 25 | recursive: true 26 | }); 27 | }); 28 | } 29 | }; 30 | 31 | const currentDir = dirname(fileURLToPath(import.meta.url)); 32 | 33 | const settings = { 34 | ...config, 35 | ...{ 36 | entryPoints: ['./src/index.ts'], 37 | absWorkingDir: currentDir, 38 | plugins: [copyProbotPlugin] 39 | } 40 | }; 41 | 42 | await esbuild.build(settings); 43 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@security-alert-watcher/server", 3 | "version": "2.0.0", 4 | "private": true, 5 | "description": "Sample GH App which monitors and enforces rules for code scanning alerts", 6 | "author": "Ken Muse (https://www.kenmuse.com)", 7 | "license": "MIT", 8 | "homepage": "https://github.com/advanced-security/probot-codescan-alerts", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/advanced-security/probot-codescan-alerts.git", 12 | "directory": "packages/server" 13 | }, 14 | "bugs": "https://github.com/advanced-security/probot-codescan-alerts/issues", 15 | "keywords": [ 16 | "probot", 17 | "github", 18 | "probot-app" 19 | ], 20 | "type": "module", 21 | "main": "dist/index.mjs", 22 | "scripts": { 23 | "build": "yarn node esbuild.config.mjs", 24 | "build:container": "docker build -t security-alert-watcher -f Dockerfile ../..", 25 | "clean": "yarn g:clean", 26 | "lint": "yarn run lint:code && yarn run lint:container", 27 | "test": "NODE_OPTIONS='--experimental-vm-modules --no-warnings=ExperimentalWarning' yarn jest --config=${PROJECT_CWD}/jest.config.mjs --rootDir=${INIT_CWD}", 28 | "start": "yarn tsx watch --env-file=.env src/index.ts", 29 | "start:nowatch": "yarn tsx --env-file=.env src/index.ts", 30 | "lint:code": "yarn eslint", 31 | "lint:container": "hadolint Dockerfile", 32 | "format": "yarn g:format", 33 | "format:check": "yarn g:format-check" 34 | }, 35 | "dependencies": { 36 | "probot": "^13.4.4", 37 | "smee-client": "^2.0.4" 38 | }, 39 | "devDependencies": { 40 | "@eslint/js": "^9.23.0", 41 | "@jest/globals": "^29.7.0", 42 | "@types/eslint": "^9.6.1", 43 | "@types/express": "^4.17.21", 44 | "@types/jest": "^29.5.14", 45 | "@types/node": "^22.13.14", 46 | "@types/supertest": "^6.0.3", 47 | "@typescript-eslint/eslint-plugin": "^8.28.0", 48 | "@typescript-eslint/parser": "^8.28.0", 49 | "esbuild": "^0.25.2", 50 | "eslint": "^9.23.0", 51 | "eslint-config-prettier": "^9.1.0", 52 | "express": "*", 53 | "jest": "^29.7.0", 54 | "nock": "^14.0.2", 55 | "prettier": "^3.5.3", 56 | "supertest": "^7.1.0", 57 | "ts-jest": "^29.3.0", 58 | "ts-jest-resolver": "^2.0.1", 59 | "tslib": "^2.8.1", 60 | "tsx": "^4.19.3", 61 | "typescript": "^5.8.2", 62 | "typescript-eslint": "^8.28.0" 63 | }, 64 | "engines": { 65 | "node": ">= 22.10.0" 66 | }, 67 | "publishConfig": { 68 | "provenance": true 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main entrypoint. 3 | */ 4 | import {startServer} from './server.js'; 5 | 6 | // Start application. This replaces using the probot binary to launch the application. 7 | startServer(); 8 | -------------------------------------------------------------------------------- /packages/server/src/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Entrypoint for debugging. 3 | */ 4 | import {run, Server} from 'probot'; 5 | import {join, dirname} from 'path'; 6 | import {existsSync} from 'fs'; 7 | import {Application, static as expressStatic} from 'express'; 8 | import {app} from '../../app/src/index.js'; 9 | import {fileURLToPath} from 'url'; 10 | 11 | /** The supported signal types (see https://nodejs.org/api/process.html#signal-events) */ 12 | export type SignalType = 'SIGINT' | 'SIGTERM'; 13 | 14 | /** 15 | * Responds to a shutdown request from a specific signal. 16 | * @param signal The type of signal 17 | * @param server The server instance 18 | */ 19 | export async function shutdown(signal: SignalType, server: Server) { 20 | server.log.info(`${signal} received. Stopping ...`); 21 | await server.stop(); 22 | server.log.info('Server stopped'); 23 | } 24 | 25 | /** 26 | * Registers with the process to receive shutdown events 27 | * @param signal The type of signal 28 | * @param server The server instance 29 | */ 30 | export function registerShutdown(signal: SignalType, server: Server) { 31 | process.on(signal, async () => await shutdown(signal, server)); 32 | } 33 | 34 | /** 35 | * Ensures the server is configured to process termination signals. 36 | * @param server the server instance 37 | */ 38 | export function registerForSignals(server: Server) { 39 | registerShutdown('SIGINT', server); 40 | registerShutdown('SIGTERM', server); 41 | return server; 42 | } 43 | 44 | /** 45 | * Initializes and starts a Probot server instance. 46 | */ 47 | /* istanbul ignore next */ 48 | export async function startServer() { 49 | const server = await run(app); 50 | return configureServer(server); 51 | } 52 | 53 | /** 54 | * Configures additional routes and listeners on the server 55 | */ 56 | export function configureServer(server: Server) { 57 | addHealthChecks(server.expressApp); 58 | addStaticFiles(server.expressApp); 59 | registerForSignals(server); 60 | return server; 61 | } 62 | 63 | /** 64 | * Provides a health check endpoint. 65 | * @param express the instance of Express 66 | */ 67 | export function addHealthChecks(express: Application) { 68 | express.get('/health', (_request, response) => { 69 | response.status(200).send('OK'); 70 | }); 71 | } 72 | 73 | /** 74 | * Adds Probot static files when using the index.mjs entrypoint 75 | * and the static files are available. 76 | * @param express the instance of Express 77 | */ 78 | export function addStaticFiles(express: Application) { 79 | const fileName = fileURLToPath(import.meta.url); 80 | const directory = dirname(fileName); 81 | const staticDir = join(directory, 'static'); 82 | /* istanbul ignore next */ 83 | if (fileName.indexOf('index.mjs') > 0 && existsSync(staticDir)) { 84 | express.use('/probot/static', expressStatic(staticDir)); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /packages/server/test/server.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import express from 'express'; 3 | import {jest} from '@jest/globals'; 4 | import { 5 | registerShutdown, 6 | shutdown, 7 | SignalType, 8 | configureServer, 9 | addHealthChecks 10 | } from '../src/server.js'; 11 | 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | async function createMockServer(probotMock: any) { 14 | const server = new probotMock.Server({ 15 | Probot: probotMock.Probot.defaults({ 16 | appId: 123, 17 | privateKey: 'notakey', 18 | logLevel: 'fatal' 19 | }), 20 | loggingOptions: { 21 | level: 'silent', 22 | enabled: false 23 | } 24 | }); 25 | 26 | // Capture the log messages to prevent them from being written to the console. 27 | // and allow them to be analyzed in tests. 28 | // eslint-disable-next-line @typescript-eslint/no-empty-function 29 | jest.spyOn(server.log, 'info').mockImplementation(() => {}); 30 | return server; 31 | } 32 | 33 | describe('Additional server routes', () => { 34 | test('health endpoint returns 200', async () => { 35 | const app = express(); 36 | addHealthChecks(app); 37 | const res = await request(app).get('/health'); 38 | expect(res.statusCode).toBe(200); 39 | expect(res.text).toEqual('OK'); 40 | }); 41 | }); 42 | 43 | describe('When configuring a server', () => { 44 | const SIGNALS = ['SIGINT', 'SIGTERM'] as const; 45 | 46 | beforeEach(() => { 47 | jest.mock('probot', () => { 48 | const probotModule = jest.requireActual('probot'); 49 | return { 50 | __esModule: true, 51 | ...(probotModule ?? {}) 52 | }; 53 | }); 54 | }); 55 | 56 | afterEach(() => { 57 | jest.resetModules(); 58 | jest.clearAllMocks(); 59 | }); 60 | 61 | test.each(SIGNALS)('receiving %s shuts down the server', async signal => { 62 | const probotMock = await import('probot'); 63 | const server = await createMockServer(probotMock); 64 | const stopMethod = jest.spyOn(server, 'stop'); 65 | const infolog = server.log.info as jest.Mock; 66 | 67 | await shutdown(signal as SignalType, server); 68 | expect(stopMethod).toHaveBeenCalledTimes(1); 69 | expect(infolog).toBeCalled(); 70 | }); 71 | 72 | test.each(SIGNALS)('process listens for %s', async signal => { 73 | const probotMock = await import('probot'); 74 | const processMock = jest.spyOn(process, 'on'); 75 | const server = await createMockServer(probotMock); 76 | registerShutdown(signal as SignalType, server); 77 | expect(processMock).toHaveBeenCalledTimes(2); 78 | expect(processMock.mock.calls[0][0]).toBe('exit'); 79 | expect(processMock.mock.calls[1][0]).toBe(signal as SignalType); 80 | }); 81 | 82 | test('configureServer ensures the signals are observed', async () => { 83 | // Prevent server implementation from starting. 84 | await import('probot'); 85 | const probotMock = await import('probot'); 86 | const processMock = jest.spyOn(process, 'on'); 87 | const server = await createMockServer(probotMock); 88 | configureServer(server); 89 | 90 | const params = processMock.mock.calls.map(item => item[0]); 91 | SIGNALS.forEach(signal => expect(params).toContain(signal)); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "incremental": false /* Enable incremental compilation */, 5 | "outDir": "./dist", 6 | "paths": { 7 | "@security-alert-watcher/app": ["../app/src"] 8 | } 9 | }, 10 | "include": ["src/"], 11 | "exclude": ["test/**/*", "esbuild.config.mjs"], 12 | "ts-node": { 13 | "transpileOnly": true, 14 | "esm": true 15 | }, 16 | "references": [ 17 | { 18 | "path": "../app" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /scripts/copyEnv.mjs: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url'; 2 | import path from 'node:path'; 3 | import fs from 'node:fs'; 4 | import dotenv from 'dotenv'; 5 | 6 | const ROOT_ENV_PATH = '.env'; 7 | const SERVER_ENV_PATH = 'packages/server/.env'; 8 | const AWS_ENV_JSON_PATH = 'packages/aws/.env.json'; 9 | const AZURE_ENV_PATH = 'packages/azure/.env'; 10 | const AZURE_ENV_JSON_PATH = 'packages/azure/local.settings.json'; 11 | 12 | /** 13 | * Gets the base directory for the project. 14 | * @returns {string} The absolute path of the base directory for the project. 15 | */ 16 | function getProjectBaseDirectory(){ 17 | const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); 18 | const baseDirectory = path.join(scriptDirectory, '../'); 19 | return baseDirectory; 20 | } 21 | 22 | /** 23 | * Copies the .env settings from the server folder to the appropriate 24 | * folder and converts them to the required JSON format. 25 | * @param {string} target The target JSON environment file 26 | * @param transformer Optional settings to apply to the environment 27 | */ 28 | function copyServerEnv(target, transformer = (settings) => settings, overwrite = true){ 29 | const baseDirectory = getProjectBaseDirectory(); 30 | const serverEnvPath = path.join(baseDirectory, SERVER_ENV_PATH); 31 | const targetPath = path.join(baseDirectory, target); 32 | 33 | // eslint-disable-next-line no-undef 34 | console.log(`Copying environment settings from ${serverEnvPath} to ${targetPath}`); 35 | 36 | if (!fs.existsSync(serverEnvPath)){ 37 | throw new Error(`A .env file must exist at ${SERVER_ENV_PATH} or in the project root.`); 38 | } 39 | if (overwrite || !fs.existsSync(targetPath)){ 40 | let settings = dotenv.parse(fs.readFileSync(serverEnvPath)); 41 | delete settings.WEBHOOK_PROXY_URL; 42 | settings = transformer(settings); 43 | const jsonSettings = JSON.stringify(settings, null, ' '); 44 | fs.writeFileSync(targetPath, jsonSettings); 45 | } 46 | } 47 | 48 | /** 49 | * Copies the .env file from the project root to the server folder 50 | * if it doesn't already exist. 51 | */ 52 | function copyEnv(source, target){ 53 | const baseDirectory = getProjectBaseDirectory(); 54 | const originalEnvPath = path.join(baseDirectory, source); 55 | const targetEnvPath = path.join(baseDirectory, target); 56 | 57 | // eslint-disable-next-line no-undef 58 | console.log(`Copying ${originalEnvPath} to ${targetEnvPath}`); 59 | if (fs.existsSync(originalEnvPath) && !fs.existsSync(targetEnvPath)) { 60 | fs.copyFileSync(originalEnvPath, targetEnvPath); 61 | } 62 | } 63 | 64 | /** 65 | * Configure environment settings for all packages based on either 66 | * an .env in the root folder or an .env in the server folder. 67 | */ 68 | copyEnv(ROOT_ENV_PATH, SERVER_ENV_PATH); 69 | copyEnv(SERVER_ENV_PATH, AZURE_ENV_PATH); 70 | copyServerEnv(AWS_ENV_JSON_PATH); 71 | copyServerEnv( 72 | AZURE_ENV_JSON_PATH, 73 | (settings) => { 74 | return { 75 | IsEncrypted: false, 76 | Values: { 77 | ...{ 78 | FUNCTIONS_WORKER_RUNTIME: "node", 79 | AzureWebJobsFeatureFlags: "EnableWorkerIndexing", 80 | AzureWebJobsStorage: "UseDevelopmentStorage=true;DevelopmentStorageProxyUri=http://127.0.0.1:10000", 81 | NODE_ENV: "development", 82 | }, 83 | ...settings} 84 | } 85 | }); 86 | -------------------------------------------------------------------------------- /scripts/hadolint-matcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "hadolint", 5 | "pattern": [ 6 | { 7 | "regexp": "^([^:]+)\\:(\\d+) ((?:DL|SC)\\d{4}) ([^:]+): (.*)$", 8 | "file": 1, 9 | "line": 2, 10 | "code": 3, 11 | "security": 4, 12 | "message": 5 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /scripts/lintDocker.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | function main() { 6 | declare -r output="${GITHUB_STEP_SUMMARY:-}" 7 | declare -r matcher="$(getWorkspaceRoot)/scripts/hadolint-matcher.json" 8 | if [ -n "${output}" ]; then 9 | echo "::add-matcher::${matcher}" 10 | echo "### Hadolint" >> $output 11 | fi 12 | process ${@} 13 | if [ -n "${output}" ]; then 14 | echo "::remove-matcher::${matcher}" 15 | fi 16 | } 17 | 18 | function getWorkspaceRoot() { 19 | SCRIPT_DIR=$(dirname $(readlink -e "${BASH_SOURCE[0]}")) &> /dev/null 20 | echo $(dirname $SCRIPT_DIR) 21 | } 22 | 23 | function process() { 24 | declare -r output="${GITHUB_STEP_SUMMARY:-}" 25 | cd $(getWorkspaceRoot) 26 | for f in "${@}" 27 | do 28 | declare filePath=$(readlink -e $f) 29 | declare summary=$(hadolint $filePath) 30 | declare status="code $?" 31 | echo $summary 32 | if [ -n "${output}" ]; then 33 | declare codeBlock='```' 34 | summary=$(echo "${summary}" | sed -E "s@${filePath}:([0-9]+)@Line \1:@g") 35 | cat <<- EOF >> $output 36 | #### ${f} (${status}) 37 | ${codeBlock} 38 | ${summary} 39 | ${codeBlock} 40 | EOF 41 | fi 42 | done 43 | } 44 | 45 | main "$@" 46 | -------------------------------------------------------------------------------- /scripts/updateAppWebHookUrl.mjs: -------------------------------------------------------------------------------- 1 | import { Octokit} from "@octokit/core"; 2 | import { createAppAuth } from "@octokit/auth-app"; 3 | import process from "process"; 4 | import console from "console" 5 | 6 | if (!process.env.APP_ID || !process.env.PRIVATE_KEY) { 7 | console.error("APP_ID and PRIVATE_KEY environment variables are required"); 8 | process.exit(1); 9 | } 10 | 11 | if (!process.argv[2]) { 12 | console.error("URL argument is required"); 13 | process.exit(1); 14 | } 15 | 16 | const webhookUrl = process.argv[2]; 17 | 18 | const certificate = process.env.PRIVATE_KEY.replace(/\\n/g, "\n"); 19 | const appId = process.env.APP_ID; 20 | 21 | console.log(`Using App Id: ${appId}`); 22 | 23 | const options = { 24 | appId : appId, 25 | privateKey: certificate, 26 | installationId: 51520851, 27 | }; 28 | 29 | if(!options.appId) { 30 | console.error("APP_ID environment variable is required"); 31 | process.exit(1); 32 | } 33 | 34 | const appAuth = createAppAuth({ 35 | appId : appId, 36 | privateKey: certificate, 37 | }); 38 | 39 | const auth = await appAuth({ type: "app" }); 40 | const octokit = new Octokit({auth: auth.token}); 41 | 42 | const appData = await octokit.request("GET /app/hook/config"); 43 | console.log(`Current WebHook URL [${appData.data.url}]`); 44 | 45 | 46 | if (appData.data.url === webhookUrl) { 47 | console.log(" WebHook URL is already up to date. Exiting."); 48 | process.exit(0); 49 | } 50 | 51 | console.log(`Updating WebHook URL to [${webhookUrl}]`); 52 | 53 | const updateResult = await octokit.request("PATCH /app/hook/config", 54 | { 55 | data: { 56 | url: webhookUrl, 57 | }, 58 | headers: { 59 | 'X-GitHub-Api-Version': '2022-11-28' 60 | } 61 | } 62 | ); 63 | 64 | if (updateResult.status !== 200) { 65 | console.error(`Update failed with status: ${updateResult.status}`); 66 | updateResult.data.errors.forEach((error) => { 67 | console.error(`Error: ${error.message}`); 68 | }); 69 | process.exit(1); 70 | } else { 71 | console.log(` WebHook URL updated to [${webhookUrl}]`); 72 | } 73 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "incremental": true /* Enable incremental compilation */, 5 | "target": "es2022" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 6 | "module": "esnext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 7 | "lib": [ 8 | "es2022", 9 | "ES2022.String" 10 | ] /* Specify library files to be included in the compilation. */, 11 | "allowJs": false /* Allow javascript files to be compiled. */, 12 | "checkJs": false /* Report errors in .js files. */, 13 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 14 | "declaration": true /* Generates corresponding '.d.ts' file. */, 15 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 16 | "sourceMap": true /* Generates corresponding '.map' file. */, 17 | // "outFile": "./", /* Concatenate and emit output to single file. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./" /* Specify file to store incremental compilation information */, 21 | "removeComments": true /* Do not emit comments to output. */, 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | "importHelpers": true /* Import emit helpers from 'tslib'. */, 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | /* Strict Type-Checking Options */ 27 | "strict": true /* Enable all strict type-checking options. */, 28 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 32 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 33 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | /* Additional Checks */ 36 | "noUnusedLocals": true /* Report errors on unused locals. */, 37 | "noUnusedParameters": true /* Report errors on unused parameters. */, 38 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 39 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 40 | /* Module Resolution Options */ 41 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 42 | // "baseUrl": "./src" /* Base directory to resolve non-absolute module names. */, 43 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 44 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 45 | // "typeRoots": [], /* List of folders to include type definitions from. */ 46 | "types": [ 47 | "node", 48 | "jest" 49 | ] /* Type declaration files to be included in compilation. */, 50 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | /* Source Map Options */ 55 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 56 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 57 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 58 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 59 | /* Experimental Options */ 60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | /* Advanced Options */ 63 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 64 | "resolveJsonModule": true, 65 | "pretty": false, 66 | "skipLibCheck": true 67 | }, 68 | "exclude": ["dist"], 69 | "ts-node": { 70 | "esm": true 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig.base.json", 3 | "files": [], 4 | "compilerOptions": { 5 | "disableSourceOfProjectReferenceRedirect": false 6 | }, 7 | "references": [ 8 | { 9 | "path": "./packages/app" 10 | }, 11 | { 12 | "path": "./packages/aws" 13 | }, 14 | { 15 | "path": "./packages/azure" 16 | }, 17 | { 18 | "path": "./packages/server" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /utils/cjs-shim.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Shim to provide CommonJS require() function in ESM modules 3 | * for ESBuild. 4 | */ 5 | 6 | import {createRequire} from 'node:module'; 7 | import path from 'node:path'; 8 | import url from 'node:url'; 9 | 10 | globalThis.require = createRequire(import.meta.url); 11 | globalThis.__filename = url.fileURLToPath(import.meta.url); 12 | globalThis.__dirname = path.dirname(__filename); 13 | -------------------------------------------------------------------------------- /utils/esm-loader.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /** 3 | * Provides TS Node ESM support. 4 | */ 5 | import {register} from 'node:module'; 6 | import {pathToFileURL} from 'node:url'; 7 | register('ts-node/esm', pathToFileURL('./')); 8 | --------------------------------------------------------------------------------