├── gitignore ├── .github ├── dependabot.yml └── workflows │ └── build-test-action-policy.yml ├── example_allow_policy.json ├── example_prohibit_policy.json ├── tsconfig.json ├── src ├── github_files.ts └── main.ts ├── LICENSE ├── action.yml ├── package.json ├── CHANGELOG.md ├── README.md └── lib └── 101.index.js /gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.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/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /example_allow_policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [ 3 | "Contrast-Security-OSS/*", 4 | "act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d", 5 | "actions/1f0c5cde4bc74cd7e1254d0cb4de8d49e9068c7d", 6 | "actions/aws-codebuild-run-build@540238d832197229bfaab9785feb4fb8450f6396", 7 | "actions/cache", 8 | "actions/checkout", 9 | "actions/deploy-pages", 10 | "actions/download-artifact", 11 | "actions/github-script", 12 | "actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9", 13 | "actions/upload-artifact", 14 | "actions/upload-pages-artifact", 15 | "azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112", 16 | "docker/login-action", 17 | "gradle/actions/setup-gradle", 18 | "grafana/setup-k6-action", 19 | "SonarSource/sonarqube-scan-action" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /example_prohibit_policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions" : [ 3 | "actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93", 4 | "actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32", 5 | "actions/upload-artifact@v1", 6 | "actions/upload-artifact@v2", 7 | "actions/upload-artifact@v3", 8 | "actions/download-artifact@v1", 9 | "actions/download-artifact@v2", 10 | "actions/download-artifact@v3", 11 | "aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a", 12 | "actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11", 13 | "digicert/ssm-code-signing@25dffce7023e3656c5ca688f96a2d7f3129fe2c0", 14 | "gradle/gradle-build-action@v2", 15 | "s4u/setup-maven-action", 16 | "s4u/*", 17 | "stCarolas/setup-maven", 18 | "stCarolas/*" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "outDir": "./lib", /* Redirect output structure to the directory. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | "strict": true, /* Enable all strict type-checking options. */ 8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 9 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | "sourceMap": true 11 | }, 12 | "exclude": ["node_modules", "**/*.test.ts"] 13 | } -------------------------------------------------------------------------------- /src/github_files.ts: -------------------------------------------------------------------------------- 1 | const github = require("@actions/github"); 2 | 3 | export async function getFilesInCommit( 4 | commit: any, 5 | token: string, 6 | ): Promise { 7 | const repo = github.context.payload.repository; 8 | console.log("repo : " + repo); 9 | const owner = repo?.owner; 10 | console.log("owner : " + owner); 11 | const allFiles: string[] = []; 12 | 13 | const args: any = { owner: owner?.name || owner?.login, repo: repo?.name }; 14 | args.ref = commit.id || commit.sha; 15 | 16 | const octokit = github.getOctokit(token); 17 | console.log("octokit : " + octokit); 18 | const result = await octokit.rest.repos.getCommit(args); 19 | console.log("result : " + result); 20 | 21 | if (result && result.data && result.data.files) { 22 | const files = result.data.files; 23 | 24 | files 25 | .filter( 26 | (file: { status: string; filename: string }) => 27 | file.status == "modified" || file.status == "added", 28 | ) 29 | .map((file: { filename: string }) => file.filename) 30 | .forEach((filename: string) => allFiles.push(filename)); 31 | } 32 | 33 | return allFiles; 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2025 Contrast Security Inc 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'actionbot' 2 | description: 'Checks your repository workflows for references to actions against a defined allow/prohibit list' 3 | author: 'Contrast Security Inc.' 4 | branding: 5 | icon: check-circle 6 | color: green 7 | inputs: 8 | policy: 9 | required: true 10 | description: 'Set to either `allow` or `prohibit`. When allow is the policy, any actions not on the list will be in violation. When prohibit is the policy, any actions on the list will be in violation.' 11 | policy-url: 12 | required: true 13 | description: 'The URL to a publicly accessible or private GitHub JSON file containing a list of allowed or prohibited actions. Private URLs require appropriate permissions or authentication.' 14 | fail-if-violations: 15 | required: false 16 | default: 'true' 17 | description: 'True to set the status of the action to Failed if violations occur. Set to false to allow downstream actions to execute. Defaults to true.' 18 | github-token: 19 | required: true 20 | description: 'Your GitHub token to access the files in the commits tied to the push or puill request.' 21 | outputs: 22 | results: 23 | description: 'A list of any actions breaking the provided policy rules.' 24 | runs: 25 | using: 'node20' 26 | main: 'lib/index.js' -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "actionbot", 3 | "version": "1.0.8", 4 | "private": true, 5 | "description": "Github Action Policy Checker as a Github Action", 6 | "main": "lib/index.js", 7 | "scripts": { 8 | "build": "ncc build src/main.ts -o lib", 9 | "clean": "rm -rf lib", 10 | "format": "prettier --write **/*.ts", 11 | "format-check": "prettier --check **/*.ts", 12 | "lint": "eslint src/**/*.ts", 13 | "package": "ncc build --source-map", 14 | "test": "jest", 15 | "version": "auto-changelog -p && git add CHANGELOG.md && git commit -m 'chore: update changelog' && git tag -a v$(node -p \"require('./package.json').version\") -m 'Version $(node -p \"require('./package.json').version\")'", 16 | "all": "npm run build && npm run format && npm run lint && npm run package && npm test" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/Contrast-Security-OSS/actionbot.git" 21 | }, 22 | "keywords": [ 23 | "actions", 24 | "node", 25 | "setup" 26 | ], 27 | "author": "Contrast Security Inc", 28 | "license": "MIT", 29 | "engines": { 30 | "node": ">=20" 31 | }, 32 | "dependencies": { 33 | "@actions/core": "^1.11.1", 34 | "@actions/github": "^6.0.0", 35 | "@octokit/rest": "^22.0.0", 36 | "@types/js-yaml": "^4.0.9", 37 | "@types/node-fetch": "^2.6.12", 38 | "@vercel/ncc": "^0.38.3", 39 | "node-fetch": "3.3.2", 40 | "octokit": "^5.0.3" 41 | }, 42 | "devDependencies": { 43 | "@types/jest": "^29.5.14", 44 | "@types/node": "^24.0.13", 45 | "@types/jest": "^30.0.0", 46 | "@types/node": "^24.2.0", 47 | "@typescript-eslint/parser": "^8.26.0", 48 | "@vercel/ncc": "^0.38.1", 49 | "auto-changelog": "^2.5.0", 50 | "eslint": "^9.21.0", 51 | "eslint-plugin-github": "^6.0.0", 52 | "jest": "^30.0.5", 53 | "eslint-plugin-jest": "^29.0.1", 54 | "prettier": "^3.5.3", 55 | "ts-jest": "^29.2.6", 56 | "typescript": "^5.8.2" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/build-test-action-policy.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test Actionbot 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - 'dependabot/**' 8 | pull_request: 9 | types: 10 | - opened 11 | - edited 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 18 | - name: Setup Node.js 19 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 20 | with: 21 | node-version: '20' 22 | - run: npm install 23 | - run: npm run build 24 | - run: npm run package 25 | 26 | test-allow-policy: 27 | runs-on: ubuntu-latest 28 | needs: build 29 | steps: 30 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 31 | 32 | - name: Test Allow Policy 33 | uses: ./ # Use the action from the current repository 34 | with: 35 | policy: 'allow' 36 | policy-url: 'https://github.com/Contrast-Security-OSS/actionbot/blob/main/example_allow_policy.json' # Replace with your allow policy URL 37 | github-token: ${{ secrets.ACTIONBOT_GITHUB_PAT }} 38 | fail-if-violations: 'true' 39 | 40 | - name: Respond to action policy violations 41 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 42 | if: steps.Actionbot.outputs.violations 43 | with: 44 | github-token: ${{ secrets.ACTIONBOT_GITHUB_PAT }} 45 | script: | 46 | const violations = JSON.parse(steps.actionbot.outputs.violations); 47 | console.log('Violations found:', violations); 48 | 49 | test-prohibit-policy: 50 | runs-on: ubuntu-latest 51 | needs: build 52 | steps: 53 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 54 | 55 | - name: Test Prohibit Policy 56 | uses: ./ 57 | with: 58 | policy: 'prohibit' 59 | policy-url: 'https://github.com/Contrast-Security-OSS/actionbot/blob/main/example_prohibit_policy.json' # Replace with your prohibit policy URL 60 | github-token: ${{ secrets.ACTIONBOT_GITHUB_PAT }} 61 | fail-if-violations: 'true' 62 | 63 | - name: Respond to action policy violations 64 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 65 | if: steps.Actionbot.outputs.violations 66 | with: 67 | github-token: ${{ secrets.ACTIONBOT_GITHUB_PAT }} 68 | script: | 69 | const violations = JSON.parse(steps.actionbot.outputs.violations); 70 | console.log('Violations found:', violations); 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC. 4 | 5 | Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). 6 | 7 | #### [v1.0.6](https://github.com/Contrast-Security-OSS/actionbot/compare/v1.0.4...v1.0.6) 8 | 9 | - Prodsec 475 - Logs [`#7`](https://github.com/Contrast-Security-OSS/actionbot/pull/7) 10 | - Prodsec 462 - fix policyUrl context [`#6`](https://github.com/Contrast-Security-OSS/actionbot/pull/6) 11 | - Prodsec 462 - fix policy url [`#5`](https://github.com/Contrast-Security-OSS/actionbot/pull/5) 12 | - PRODDSEC-462 - Add support to policies hosted in private Github repositories [`#4`](https://github.com/Contrast-Security-OSS/actionbot/pull/4) 13 | - chore: Logs more readable [`1909c15`](https://github.com/Contrast-Security-OSS/actionbot/commit/1909c15fcc7b4ee0d5684406dc4083fe7468347d) 14 | - chore: build [`0cdbb1e`](https://github.com/Contrast-Security-OSS/actionbot/commit/0cdbb1e07d25532a1f06842dee4cc7242d181e61) 15 | - fix: scan subdirectories in workflows and actions folder [`f11daf4`](https://github.com/Contrast-Security-OSS/actionbot/commit/f11daf4baf8cea627e2ba44a795ed774ecd3a71a) 16 | 17 | #### [v1.0.4](https://github.com/Contrast-Security-OSS/actionbot/compare/v1.0.3...v1.0.4) 18 | 19 | > 1 May 2025 20 | 21 | - fix: policyUrl getContents was using context owner and repo [`71e65a3`](https://github.com/Contrast-Security-OSS/actionbot/commit/71e65a36efa30aff6fc8c7e53abdb462131bf2f3) 22 | - chore: update changelog [`12fd334`](https://github.com/Contrast-Security-OSS/actionbot/commit/12fd334cbdc30bfd78602236adc3680e0ea28bc2) 23 | 24 | #### [v1.0.3](https://github.com/Contrast-Security-OSS/actionbot/compare/v1.0.0...v1.0.3) 25 | 26 | > 1 May 2025 27 | 28 | - Bump eslint-plugin-github from 5.1.8 to 6.0.0 [`#1`](https://github.com/Contrast-Security-OSS/actionbot/pull/1) 29 | - Migration build [`#3`](https://github.com/Contrast-Security-OSS/actionbot/pull/3) 30 | - Migrate @actions/github from 4.0.0 to 6.0.0 [`#2`](https://github.com/Contrast-Security-OSS/actionbot/pull/2) 31 | - main.ts lint changes [`3182d32`](https://github.com/Contrast-Security-OSS/actionbot/commit/3182d3203c5c7dbc9edb7b96289aeddd38c62954) 32 | - Lint changes [`8235327`](https://github.com/Contrast-Security-OSS/actionbot/commit/823532706c187506bcb5f71d6bb00f8727390037) 33 | - Build [`4882543`](https://github.com/Contrast-Security-OSS/actionbot/commit/48825436495029524a627038ad1dcfb1999eb3c4) 34 | 35 | #### v1.0.0 36 | 37 | > 18 March 2025 38 | 39 | - Add files via upload [`fa95c96`](https://github.com/Contrast-Security-OSS/actionbot/commit/fa95c969a50f6c331ec8ef19580fb9b8521851ca) 40 | - adding main source [`8abd978`](https://github.com/Contrast-Security-OSS/actionbot/commit/8abd978c460b0e7b9c9cc611eb5540e5a18011f9) 41 | - Initial upload [`594800b`](https://github.com/Contrast-Security-OSS/actionbot/commit/594800ba2c056682322dc9e2176f95f16c131ba7) 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :running: Actionbot 2 | 3 | This GitHub action allows you to enforce allow and prohit policies on the actions used in your GitHub workflows. It checks your repository's workflow files (`.github/workflows/*.yaml` or `.yml`) to ensure that the actions they use comply with a defined policy as an "allow" list and/or "prohibit" list, set by you. 4 | 5 | ## 🏁 Actions can be added to allow/prohit policies by: 6 | * Author 7 | * Author/Action 8 | * Author/Action@Ref 9 | 10 | ## :dart: Usage 11 | 12 | 1. Create a policy JSON file (e.g., `allow-policy.json`, `prohibit-policy.json`) that defines your allowed or prohibited actions, respectively. 13 | 14 | **Example `allow-policy.json`:** 15 | 16 | ```json 17 | { 18 | "actions": [ 19 | "Contrast-Security-OSS", //example allowed Author 20 | "actions/aws-codebuild-run-build", //example allowed Author/Action 21 | "actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf", //example allowed Author/Action@Ref (commit hash) 22 | "actions/cache@v3.1" //example allowed Author/Action@Ref (tag) 23 | ] 24 | } 25 | ``` 26 | 27 | **Example `prohibit-policy.json`:** 28 | 29 | ```json 30 | { 31 | "actions" : [ 32 | "stCarolas", //example prohibited Author 33 | "actions/upload-artifact", //example prohibited Author/Action 34 | "actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32", //example prohibited Author/Action@Ref (commit hash) 35 | "actions/download-artifact@v1" //example prohibited Author/Action@Ref (tag) 36 | ] 37 | } 38 | ``` 39 | 2. Create a workflow file (e.g., `.github/workflows/actionbot.yml`) that uses this action. See the example below. 40 | 41 | ```yaml 42 | name: "Enforce Github Action Policy" 43 | on: 44 | push: 45 | branches: 46 | - main 47 | pull_request: 48 | types: 49 | - opened 50 | - edited 51 | jobs: 52 | enforce-allow-policy: 53 | runs-on: ubuntu-latest 54 | needs: build 55 | steps: 56 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 57 | 58 | - name: Test Allow Policy 59 | uses: Contrast-Security-OSS/actionbot@v1.0.0 60 | with: 61 | policy: 'allow' 62 | policy-url: 'https://github.com/Contrast-Security-OSS/actionbot/example_allow_policy.json' # Replace with your allow policy URL 63 | github-token: ${{ secrets.YOUR_GITHUB_PAT }} 64 | fail-if-violations: 'true' 65 | 66 | enforce-prohibit-policy: 67 | runs-on: ubuntu-latest 68 | needs: build 69 | steps: 70 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 71 | 72 | - name: Test Prohibit Policy 73 | uses: Contrast-Security-OSS/actionbot@v1.0.0 74 | with: 75 | policy: 'prohibit' 76 | policy-url: 'https://github.com/Contrast-Security-OSS/actionbot/example_prohibit_policy.json' # Replace with your prohibit policy URL 77 | github-token: ${{ secrets.YOUR_GITHUB_PAT }} 78 | fail-if-violations: 'true' 79 | ``` 80 | 81 | ## :pencil: Configuration 82 | 83 | | Input | Description | Required | Default | 84 | |---|---|---|---| 85 | | `policy` | 'allow' or 'prohibit' to specify whether the policy is an allow list or a prohibit list. | Yes | - | 86 | | `policy-url` | URL of the JSON policy file. | Yes | - | 87 | | `github-token` | GitHub token for API access (usually ${{ secrets.GITHUB_TOKEN }}). | Yes | - | 88 | | `fail-if-violations` | Whether to fail the workflow if policy violations are found. | No | 'false' | 89 | 90 | 91 | ## :warning: Responding to Policy Violations 92 | 93 | This action provides an output variable `violations` that contains an array of JSON objects representing the policy violations. You can use this output in subsequent steps of your workflow to perform actions like: 94 | 95 | * **Creating an issue:** Create a GitHub issue to report the violations. 96 | * **Adding a comment to a pull request:** Comment on the pull request with details about the violations. 97 | * **Sending notifications:** Send notifications to a Slack channel or other communication tools. 98 | 99 | **Example using `actions/github-script`:** 100 | 101 | ```yaml 102 | - name: Respond to action policy violations 103 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 104 | if: steps.action-policy.outputs.violations 105 | with: 106 | github-token: ${{ secrets.GITHUB_TOKEN }} 107 | script: | 108 | const violations = JSON.parse(steps.action-policy.outputs.violations); 109 | console.log('Violations found:', violations); 110 | // Add your logic here to handle the violations (e.g., create an issue, comment on a PR) 111 | ``` 112 | 113 | ## :boom: In Action 114 | 115 | * **Workflow Output:** The action logs information about the policy evaluation and any violations found. 116 | * **Workflow Status:** If `fail-if-violations` is set to `true`, the workflow run will fail if violations are detected. 117 | 118 | ## :scroll: License 119 | 120 | MIT 121 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import fs from "fs"; 3 | import * as ghf from "./github_files"; 4 | import path from "path"; 5 | import yamlParse from "js-yaml"; 6 | const github = require("@actions/github"); 7 | const core = require("@actions/core"); 8 | 9 | export class Action { 10 | constructor(actionString: string) { 11 | actionString = actionString.toLowerCase(); 12 | let as = actionString.split("/"); 13 | this.author = as[0]; 14 | 15 | let action = as[1].split("@"); 16 | this.name = action[0]; 17 | this.ref = action.length > 1 ? action[1] : "*"; 18 | } 19 | 20 | toString(): string { 21 | return `${this.author}/${this.name}@${this.ref}`; 22 | } 23 | 24 | author: string; 25 | name: string; 26 | ref: string; 27 | } 28 | 29 | export interface Workflow { 30 | filePath: string; 31 | actions: Array; 32 | } 33 | 34 | interface PolicyResponse { 35 | actions: string[]; 36 | } 37 | 38 | function isGitHubUrl(url: string): boolean { 39 | const githubPattern = /^https?:\/\/(www\.)?github\.com\/.+/; 40 | return githubPattern.test(url); 41 | } 42 | 43 | function isPolicyResponse(obj: any): obj is PolicyResponse { 44 | return typeof obj === "object" && obj !== null && Array.isArray(obj.actions); 45 | } 46 | 47 | async function run(context: typeof github.context): Promise { 48 | try { 49 | const line = "-------------------------------------------"; 50 | 51 | const policyType = core.getInput("policy", { required: true }); 52 | const policyUrl = core.getInput("policy-url", { required: true }); 53 | const gitHubToken = core.getInput("github-token", { required: true }); 54 | const failIfViolations = 55 | core.getInput("fail-if-violations", { required: false }) == "true"; 56 | 57 | if (!policyType || (policyType != "allow" && policyType != "prohibit")) 58 | throw new Error("policy must be set to 'allow' or 'prohibit'"); 59 | 60 | if (!policyUrl) throw new Error("policy-url not set"); 61 | 62 | const client = github.getOctokit(gitHubToken); 63 | 64 | //get all the modified or added files in the commits 65 | let allFiles = new Set(); 66 | let commits; 67 | 68 | switch (context.eventName) { 69 | case "pull_request": 70 | // Get the pull request number 71 | const prNumber = github.context.payload.pull_request?.number; 72 | 73 | if (prNumber) { 74 | console.log("prNumber : " + prNumber); 75 | // Fetch the pull request details to get the commits_url 76 | const prDetails = await client.rest.pulls.get({ 77 | owner: github.context.repo.owner, 78 | repo: github.context.repo.repo, 79 | pull_number: prNumber, 80 | }); 81 | console.log("prDetails : " + prDetails); 82 | 83 | // Use the commits_url to fetch commits related to the pull request 84 | const url = prDetails.data.commits_url; 85 | console.log("url : " + url); 86 | 87 | commits = await client.paginate(`GET ${url}`, { 88 | owner: github.context.repo.owner, 89 | repo: github.context.repo.repo, 90 | }); 91 | console.log("commits : " + commits); 92 | } else { 93 | console.error("Pull request number not found in payload."); 94 | commits = []; 95 | } 96 | break; 97 | case "push": 98 | // Get the pull request number 99 | const prNumber2 = github.context.payload.pull_request?.number; 100 | 101 | if (prNumber2) { 102 | console.log("prNumber2 : " + prNumber2); 103 | // Fetch the pull request details to get the commits_url 104 | const prDetails2 = await client.rest.pulls.get({ 105 | owner: github.context.repo.owner, 106 | repo: github.context.repo.repo, 107 | pull_number: prNumber2, 108 | }); 109 | console.log("prDetails : " + prDetails2); 110 | 111 | // Use the commits_url to fetch commits related to the pull request 112 | const url2 = prDetails2.data.commits_url; 113 | console.log("url2 : " + url2); 114 | 115 | commits = await client.paginate(`GET ${url2}`, { 116 | owner: github.context.repo.owner, 117 | repo: github.context.repo.repo, 118 | }); 119 | console.log("commits : " + commits); 120 | } else { 121 | console.error("Pull request number not found in payload."); 122 | commits = []; 123 | } 124 | break; 125 | default: 126 | commits = []; 127 | } 128 | 129 | for (let i = 0; i < commits.length; i++) { 130 | var f = await ghf.getFilesInCommit(commits[i], gitHubToken); 131 | f.forEach((element) => allFiles.add(element)); 132 | } 133 | 134 | let actionPolicyList = new Array(); 135 | let actionViolations = new Array(); 136 | let workflowFiles = new Array(); 137 | let workflowFilePaths = new Array(); 138 | 139 | //look for any workflow file updates 140 | allFiles.forEach((file) => { 141 | let filePath = path.parse(file); 142 | 143 | console.log("filePath : " + filePath); 144 | const dirLower = filePath.dir.toLowerCase(); 145 | console.log("filePath.ext.toLowerCase() : " + filePath.ext.toLowerCase()); 146 | console.log("dirLower : " + dirLower); 147 | if ( 148 | (filePath.ext.toLowerCase() == ".yaml" || filePath.ext.toLowerCase() == ".yml") && 149 | (dirLower.startsWith(".github/workflows") || dirLower.startsWith(".github/actions")) 150 | ) { 151 | workflowFilePaths.push(file); 152 | } 153 | }); 154 | 155 | //No workflow updates - byeee! 156 | if (workflowFilePaths.length == 0) { 157 | console.log("No workflow files detected in change set."); 158 | return; 159 | } 160 | 161 | if (!isGitHubUrl(policyUrl)) { 162 | // Load up the remote policy list 163 | await fetch(policyUrl) 164 | .then((response) => response.json() as Promise) 165 | .then((json) => { 166 | // json is now correctly typed as PolicyResponse 167 | json.actions.forEach((as) => { 168 | actionPolicyList.push(new Action(as)); 169 | }); 170 | }) 171 | .catch((error) => { 172 | console.error("Error fetching or parsing policy:", error); 173 | // Handle the error appropriately (e.g., throw an error, set a default policy) 174 | }); 175 | } else { 176 | // Extract owner, repo, and file path from the policyUrl 177 | const urlParts = policyUrl.replace("https://github.com/", "").split("/"); 178 | const owner = urlParts[0]; // Extract the owner 179 | const repo = urlParts[1]; // Extract the repository name 180 | const filePath = urlParts.slice(4).join("/"); // Extract the file path after 'blob/{branch}' 181 | 182 | const response = await client.rest.repos.getContent({ 183 | owner: owner, // Use the extracted owner 184 | repo: repo, // Use the extracted repo 185 | path: filePath, // Use the extracted file path 186 | }); 187 | 188 | if (response.data && "content" in response.data) { 189 | const content = Buffer.from(response.data.content, "base64").toString( 190 | "utf-8", 191 | ); 192 | const json = JSON.parse(content) as PolicyResponse; 193 | json.actions.forEach((as) => { 194 | actionPolicyList.push(new Action(as)); 195 | }); 196 | } else { 197 | throw new Error("Failed to load GitHub policy list."); 198 | } 199 | } 200 | 201 | console.log("\nACTION POLICY LIST"); 202 | console.log(line); 203 | actionPolicyList.forEach((item) => { 204 | console.log(item.toString()); 205 | }); 206 | 207 | console.log("\nREADING WORKFLOW FILES"); 208 | workflowFilePaths.forEach((wf) => { 209 | console.log(line); 210 | let workflow: Workflow = { filePath: wf, actions: Array() }; 211 | workflowFiles.push(workflow); 212 | if (fs.existsSync(workflow.filePath)) { 213 | console.log("\nReading:" + workflow.filePath); 214 | try { 215 | let yaml: any = yamlParse.load( 216 | fs.readFileSync(workflow.filePath, "utf-8"), 217 | ); 218 | let actionStrings = getPropertyValues(yaml, "uses"); 219 | 220 | actionStrings.forEach((as) => { 221 | workflow.actions.push(new Action(as)); 222 | }); 223 | } catch (error: unknown) { 224 | if (error instanceof Error) { 225 | core.debug(error.message); 226 | core.setFailed( 227 | `Unable to parse workflow file '${workflow.filePath}' - please ensure it's formatted properly.`, 228 | ); 229 | } else { 230 | // Handle cases where error is not an Error object 231 | core.debug("An unknown error occurred."); 232 | core.setFailed("An unknown error occurred."); 233 | } 234 | console.log(error); 235 | } 236 | } 237 | }); 238 | 239 | //iterate through all the workflow files found 240 | workflowFiles.forEach((workflow: Workflow) => { 241 | console.log(`\nEvaluating '${workflow.filePath}'`); 242 | console.log(line); 243 | 244 | let violation: Workflow = { 245 | filePath: workflow.filePath, 246 | actions: Array(), 247 | }; 248 | workflow.actions.forEach((action: Action) => { 249 | console.log(` - ${action.toString()}`); 250 | 251 | if (action.author == ".") return; 252 | 253 | let match = actionPolicyList.find( 254 | (policy) => 255 | policy.author === action.author && 256 | (policy.name === "*" || action.name === policy.name) && 257 | (policy.ref === "*" || action.ref == policy.ref), 258 | ); 259 | 260 | if (policyType == "allow") { 261 | if (!match) { 262 | violation.actions.push(action); 263 | } 264 | } else if (policyType == "prohibit") { 265 | if (match) { 266 | violation.actions.push(action); 267 | } 268 | } 269 | }); 270 | 271 | if (violation.actions.length > 0) { 272 | actionViolations.push(violation); 273 | } else { 274 | console.log("\n ✅ No violations detected"); 275 | } 276 | }); 277 | 278 | if (actionViolations.length > 0) { 279 | core.setOutput("violations", actionViolations); 280 | console.log("\n ❌ ACTION POLICY VIOLATIONS DETECTED ❌"); 281 | console.log("\n At least one action does not conform to the policy provided. See above for Actionbot workflow output for details."); 282 | console.log(line); 283 | 284 | actionViolations.forEach((workflow) => { 285 | console.log(`Workflow: ${workflow.filePath}`); 286 | 287 | workflow.actions.forEach((action) => { 288 | console.log(` - ${action.toString()}`); 289 | }); 290 | 291 | console.log(); 292 | }); 293 | 294 | if (failIfViolations) { 295 | core.setFailed(" ❌ ACTION POLICY VIOLATIONS DETECTED ❌"); 296 | } 297 | } else { 298 | console.log( 299 | "\n ✅ All workflow files contain actions that conform to the policy provided.", 300 | ); 301 | } 302 | } catch (error: unknown) { 303 | if (error instanceof Error) { 304 | core.debug(error.message); 305 | core.setFailed(error.message); 306 | } else { 307 | // Handle cases where error is not an Error object 308 | core.debug("An unknown error occurred."); 309 | core.setFailed("An unknown error occurred."); 310 | } 311 | console.log(error); 312 | } 313 | } 314 | 315 | function getPropertyValues( 316 | obj: any, 317 | propName: string, 318 | values?: string[], 319 | ): string[] { 320 | if (!values) values = []; 321 | 322 | for (var property in obj) { 323 | if (obj.hasOwnProperty(property)) { 324 | if (typeof obj[property] == "object") { 325 | getPropertyValues(obj[property], propName, values); 326 | } else { 327 | if (property == propName) { 328 | values.push(obj[property]); 329 | //console.log(property + " " + obj[property]); 330 | } 331 | } 332 | } 333 | } 334 | return values; 335 | } 336 | 337 | run(github.context); 338 | -------------------------------------------------------------------------------- /lib/101.index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | exports.id = 101; 3 | exports.ids = [101]; 4 | exports.modules = { 5 | 6 | /***/ 9101: 7 | /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => { 8 | 9 | /* harmony export */ __webpack_require__.d(__webpack_exports__, { 10 | /* harmony export */ toFormData: () => (/* binding */ toFormData) 11 | /* harmony export */ }); 12 | /* harmony import */ var fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9802); 13 | /* harmony import */ var formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3018); 14 | 15 | 16 | 17 | let s = 0; 18 | const S = { 19 | START_BOUNDARY: s++, 20 | HEADER_FIELD_START: s++, 21 | HEADER_FIELD: s++, 22 | HEADER_VALUE_START: s++, 23 | HEADER_VALUE: s++, 24 | HEADER_VALUE_ALMOST_DONE: s++, 25 | HEADERS_ALMOST_DONE: s++, 26 | PART_DATA_START: s++, 27 | PART_DATA: s++, 28 | END: s++ 29 | }; 30 | 31 | let f = 1; 32 | const F = { 33 | PART_BOUNDARY: f, 34 | LAST_BOUNDARY: f *= 2 35 | }; 36 | 37 | const LF = 10; 38 | const CR = 13; 39 | const SPACE = 32; 40 | const HYPHEN = 45; 41 | const COLON = 58; 42 | const A = 97; 43 | const Z = 122; 44 | 45 | const lower = c => c | 0x20; 46 | 47 | const noop = () => {}; 48 | 49 | class MultipartParser { 50 | /** 51 | * @param {string} boundary 52 | */ 53 | constructor(boundary) { 54 | this.index = 0; 55 | this.flags = 0; 56 | 57 | this.onHeaderEnd = noop; 58 | this.onHeaderField = noop; 59 | this.onHeadersEnd = noop; 60 | this.onHeaderValue = noop; 61 | this.onPartBegin = noop; 62 | this.onPartData = noop; 63 | this.onPartEnd = noop; 64 | 65 | this.boundaryChars = {}; 66 | 67 | boundary = '\r\n--' + boundary; 68 | const ui8a = new Uint8Array(boundary.length); 69 | for (let i = 0; i < boundary.length; i++) { 70 | ui8a[i] = boundary.charCodeAt(i); 71 | this.boundaryChars[ui8a[i]] = true; 72 | } 73 | 74 | this.boundary = ui8a; 75 | this.lookbehind = new Uint8Array(this.boundary.length + 8); 76 | this.state = S.START_BOUNDARY; 77 | } 78 | 79 | /** 80 | * @param {Uint8Array} data 81 | */ 82 | write(data) { 83 | let i = 0; 84 | const length_ = data.length; 85 | let previousIndex = this.index; 86 | let {lookbehind, boundary, boundaryChars, index, state, flags} = this; 87 | const boundaryLength = this.boundary.length; 88 | const boundaryEnd = boundaryLength - 1; 89 | const bufferLength = data.length; 90 | let c; 91 | let cl; 92 | 93 | const mark = name => { 94 | this[name + 'Mark'] = i; 95 | }; 96 | 97 | const clear = name => { 98 | delete this[name + 'Mark']; 99 | }; 100 | 101 | const callback = (callbackSymbol, start, end, ui8a) => { 102 | if (start === undefined || start !== end) { 103 | this[callbackSymbol](ui8a && ui8a.subarray(start, end)); 104 | } 105 | }; 106 | 107 | const dataCallback = (name, clear) => { 108 | const markSymbol = name + 'Mark'; 109 | if (!(markSymbol in this)) { 110 | return; 111 | } 112 | 113 | if (clear) { 114 | callback(name, this[markSymbol], i, data); 115 | delete this[markSymbol]; 116 | } else { 117 | callback(name, this[markSymbol], data.length, data); 118 | this[markSymbol] = 0; 119 | } 120 | }; 121 | 122 | for (i = 0; i < length_; i++) { 123 | c = data[i]; 124 | 125 | switch (state) { 126 | case S.START_BOUNDARY: 127 | if (index === boundary.length - 2) { 128 | if (c === HYPHEN) { 129 | flags |= F.LAST_BOUNDARY; 130 | } else if (c !== CR) { 131 | return; 132 | } 133 | 134 | index++; 135 | break; 136 | } else if (index - 1 === boundary.length - 2) { 137 | if (flags & F.LAST_BOUNDARY && c === HYPHEN) { 138 | state = S.END; 139 | flags = 0; 140 | } else if (!(flags & F.LAST_BOUNDARY) && c === LF) { 141 | index = 0; 142 | callback('onPartBegin'); 143 | state = S.HEADER_FIELD_START; 144 | } else { 145 | return; 146 | } 147 | 148 | break; 149 | } 150 | 151 | if (c !== boundary[index + 2]) { 152 | index = -2; 153 | } 154 | 155 | if (c === boundary[index + 2]) { 156 | index++; 157 | } 158 | 159 | break; 160 | case S.HEADER_FIELD_START: 161 | state = S.HEADER_FIELD; 162 | mark('onHeaderField'); 163 | index = 0; 164 | // falls through 165 | case S.HEADER_FIELD: 166 | if (c === CR) { 167 | clear('onHeaderField'); 168 | state = S.HEADERS_ALMOST_DONE; 169 | break; 170 | } 171 | 172 | index++; 173 | if (c === HYPHEN) { 174 | break; 175 | } 176 | 177 | if (c === COLON) { 178 | if (index === 1) { 179 | // empty header field 180 | return; 181 | } 182 | 183 | dataCallback('onHeaderField', true); 184 | state = S.HEADER_VALUE_START; 185 | break; 186 | } 187 | 188 | cl = lower(c); 189 | if (cl < A || cl > Z) { 190 | return; 191 | } 192 | 193 | break; 194 | case S.HEADER_VALUE_START: 195 | if (c === SPACE) { 196 | break; 197 | } 198 | 199 | mark('onHeaderValue'); 200 | state = S.HEADER_VALUE; 201 | // falls through 202 | case S.HEADER_VALUE: 203 | if (c === CR) { 204 | dataCallback('onHeaderValue', true); 205 | callback('onHeaderEnd'); 206 | state = S.HEADER_VALUE_ALMOST_DONE; 207 | } 208 | 209 | break; 210 | case S.HEADER_VALUE_ALMOST_DONE: 211 | if (c !== LF) { 212 | return; 213 | } 214 | 215 | state = S.HEADER_FIELD_START; 216 | break; 217 | case S.HEADERS_ALMOST_DONE: 218 | if (c !== LF) { 219 | return; 220 | } 221 | 222 | callback('onHeadersEnd'); 223 | state = S.PART_DATA_START; 224 | break; 225 | case S.PART_DATA_START: 226 | state = S.PART_DATA; 227 | mark('onPartData'); 228 | // falls through 229 | case S.PART_DATA: 230 | previousIndex = index; 231 | 232 | if (index === 0) { 233 | // boyer-moore derrived algorithm to safely skip non-boundary data 234 | i += boundaryEnd; 235 | while (i < bufferLength && !(data[i] in boundaryChars)) { 236 | i += boundaryLength; 237 | } 238 | 239 | i -= boundaryEnd; 240 | c = data[i]; 241 | } 242 | 243 | if (index < boundary.length) { 244 | if (boundary[index] === c) { 245 | if (index === 0) { 246 | dataCallback('onPartData', true); 247 | } 248 | 249 | index++; 250 | } else { 251 | index = 0; 252 | } 253 | } else if (index === boundary.length) { 254 | index++; 255 | if (c === CR) { 256 | // CR = part boundary 257 | flags |= F.PART_BOUNDARY; 258 | } else if (c === HYPHEN) { 259 | // HYPHEN = end boundary 260 | flags |= F.LAST_BOUNDARY; 261 | } else { 262 | index = 0; 263 | } 264 | } else if (index - 1 === boundary.length) { 265 | if (flags & F.PART_BOUNDARY) { 266 | index = 0; 267 | if (c === LF) { 268 | // unset the PART_BOUNDARY flag 269 | flags &= ~F.PART_BOUNDARY; 270 | callback('onPartEnd'); 271 | callback('onPartBegin'); 272 | state = S.HEADER_FIELD_START; 273 | break; 274 | } 275 | } else if (flags & F.LAST_BOUNDARY) { 276 | if (c === HYPHEN) { 277 | callback('onPartEnd'); 278 | state = S.END; 279 | flags = 0; 280 | } else { 281 | index = 0; 282 | } 283 | } else { 284 | index = 0; 285 | } 286 | } 287 | 288 | if (index > 0) { 289 | // when matching a possible boundary, keep a lookbehind reference 290 | // in case it turns out to be a false lead 291 | lookbehind[index - 1] = c; 292 | } else if (previousIndex > 0) { 293 | // if our boundary turned out to be rubbish, the captured lookbehind 294 | // belongs to partData 295 | const _lookbehind = new Uint8Array(lookbehind.buffer, lookbehind.byteOffset, lookbehind.byteLength); 296 | callback('onPartData', 0, previousIndex, _lookbehind); 297 | previousIndex = 0; 298 | mark('onPartData'); 299 | 300 | // reconsider the current character even so it interrupted the sequence 301 | // it could be the beginning of a new sequence 302 | i--; 303 | } 304 | 305 | break; 306 | case S.END: 307 | break; 308 | default: 309 | throw new Error(`Unexpected state entered: ${state}`); 310 | } 311 | } 312 | 313 | dataCallback('onHeaderField'); 314 | dataCallback('onHeaderValue'); 315 | dataCallback('onPartData'); 316 | 317 | // Update properties for the next call 318 | this.index = index; 319 | this.state = state; 320 | this.flags = flags; 321 | } 322 | 323 | end() { 324 | if ((this.state === S.HEADER_FIELD_START && this.index === 0) || 325 | (this.state === S.PART_DATA && this.index === this.boundary.length)) { 326 | this.onPartEnd(); 327 | } else if (this.state !== S.END) { 328 | throw new Error('MultipartParser.end(): stream ended unexpectedly'); 329 | } 330 | } 331 | } 332 | 333 | function _fileName(headerValue) { 334 | // matches either a quoted-string or a token (RFC 2616 section 19.5.1) 335 | const m = headerValue.match(/\bfilename=("(.*?)"|([^()<>@,;:\\"/[\]?={}\s\t]+))($|;\s)/i); 336 | if (!m) { 337 | return; 338 | } 339 | 340 | const match = m[2] || m[3] || ''; 341 | let filename = match.slice(match.lastIndexOf('\\') + 1); 342 | filename = filename.replace(/%22/g, '"'); 343 | filename = filename.replace(/&#(\d{4});/g, (m, code) => { 344 | return String.fromCharCode(code); 345 | }); 346 | return filename; 347 | } 348 | 349 | async function toFormData(Body, ct) { 350 | if (!/multipart/i.test(ct)) { 351 | throw new TypeError('Failed to fetch'); 352 | } 353 | 354 | const m = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i); 355 | 356 | if (!m) { 357 | throw new TypeError('no or bad content-type header, no multipart boundary'); 358 | } 359 | 360 | const parser = new MultipartParser(m[1] || m[2]); 361 | 362 | let headerField; 363 | let headerValue; 364 | let entryValue; 365 | let entryName; 366 | let contentType; 367 | let filename; 368 | const entryChunks = []; 369 | const formData = new formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1__/* .FormData */ .fS(); 370 | 371 | const onPartData = ui8a => { 372 | entryValue += decoder.decode(ui8a, {stream: true}); 373 | }; 374 | 375 | const appendToFile = ui8a => { 376 | entryChunks.push(ui8a); 377 | }; 378 | 379 | const appendFileToFormData = () => { 380 | const file = new fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0__/* .File */ .ZH(entryChunks, filename, {type: contentType}); 381 | formData.append(entryName, file); 382 | }; 383 | 384 | const appendEntryToFormData = () => { 385 | formData.append(entryName, entryValue); 386 | }; 387 | 388 | const decoder = new TextDecoder('utf-8'); 389 | decoder.decode(); 390 | 391 | parser.onPartBegin = function () { 392 | parser.onPartData = onPartData; 393 | parser.onPartEnd = appendEntryToFormData; 394 | 395 | headerField = ''; 396 | headerValue = ''; 397 | entryValue = ''; 398 | entryName = ''; 399 | contentType = ''; 400 | filename = null; 401 | entryChunks.length = 0; 402 | }; 403 | 404 | parser.onHeaderField = function (ui8a) { 405 | headerField += decoder.decode(ui8a, {stream: true}); 406 | }; 407 | 408 | parser.onHeaderValue = function (ui8a) { 409 | headerValue += decoder.decode(ui8a, {stream: true}); 410 | }; 411 | 412 | parser.onHeaderEnd = function () { 413 | headerValue += decoder.decode(); 414 | headerField = headerField.toLowerCase(); 415 | 416 | if (headerField === 'content-disposition') { 417 | // matches either a quoted-string or a token (RFC 2616 section 19.5.1) 418 | const m = headerValue.match(/\bname=("([^"]*)"|([^()<>@,;:\\"/[\]?={}\s\t]+))/i); 419 | 420 | if (m) { 421 | entryName = m[2] || m[3] || ''; 422 | } 423 | 424 | filename = _fileName(headerValue); 425 | 426 | if (filename) { 427 | parser.onPartData = appendToFile; 428 | parser.onPartEnd = appendFileToFormData; 429 | } 430 | } else if (headerField === 'content-type') { 431 | contentType = headerValue; 432 | } 433 | 434 | headerValue = ''; 435 | headerField = ''; 436 | }; 437 | 438 | for await (const chunk of Body) { 439 | parser.write(chunk); 440 | } 441 | 442 | parser.end(); 443 | 444 | return formData; 445 | } 446 | 447 | 448 | /***/ }) 449 | 450 | }; 451 | ; --------------------------------------------------------------------------------