├── analyzer ├── __init__.py ├── analyzer_test.py ├── regex.py └── analyzer.py ├── __about__.py ├── example.dockerfile ├── .pylintrc ├── requirements.txt ├── actions ├── action-with-pull-request-target.yml ├── action-with-write-all-permissions.yml ├── action-with-remote-script.yml ├── action-using-self-hosted-runners.yml ├── action-with-write-permissions-all-jobs.yml ├── action-using-self-hosted-runner-referenced-by-group.yml ├── action-with-unsecure-command-env-var.yml ├── action-using-upload-artifact-action.yml ├── action-creates-or-approves-pr-using-gh-script.yml ├── action-with-dangerous-gh-variables.yml ├── action-creates-or-approves-pr-using-gh-cli.yml ├── action-with-dangerous-gh-context-variables.yml ├── action-using-self-hosted-runner-in-matrix.yml ├── action-with-inline-script.yml ├── action-with-write-permissions-one-job.yml ├── action-with-dangerous-gh-variables-2.yml ├── action-using-github-cache.yml ├── action-using-configure-aws-creds-non-oidc-auth.yml └── action-create-or-approves-pr-using-curl.yml ├── .github └── workflows │ └── ghast-scan.yml ├── colors.py ├── LICENSE ├── setup.py ├── action.yml ├── EXAMPLES.md ├── .gitignore ├── pyproject.toml ├── main.py └── README.md /analyzer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /__about__.py: -------------------------------------------------------------------------------- 1 | """ about.py """ 2 | 3 | __version__ = "1.8.8" 4 | -------------------------------------------------------------------------------- /example.dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginxdemos/hello:latest 2 | EXPOSE 80 3 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable=line-too-long,fixme,broad-exception-caught,consider-using-dict-items -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | iniconfig==2.0.0 2 | packaging==23.1 3 | pluggy==1.2.0 4 | pytest==7.4.0 5 | PyYAML==6.0 6 | -------------------------------------------------------------------------------- /actions/action-with-pull-request-target.yml: -------------------------------------------------------------------------------- 1 | name: ActionWithPullRequestTarget 2 | on: [pull_request_target] 3 | jobs: 4 | SomeJob: 5 | steps: 6 | - name: "Task 1..." 7 | run: "echo running task 1..." 8 | -------------------------------------------------------------------------------- /actions/action-with-write-all-permissions.yml: -------------------------------------------------------------------------------- 1 | name: WriteAllPermissionsAllJobs 2 | on: [push] 3 | permissions: write-all 4 | jobs: 5 | SomeJob: 6 | steps: 7 | - name: "Task 1" 8 | run: "running task 1" 9 | -------------------------------------------------------------------------------- /actions/action-with-remote-script.yml: -------------------------------------------------------------------------------- 1 | name: "ActionWithRemoteScript" 2 | on: push 3 | jobs: 4 | SomeJob: 5 | steps: 6 | - name: "curl script and execute" 7 | run: curl https://somedomain.com/install.sh | sh 8 | -------------------------------------------------------------------------------- /actions/action-using-self-hosted-runners.yml: -------------------------------------------------------------------------------- 1 | name: ActionUsingSelfHostedRunner 2 | on: [push] 3 | jobs: 4 | SomeJob: 5 | runs-on: [self-hosted-runner] 6 | steps: 7 | - name: "Task 1..." 8 | run: "echo running task 1..." 9 | -------------------------------------------------------------------------------- /actions/action-with-write-permissions-all-jobs.yml: -------------------------------------------------------------------------------- 1 | name: WritePermissionsAllJobs 2 | on: [push] 3 | permissions: 4 | contents: write 5 | jobs: 6 | SomeJob: 7 | steps: 8 | - name: "Task 1" 9 | run: "running task 1" 10 | -------------------------------------------------------------------------------- /actions/action-using-self-hosted-runner-referenced-by-group.yml: -------------------------------------------------------------------------------- 1 | name: ActionUsingSelfHostedGroupReference 2 | on: [push] 3 | jobs: 4 | SomeJob: 5 | runs-on: 6 | group: ubunter-runners 7 | steps: 8 | - name: "Task 1..." 9 | run: "echo running task 1..." 10 | -------------------------------------------------------------------------------- /actions/action-with-unsecure-command-env-var.yml: -------------------------------------------------------------------------------- 1 | name: ActionWithUnsecureCommandEnv 2 | on: [push, pull_request] 3 | jobs: 4 | DangerousJob: 5 | steps: 6 | - name: "dangerous task" 7 | env: 8 | ACTIONS_ALLOW_UNSECURE_COMMANDS: "true" 9 | run: echo "..." 10 | -------------------------------------------------------------------------------- /actions/action-using-upload-artifact-action.yml: -------------------------------------------------------------------------------- 1 | name: "ActionUsingUploadArtifactAction" 2 | on: [push] 3 | jobs: 4 | SomeJob: 5 | steps: 6 | - name: 7 | uses: actions/upload-artifact@v3 8 | with: 9 | name: my-artifact 10 | path: path/to/artifact/file.txt 11 | -------------------------------------------------------------------------------- /actions/action-creates-or-approves-pr-using-gh-script.yml: -------------------------------------------------------------------------------- 1 | name: "ActionCreatePRGHScript" 2 | on: [pull_request] 3 | jobs: 4 | SomeJob: 5 | steps: 6 | - name: "Create PR using GH Script Action" 7 | uses: actions/github-script@v6 8 | with: 9 | script: | 10 | github.rest.pulls.create(...) 11 | -------------------------------------------------------------------------------- /actions/action-with-dangerous-gh-variables.yml: -------------------------------------------------------------------------------- 1 | name: ActionWithDangerousVariable 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | SomeJob: 8 | steps: 9 | - name: "Task 1..." 10 | run: "${{ github.context.pull_request.title }}" 11 | - name: "Task 2..." 12 | run: "${{ github.event.head_commit.message }}" 13 | -------------------------------------------------------------------------------- /actions/action-creates-or-approves-pr-using-gh-cli.yml: -------------------------------------------------------------------------------- 1 | name: ActionThatCreateOrApprovesPR 2 | on: [pull_request] 3 | jobs: 4 | SomeJobThatRequiresAWSCreds: 5 | steps: 6 | - name: Create a PR 7 | run: 'gh pr create --title "The bug is fixed" --body "Everything works again"' 8 | - name: Approve PR 9 | run: 'gh pr review --approve' 10 | 11 | -------------------------------------------------------------------------------- /actions/action-with-dangerous-gh-context-variables.yml: -------------------------------------------------------------------------------- 1 | name: ActionWithDangerousVariable 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | SomeJob: 8 | steps: 9 | - name: "Task 1..." 10 | run: "echo ${{ github.context.pull_request.title }}" 11 | - name: "Task 2..." 12 | run: "echo ${{ github.event.head_commit.message }}" 13 | -------------------------------------------------------------------------------- /actions/action-using-self-hosted-runner-in-matrix.yml: -------------------------------------------------------------------------------- 1 | name: My Workflow 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | build: 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, macos-latest, windows-latest] 11 | runner: [self-hosted, github-hosted] 12 | steps: 13 | - name: "Task 1..." 14 | run: "echo running Task 1..." 15 | -------------------------------------------------------------------------------- /actions/action-with-inline-script.yml: -------------------------------------------------------------------------------- 1 | name: ActionWithRun 2 | on: [pull_request] 3 | permissions: read 4 | jobs: 5 | SomeJob: 6 | steps: 7 | - name: run some script 8 | run: echo $PATH 9 | # adding download artifact example here too 10 | - uses: actions/download-artifact@v3 11 | with: 12 | name: my-artifact 13 | path: path/to/artifact 14 | -------------------------------------------------------------------------------- /actions/action-with-write-permissions-one-job.yml: -------------------------------------------------------------------------------- 1 | name: WritePermissionsOneJob 2 | on: [push] 3 | jobs: 4 | SomeJob: 5 | permissions: 6 | contents: read 7 | steps: 8 | - name: "task 1" 9 | run: "echo running task 1" 10 | AnotherJob: 11 | permissions: 12 | contents: write 13 | steps: 14 | - name: "task 2" 15 | run: "echo running task 2" 16 | -------------------------------------------------------------------------------- /actions/action-with-dangerous-gh-variables-2.yml: -------------------------------------------------------------------------------- 1 | name: 'ActionWithDangerousGHVariableExample2' 2 | on: push 3 | jobs: 4 | SomeJob: 5 | steps: 6 | - name: Check PR title 7 | run: | 8 | title="${{ github.event.pull_request.title }}" 9 | if [[ $title =~ ^octocat ]]; then 10 | echo "PR title starts with 'octocat'" 11 | exit 0 12 | else 13 | echo "PR title did not start with 'octocat'" 14 | exit 1 15 | fi 16 | - name: 17 | run: "echo another inline script..." 18 | 19 | -------------------------------------------------------------------------------- /actions/action-using-github-cache.yml: -------------------------------------------------------------------------------- 1 | name: Caching Primes 2 | on: push 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - name: Cache Primes 9 | id: cache-primes 10 | uses: actions/cache@v3 11 | with: 12 | path: prime-numbers 13 | key: ${{ runner.os }}-primes 14 | - name: Generate Prime Numbers 15 | if: steps.cache-primes.outputs.cache-hit != 'true' 16 | run: /generate-primes.sh -d prime-numbers 17 | - name: Use Prime Numbers 18 | run: /primes.sh -d prime-numbers 19 | -------------------------------------------------------------------------------- /actions/action-using-configure-aws-creds-non-oidc-auth.yml: -------------------------------------------------------------------------------- 1 | name: ActionUsingNonOIDCConfigureAWSCreds 2 | on: [pull_request] 3 | jobs: 4 | SomeJobThatRequiresAWSCreds: 5 | steps: 6 | - name: Configure AWS Credentials 7 | uses: aws-actions/configure-aws-credentials@v2 8 | with: 9 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 10 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 11 | aws-region: us-east-2 12 | role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} 13 | role-external-id: ${{ secrets.AWS_ROLE_EXTERNAL_ID }} 14 | role-duration-seconds: 1200 15 | role-session-name: MySessionName 16 | -------------------------------------------------------------------------------- /analyzer/analyzer_test.py: -------------------------------------------------------------------------------- 1 | """analyzer_test.py: tests for analyzer.py""" 2 | # pylint: disable=missing-function-docstring 3 | from pathlib import Path 4 | import pytest 5 | import yaml 6 | 7 | from analyzer.analyzer import Analyzer 8 | 9 | 10 | @pytest.fixture(name="analyzer") 11 | def analyzer_fixture(): 12 | """Returns instance of Analyzer class as fixture for pytests""" 13 | return Analyzer(ignore_checks=[]) 14 | 15 | 16 | def test_all_checks(analyzer): 17 | dir_ = Path("./actions") 18 | for file_ in dir_.iterdir(): 19 | with open(file_, "r", encoding="utf-8") as action: 20 | action_dict = yaml.safe_load(action) 21 | assert analyzer.run_checks(action=action_dict) is True 22 | -------------------------------------------------------------------------------- /.github/workflows/ghast-scan.yml: -------------------------------------------------------------------------------- 1 | name: "RunGhast" 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - dev 7 | paths: 8 | - ".github/workflows/**" 9 | jobs: 10 | RunGhast: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: "Checkout repo" 14 | uses: actions/checkout@96f53100ba2a5449eb71d2e6604bbcd94b9449b5 # v3.5.3 15 | - name: "Run Ghast" 16 | uses: "bin3xish477/ghast@591140d3068c278519062744c180bd3198b7394c" 17 | with: 18 | dir: "./actions/" 19 | verbose: true 20 | #no-summary: true 21 | #ignore-checks: '' 22 | #ignore-warnings: true 23 | continue-on-error: true # adding this since this workflow is always expected to fail 24 | -------------------------------------------------------------------------------- /actions/action-create-or-approves-pr-using-curl.yml: -------------------------------------------------------------------------------- 1 | name: "ActionCreatePRUsingCURL" 2 | on: [pull_request] 3 | jobs: 4 | SomeJob: 5 | steps: 6 | - name: "Create PR using cURL" 7 | run: | 8 | curl -L \ 9 | -X POST \ 10 | -H "Accept: application/vnd.github+json" \ 11 | -H "Authorization: Bearer " \ 12 | -H "X-GitHub-Api-Version: 2022-11-28" \ 13 | https://api.github.com/repos/alexrrr/asa/pulls/31337/reviews \ 14 | -d '{"commit_id":"ecdd80bb57125d7ba9641ffaa4d7d2c19d3f3091","body":"This is close to perfect! Please address the suggested inline change.","event":"REQUEST_CHANGES","comments":[{"path":"file.md","position":6,"body":"Please add more information here, and fix this typo."}]}' 15 | -------------------------------------------------------------------------------- /colors.py: -------------------------------------------------------------------------------- 1 | class Colors: 2 | """ANSI color codes""" 3 | 4 | BLACK = "\033[0;30m" 5 | RED = "\033[0;31m" 6 | GREEN = "\033[0;32m" 7 | BROWN = "\033[0;33m" 8 | BLUE = "\033[0;34m" 9 | PURPLE = "\033[0;35m" 10 | CYAN = "\033[0;36m" 11 | LIGHT_GRAY = "\033[0;37m" 12 | DARK_GRAY = "\033[1;30m" 13 | LIGHT_RED = "\033[1;31m" 14 | LIGHT_GREEN = "\033[1;32m" 15 | YELLOW = "\033[1;33m" 16 | LIGHT_BLUE = "\033[1;34m" 17 | LIGHT_PURPLE = "\033[1;35m" 18 | LIGHT_CYAN = "\033[1;36m" 19 | LIGHT_WHITE = "\033[1;37m" 20 | BOLD = "\033[1m" 21 | FAINT = "\033[2m" 22 | ITALIC = "\033[3m" 23 | UNDERLINE = "\033[4m" 24 | BLINK = "\033[5m" 25 | NEGATIVE = "\033[7m" 26 | CROSSED = "\033[9m" 27 | END = "\033[0m" 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Alex Rodriguez 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. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='ghast-scanner', 5 | version='1.6.8', 6 | packages=find_packages(), 7 | install_requires=[ 8 | 'iniconfig==2.0.0', 9 | 'packaging==23.1', 10 | 'pluggy==1.2.0', 11 | 'pytest==7.4.0', 12 | 'PyYAML==6.0', 13 | ], 14 | author='Alex Rodriguez', 15 | author_email='arodriguez99@protonmail.com', 16 | description='Analyze the security posture of your GitHub Action', 17 | url='https://github.com/bin3xish477/ghast', 18 | classifiers=[ 19 | 'Development Status :: 4 - Beta', 20 | 'License :: OSI Approved :: MIT License', 21 | 'Programming Language :: Python', 22 | 'Programming Language :: Python :: 3.7', 23 | 'Programming Language :: Python :: 3.8', 24 | 'Programming Language :: Python :: 3.9', 25 | 'Programming Language :: Python :: 3.10', 26 | 'Programming Language :: Python :: 3.11', 27 | 'Programming Language :: Python :: Implementation :: CPython', 28 | 'Programming Language :: Python :: Implementation :: PyPy', 29 | ], 30 | ) 31 | -------------------------------------------------------------------------------- /analyzer/regex.py: -------------------------------------------------------------------------------- 1 | """ regex.py """ 2 | 3 | ACTION_WITH_VERSION = r"([\w-]+)\/([\w-]+)@v\d+(\.\d+)?(\.\d+)?" 4 | DANGEROUS_GITHUB_CONTEXT_VARIABLE = r"\$\{\{.*github.+\}\}" 5 | POTENTIAL_REMOTE_SCRIPT = ( 6 | r"((?<=[^a-zA-Z0-9])(?:https?\:\/\/|[a-zA-Z0-9]{1,}\.{1}|\b)(?:\w{1,}\.{1})" 7 | r"{1,5}(?:com|org|edu|gov|uk|net|ca|de|jp|fr|au|us|ru|ch|it|nl|se|no|es|mil|iq|io|ac|ly|sm){1}(?:\/[a-zA-Z0-9.-_]{1,})*)" 8 | ) 9 | CACHE_ACTION = r"actions\/cache@(v\d+(\.\d+)?(\.\d+)?|[a-f0-9]{40})" 10 | UPLOAD_DOWNLOAD_ARTIFACTS_ACTION = r"actions\/(upload|download)\-artifact@(v\d+(\.\d+)?(\.\d+)?|[a-f0-9]{40})" 11 | CONFIGURE_AWS_CREDS_ACTION = r"aws\-actions\/configure\-aws\-credentials@(v\d+(\.\d+)?(\.\d+)?|[a-f0-9]{40})" 12 | GH_CLI_PR_CREATE_APPROVE = r"gh pr (review.*--approve|create.*)" 13 | GITHUB_SCRIPT_ACTION = r"actions\/github\-script@(v\d+(\.\d+)?(\.\d+)?|[a-f0-9]{40})" 14 | GITHUB_SCRIPT_CREATE_APPROVE_PR = r".*github\.rest\.pulls\.(create\(.*|reviews\(.*APPROVE.*)" 15 | CURL_CREATE_APPROVE_PR = ( 16 | r".*curl.*https:\/\/api\.github\.com\/repos\/[0-9a-zA-Z-._]+\/[0-9a-zA-Z-._]+\/pulls\/[0-9]{1,}\/reviews.*" 17 | ) 18 | GITHUB_MANAGED_ACTION = r"^actions\/.*" 19 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "ghast-scanner" 2 | description: "Scan your GitHub actions and environment for common security vulnerabilities" 3 | author: "bin3xish477" 4 | branding: 5 | icon: "shield" 6 | color: "green" 7 | inputs: 8 | dir: 9 | description: "path to directory with GitHub Actions files to scan" 10 | required: false 11 | default: "./.github/workflows" 12 | ignore-checks: 13 | description: "specify checks to ignore" 14 | required: false 15 | ignore-warnings: 16 | description: "ignore checks labeled as WARN" 17 | required: false 18 | no-summary: 19 | description: "don't show tool summary in output" 20 | required: false 21 | verbose: 22 | description: "enable verbose output" 23 | required: false 24 | runs: 25 | using: composite 26 | steps: 27 | - uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 # v4.7.0 28 | with: 29 | python-version: "3.11" 30 | - run: | 31 | pip install ghast-scanner 32 | args=() 33 | 34 | dir=$( echo ${{ inputs.dir }} | tr '[:upper:]' '[:lower:]') 35 | ignore_warnings=$( echo ${{ inputs.ignore-warnings }} | tr '[:upper:]' '[:lower:]') 36 | ignore_checks=$( echo ${{ inputs.ignore-checks }} | tr '[:upper:]' '[:lower:]') 37 | no_summary=$( echo ${{ inputs.no-summary }} | tr '[:upper:]' '[:lower:]') 38 | verbose=$( echo ${{ inputs.verbose }} | tr '[:upper:]' '[:lower:]') 39 | 40 | [ ! -z $dir ] && args+=('--dir', ${{ inputs.dir }}) 41 | [ ! -z $ignore_warnings ] && [[ $ignore_warnings != "false" ]] && args+=('--ignore-warnings') 42 | [ ! -z $no_summary ] && [[ $no_summary != "false" ]] && args+=('--no-summary') 43 | [ ! -z $verbose ] && [[ $verbose != "false" ]] && args+=('--verbose') 44 | 45 | if [[ ! -z "$ignore_checks" ]]; then 46 | checks=() 47 | for check in $(echo $ignore_checks) 48 | do 49 | checks+=("$check") 50 | done 51 | args+=('--ignore-checks', ${checks[@]/,/ }) 52 | fi 53 | 54 | echo "ARGS: ${args[@]/,/ }" 55 | ghast ${args[@]/,/ } 56 | 57 | shell: bash 58 | -------------------------------------------------------------------------------- /EXAMPLES.md: -------------------------------------------------------------------------------- 1 | # ghast 2 | 3 | GHAST (GitHub Actions Static Analysis Tool) is a tool to analyze the security posture of your GitHub Actions. 4 | The best way to do that is to automate the analysis of your Action workflows with Ghast. 5 | 6 | ### Default Ghast workflow 7 | 8 | This workflow will work for 99% of users. It will scan all the workflow files it finds in ``` ./.github/workflow/ ```. If you want to customize your Ghast scan please feel free to use one of the examples in the "Additional Ghast workflow options" section. 9 | 10 | ```yaml 11 | name: 'RunGhast' 12 | on: 13 | push: 14 | jobs: 15 | RunGhast: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: "Checkout repo" 19 | uses: actions/checkout@96f53100ba2a5449eb71d2e6604bbcd94b9449b5 # v3.5.3 20 | - name: "Run Ghast" 21 | uses: "bin3xish477/ghast@43c471b8e05599d67f618ecccfc8d7b9281bfd9b" 22 | ``` 23 | 24 | ### Additional Ghast workflow options 25 | 26 | #### Specify what branches to run Ghast in 27 | 28 | ```yaml 29 | name: 'RunGhast' 30 | on: 31 | push: 32 | branches: 33 | - main 34 | - dev 35 | - pauls-baseline 36 | jobs: 37 | RunGhast: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: "Checkout repo" 41 | uses: actions/checkout@96f53100ba2a5449eb71d2e6604bbcd94b9449b5 # v3.5.3 42 | - name: "Run Ghast" 43 | uses: "bin3xish477/ghast@ee733379e314d44f1a960a70339ee5e5d19e404d" 44 | ``` 45 | 46 | #### Ignore specific checks 47 | 48 | ```yaml 49 | name: 'RunGhast' 50 | on: 51 | push: 52 | jobs: 53 | RunGhast: 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: "Checkout repo" 57 | uses: actions/checkout@96f53100ba2a5449eb71d2e6604bbcd94b9449b5 # v3.5.3 58 | - name: "Run Ghast" 59 | uses: "bin3xish477/ghast@ee733379e314d44f1a960a70339ee5e5d19e404d" 60 | with: 61 | ignore-checks: 'check_for_inline_script check_for_cache_action_usage' 62 | ``` 63 | 64 | #### Specify directory where Action Workflow files are 65 | 66 | ```yaml 67 | name: 'RunGhast' 68 | on: 69 | push: 70 | paths: 71 | - '.github/workflows/**' 72 | jobs: 73 | RunGhast: 74 | runs-on: ubuntu-latest 75 | steps: 76 | - name: "Checkout repo" 77 | uses: actions/checkout@96f53100ba2a5449eb71d2e6604bbcd94b9449b5 # v3.5.3 78 | - name: "Run Ghast" 79 | uses: "bin3xish477/ghast@ee733379e314d44f1a960a70339ee5e5d19e404d" 80 | with: 81 | dir: ".github/workflows/my_workflows/" 82 | ``` 83 | 84 | #### Run in verbose mode, don't show tool summary section, and ignore checks labeled as warning 85 | 86 | ```yaml 87 | name: 'RunGhast' 88 | on: 89 | push: 90 | jobs: 91 | RunGhast: 92 | runs-on: ubuntu-latest 93 | steps: 94 | - name: "Checkout repo" 95 | uses: actions/checkout@96f53100ba2a5449eb71d2e6604bbcd94b9449b5 # v3.5.3 96 | - name: "Run Ghast" 97 | uses: "bin3xish477/ghast@ee733379e314d44f1a960a70339ee5e5d19e404d" 98 | with: 99 | verbose: true 100 | no-summary: true 101 | ignore-warnings: true 102 | ``` 103 | 104 | #### The kitchen sink... 105 | 106 | ```yaml 107 | name: 'RunGhast' 108 | on: 109 | push: 110 | branches: 111 | - main 112 | - dev 113 | paths: 114 | - '.github/workflows/**' 115 | jobs: 116 | RunGhast: 117 | runs-on: ubuntu-latest 118 | steps: 119 | - name: "Checkout repo" 120 | uses: actions/checkout@96f53100ba2a5449eb71d2e6604bbcd94b9449b5 # v3.5.3 121 | - name: "Run Ghast" 122 | uses: "bin3xish477/ghast@ee733379e314d44f1a960a70339ee5e5d19e404d" 123 | with: 124 | dir: ".github/workflows/my_workflows/" 125 | verbose: true 126 | no-summary: true 127 | ignore-checks: 'check_for_inline_script check_for_cache_action_usage' 128 | ``` 129 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | .vscode -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "ghast-scanner" 7 | dynamic = ["version"] 8 | description = 'Analyze the security posture of your GitHub Actions' 9 | readme = "README.md" 10 | requires-python = ">=3.7" 11 | license = "MIT" 12 | keywords = [] 13 | authors = [ 14 | { name = "Alexis Rodriguez", email = "arodriguez99@protonmail.com" }, 15 | ] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3.7", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: Implementation :: CPython", 25 | "Programming Language :: Python :: Implementation :: PyPy", 26 | ] 27 | dependencies = [ 28 | "iniconfig==2.0.0", 29 | "packaging==23.1", 30 | "pluggy==1.2.0", 31 | "pytest==7.4.0", 32 | "PyYAML==6.0", 33 | ] 34 | 35 | [project.urls] 36 | Documentation = "https://github.com/bin3xish477/ghast#readme" 37 | Issues = "https://github.com/bin3xish477/ghast/issues" 38 | Source = "https://github.com/bin3xish477/ghast" 39 | 40 | [project.scripts] 41 | ghast = "main:_main" 42 | 43 | [tool.hatch.version] 44 | path = "__about__.py" 45 | 46 | [tool.hatch.envs.default] 47 | dependencies = [ 48 | "coverage[toml]>=6.5", 49 | "pytest", 50 | ] 51 | [tool.hatch.envs.default.scripts] 52 | test = "pytest {args:tests}" 53 | test-cov = "coverage run -m pytest {args:tests}" 54 | cov-report = [ 55 | "- coverage combine", 56 | "coverage report", 57 | ] 58 | cov = [ 59 | "test-cov", 60 | "cov-report", 61 | ] 62 | 63 | [[tool.hatch.envs.all.matrix]] 64 | python = ["3.7", "3.8", "3.9", "3.10", "3.11"] 65 | 66 | [tool.hatch.envs.lint] 67 | detached = true 68 | dependencies = [ 69 | "black>=23.1.0", 70 | "mypy>=1.0.0", 71 | "ruff>=0.0.243", 72 | ] 73 | [tool.hatch.envs.lint.scripts] 74 | typing = "mypy --install-types --non-interactive {args:src/ghast tests}" 75 | style = [ 76 | "ruff {args:.}", 77 | "black --check --diff {args:.}", 78 | ] 79 | fmt = [ 80 | "black {args:.}", 81 | "ruff --fix {args:.}", 82 | "style", 83 | ] 84 | all = [ 85 | "style", 86 | "typing", 87 | ] 88 | 89 | [tool.black] 90 | target-version = ["py37"] 91 | line-length = 120 92 | skip-string-normalization = true 93 | 94 | [tool.ruff] 95 | target-version = "py37" 96 | line-length = 120 97 | select = [ 98 | "A", 99 | "ARG", 100 | "B", 101 | "C", 102 | "DTZ", 103 | "E", 104 | "EM", 105 | "F", 106 | "FBT", 107 | "I", 108 | "ICN", 109 | "ISC", 110 | "N", 111 | "PLC", 112 | "PLE", 113 | "PLR", 114 | "PLW", 115 | "Q", 116 | "RUF", 117 | "S", 118 | "T", 119 | "TID", 120 | "UP", 121 | "W", 122 | "YTT", 123 | ] 124 | ignore = [ 125 | # Allow non-abstract empty methods in abstract base classes 126 | "B027", 127 | # Allow boolean positional values in function calls, like `dict.get(... True)` 128 | "FBT003", 129 | # Ignore checks for possible passwords 130 | "S105", "S106", "S107", 131 | # Ignore complexity 132 | "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", 133 | ] 134 | unfixable = [ 135 | # Don't touch unused imports 136 | "F401", 137 | ] 138 | 139 | [tool.ruff.isort] 140 | known-first-party = ["ghast"] 141 | 142 | [tool.ruff.flake8-tidy-imports] 143 | ban-relative-imports = "all" 144 | 145 | [tool.ruff.per-file-ignores] 146 | # Tests can use magic values, assertions, and relative imports 147 | "tests/**/*" = ["PLR2004", "S101", "TID252"] 148 | 149 | [tool.coverage.run] 150 | source_pkgs = ["ghast", "tests"] 151 | branch = true 152 | parallel = true 153 | omit = [ 154 | "__about__.py", 155 | ] 156 | 157 | [tool.coverage.paths] 158 | ghast = ["src/ghast", "*/ghast/src/ghast"] 159 | tests = ["tests", "*/ghast/tests"] 160 | 161 | [tool.coverage.report] 162 | exclude_lines = [ 163 | "no cov", 164 | "if __name__ == .__main__.:", 165 | "if TYPE_CHECKING:", 166 | ] 167 | 168 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """main.py for actions-security-analyzer""" 2 | 3 | from argparse import ArgumentParser 4 | from pathlib import Path 5 | from os import sep 6 | import sys 7 | 8 | from yaml.resolver import Resolver 9 | from yaml import safe_load 10 | from analyzer.analyzer import Analyzer 11 | from colors import Colors 12 | 13 | FAILED = 1 14 | SUCCESS = 0 15 | 16 | 17 | # reference: https://stackoverflow.com/a/36470466 18 | def _rewrite_pyyaml_boolean_recognition_rules(): 19 | for char in "OoYyNn": 20 | if len(Resolver.yaml_implicit_resolvers[char]) == 1: 21 | del Resolver.yaml_implicit_resolvers[char] 22 | else: 23 | Resolver.yaml_implicit_resolvers[char] = [ 24 | x for x in Resolver.yaml_implicit_resolvers[char] if x[0] != "tag:yaml.org,2002:bool" 25 | ] 26 | 27 | 28 | def _parse_args(): 29 | parser = ArgumentParser() 30 | parser.add_argument("--file", "-f", type=str, help="path to GitHub Action .yaml|.yml file") 31 | parser.add_argument( 32 | "--dir", 33 | "-d", 34 | type=str, 35 | help="path to directory with GitHub Action .yaml|.yml files", 36 | ) 37 | parser.add_argument( 38 | "--list-checks", 39 | "-l", 40 | action="store_true", 41 | help="list all checks performed against provided GitHub Action", 42 | ) 43 | parser.add_argument("--verbose", "-v", action="store_true", help="increase tool verbosity") 44 | parser.add_argument( 45 | "--ignore-warnings", 46 | "-i", 47 | action="store_true", 48 | help="ignore checks labeled as warning", 49 | ) 50 | parser.add_argument( 51 | "--ignore-checks", 52 | "-k", 53 | nargs="+", 54 | metavar="CHECK", 55 | help="specify checks to ignore", 56 | ) 57 | parser.add_argument("--no-summary", "-n", action="store_true", help="don't show tool summary section") 58 | return parser.parse_args() 59 | 60 | 61 | def _main(): 62 | _rewrite_pyyaml_boolean_recognition_rules() 63 | args = _parse_args() 64 | 65 | file_ = args.file 66 | dir_ = args.dir 67 | list_checks = args.list_checks 68 | verbose = args.verbose 69 | ignore_warnings = args.ignore_warnings 70 | ignore_checks = args.ignore_checks 71 | no_summary = args.no_summary 72 | 73 | analyzer = Analyzer(ignore_checks=ignore_checks, ignore_warnings=ignore_warnings, verbose=verbose) 74 | 75 | errored = False 76 | failed_actions = [] 77 | try: 78 | if file_: 79 | file_ = Path(file_) 80 | if file_.exists(): 81 | print(f"FILE => {Colors.BOLD}{file_}{Colors.END}") 82 | with file_.open("r") as action_file: 83 | action_dict = safe_load(action_file) 84 | if not analyzer.run_checks(action=action_dict): 85 | failed_actions.append(file_) 86 | else: 87 | errored = True 88 | print(f"[{Colors.RED}ERROR{Colors.END}]: the file '{str(file_)}' does not exist...") 89 | print() 90 | elif dir_: 91 | dir_ = Path(dir_) 92 | if dir_.is_dir() and dir_.exists(): 93 | if verbose: 94 | print( 95 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} " 96 | f"Scanning {Colors.UNDERLINE}{dir_}{Colors.END} directory..." 97 | ) 98 | for action in dir_.iterdir(): 99 | print( 100 | f"FILE => {Colors.BOLD}{Colors.UNDERLINE}{str(action).rsplit(sep, maxsplit=1)[-1]}{Colors.END}" 101 | ) 102 | if action.is_file and action.suffix in (".yml", ".yaml"): 103 | with action.open("r") as action_file: 104 | action_dict = safe_load(action_file) 105 | if not analyzer.run_checks(action=action_dict): 106 | failed_actions.append(action) 107 | else: 108 | if verbose: 109 | print(f"{Colors.LIGHT_GRAY}INFO{Colors.END} {str(action)} passed all checks") 110 | print() 111 | else: 112 | errored = True 113 | print(f"[{Colors.RED}ERROR{Colors.END}] the directory '{str(dir_)}' does not exist...") 114 | elif list_checks: 115 | for i, check in enumerate(analyzer.get_checks(), 1): 116 | print(f"{i}. {check[1:]}") 117 | else: 118 | errored = True 119 | print(f"[{Colors.LIGHT_GRAY}INFO{Colors.END}] must provide `--file` or `--dir`") 120 | 121 | except Exception as exception: 122 | errored = True 123 | print(f"[{Colors.RED}ERROR{Colors.END}] {exception}") 124 | finally: 125 | if not no_summary: 126 | if not errored and not list_checks: 127 | if failed_actions: 128 | print( 129 | f"{Colors.PURPLE}{Colors.UNDERLINE}Summary{Colors.END}" 130 | "\nThe following Actions failed to pass one or more checks:" 131 | ) 132 | for action in failed_actions: 133 | print(f" \u2022 {Colors.BOLD}{action}{Colors.END}") 134 | sys.exit(FAILED) 135 | else: 136 | print(f"{Colors.PURPLE}Summary{Colors.END}: Passed all checks \U0001F44D") 137 | sys.exit(SUCCESS) 138 | if failed_actions: 139 | sys.exit(FAILED) 140 | 141 | 142 | if __name__ == "__main__": 143 | _main() 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghast 2 | 3 |

image

4 | 5 | Ghast (GitHub Actions Static Analysis Tool) is a tool to analyze the security posture of your GitHub Actions and its surrounding environment for common security vulnerabilities and/or missing security configuration. You can use Ghast as a stand-alone analysis tool via the Ghast CLI or by running Ghast as a native GitHub Action. 6 | 7 | The actions directory has some example GitHub Actions with vulnerable steps that you can use to test. 8 | 9 |

image

10 | 11 | 12 | ### Install the Ghast CLI 13 | 14 | > Make sure you have `$HOME/.local/bin` in your PATH 15 | 16 | #### Using Pip 17 | 18 | ``` 19 | pip install ghast-scanner 20 | ``` 21 | 22 | #### With Git/Python 23 | 24 | ``` 25 | git clone https://github.com/bin3xish477/ghast.git 26 | python3 -m pip install . 27 | ``` 28 | 29 | ### How to use the Ghast CLI 30 | 31 | ``` 32 | ghast -h # get help 33 | ghast --file action.yml # scan a specific workflow file 34 | ghast -d directory-with-actions/ --verbose # scan a directory in verbose mode 35 | ghast --file action.yml --ignore-warnings # scan a specific workflow file and ignore warnings 36 | ghast --list-checks # list all known checks 37 | ghast -i check_for_inline_script --no-summary # only run a specific check and don't show tool summary 38 | ``` 39 | 40 | #### See how the Ghast CLI works 41 | 42 | 43 | ### Use `ghast` in Your GitHub Workflows 44 | 45 | #### Default Workflow 46 | 47 | ```yaml 48 | name: 'RunGhast' 49 | on: 50 | push: 51 | jobs: 52 | RunGhast: 53 | runs-on: ubuntu-latest 54 | steps: 55 | - name: "Checkout repo" 56 | uses: actions/checkout@96f53100ba2a5449eb71d2e6604bbcd94b9449b5 # v3.5.3 57 | - name: "Run Ghast" 58 | uses: "bin3xish477/ghast@43c471b8e05599d67f618ecccfc8d7b9281bfd9b" 59 | ``` 60 | 61 | ### See Additional Workflow Examples 62 | [Additional Ghast Workflow Examples](EXAMPLES.md) 63 | 64 | ### Checks Performed by `ghast` 65 | 66 | 1. Name: `check_for_3p_actions_without_hash`, Level: `FAIL` 67 | 68 | - This check identifies any third party GitHub Actions in use that have been referenced via a version number such as `v1.1` instead of commit SHA haah. Using a hash can help mitigate supply chain threats in a scenario where a threat actor has compromised the source repository where the 3P action lives. 69 | 70 | 2. Name: `check_for_allow_unsecure_commands`, Level: `FAIL` 71 | 72 | - This check looks for the usage of environment variable called `ACTIONS_ALLOW_UNSECURE_COMMANDS` which allows for an Action to get access to dangerous commands (`get-env`, `add-path`) which can lead to code injection and credential thefts opportunities. 73 | 74 | 3. Name: `check_for_cache_action`, Level: `WARN` 75 | 76 | - This check finds any usage of GitHub's caching Action (`actions/cache`) which may result in sensitive information disclosure or cache poisoning. 77 | 78 | 4. Name: `check_for_dangerous_write_permissions`, Level: `FAIL` 79 | 80 | - This check looks for write permissions granted to potentially dangerous scopes such as the `contents` scope which may allow an adversary write code into the target repository if they're able to compromise the workflow. It's also looks for usage of the `write-all` which gives the action complete write access to all scopes. 81 | 82 | 5. Name: `check_for_inline_script`, Level: `WARN` 83 | 84 | - This check simply warns that you're using an inline script instead of GitHub Action. Inline scripts are susceptible to script injection attacks (another check covered by `ghast`). It is recommended to write an action and pass any required context values as inputs to that action which removes script injection vector because action input are properly treated as arguments and are not evaluated as part of a script. 85 | 86 | 6. Name: `check_for_pull_request_target`, Level: `FAIL` 87 | 88 | - This check looks for the usage of the dangerous event trigger `pull_request_target` which allows workflow executions to run in the context of the repository that defines the workflow, not the repository that the pull request originated from, potentially allowing a threat actor to gain access to a repositories sensitive secrets! 89 | 90 | 7. Name: `check_for_script_injection`, Level: `FAIL` 91 | 92 | - This check looks for the most commonly known security risk to GitHub Action - script injection. Script injection occurs when an action directly includes (using the `${{ ... }}` syntax) a GitHub Context variable(s) in an inline script that can be controlled by an untrusted actor, resulting in command execution in the interpreted shell. These user-controllable parameters should be passed into an inline script as environment variables. 93 | 94 | 8. Name: `check_for_self_hosted_runners`, Level: `WARN` 95 | 96 | - This checks attempts to identify the usage of self-hosted runners. Self-hosted runners are dangerous because if the Action is compromised it may allow a threat actor to gain access to on premise environment or establish persistence mechanisms on a server you own/rent. 97 | 98 | 9. Name: `check_for_aws_configure_credentials_non_oidc`, Level: `WARN` 99 | 100 | - This checks looks for the usage of AWS's `aws-actions/configure-aws-credentials` action and attempts to identify non-OIDC authentication parameters. Non-OIDC authentication types are less secure than OIDC because they require the creation of long-term credentials which can be compromised, however, OIDC tokens are short-lived and are usually scoped to only the permissions that are essential to a workflow and thus help reduce the attack surface. 101 | 102 | 10. Name: `check_for_create_or_approve_pull_request`, Level: `WARN` 103 | 104 | - This check looks for Action that have logic related to creating or improving pull requests. Creating or approving pull requests via automation poses a security risk if sufficient controls aren't in place to protect against malicious code being merged into a repository. 105 | 106 | 11. Name: `check_for_remote_script`, Level: `WARN` 107 | 108 | - This check looks for a URL in an inline script of a GitHub Action which usually signals the inclusion of a remote script which can be dangerous. 109 | 110 | 12. Name: `check_for_upload_download_artifact_action`, Level: `WARN` 111 | 112 | - This check is essential for identifying any usage of GitHub's upload/download artifact Action, as it can potentially expose your workflow to compromised files. For instance, an uploaded artifact might contain a compiled binary from a previous workflow, but this binary could be compromised due to the introduction of malicious dependencies during the compilation phase. Consequently, if this tainted binary is executed within another workflow, it could lead to significant security risks. To mitigate such risks, it is crucial for users to conduct integrity checks on artifacts before consumption. This check serves as a valuable reminder to reinforce this security practice. 113 | 114 | 13. Name: `check_for_non_github_managed_actions`, Level: `WARN` 115 | 116 | - This check looks for inclusion of non GitHub-managed actions and serves as a reminder to review the security posture of any third party actions you include in your workflow(s), especially if they are not developed and maintained by credible entities. 117 | 118 | #### Auxiliary Checks 119 | 120 | 1. Name: `check_for_missing_codeowners_file` - checks for missing [CODEOWNERS](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners) file. 121 | 2. Name: `check_for_missing_security_md_file` - checks for missing [SECURITY.md](https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository) file. 122 | 3. Name: `check_for_missing_gitignore_file` - check for missing [gitignore](https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files) file. 123 | 4. Name: `check_for_missing_dockerignore_file` - check for missing [dockerignore](https://docs.docker.com/engine/reference/builder/#dockerignore-file) file. 124 | 125 | ### References 126 | 127 | - [Security hardening for GitHub Actions](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions) 128 | -------------------------------------------------------------------------------- /analyzer/analyzer.py: -------------------------------------------------------------------------------- 1 | """analyzer.py contains all the INFOic related to analyzing GitHub Actions""" 2 | 3 | from re import search, DOTALL 4 | from pathlib import Path 5 | from colors import Colors 6 | 7 | import analyzer.regex 8 | 9 | 10 | class Analyzer: 11 | """Analyzer contains all the checks that will run 12 | against a specified GitHub Action parsed into a Python 13 | dictionary. 14 | """ 15 | 16 | def __init__( 17 | self, 18 | ignore_checks: list, 19 | ignore_warnings: bool = False, 20 | verbose: bool = False, 21 | ) -> None: 22 | self.ignore_warnings = ignore_warnings 23 | self.ignore_checks = ignore_checks or [] 24 | self.verbose = verbose 25 | self.checks = { 26 | "_check_for_3p_actions_without_hash": {"level": "FAIL"}, 27 | "_check_for_allow_unsecure_commands": {"level": "FAIL"}, 28 | "_check_for_cache_action": {"level": "WARN"}, 29 | "_check_for_upload_download_artifact_action": {"level": "WARN"}, 30 | "_check_for_dangerous_write_permissions": {"level": "FAIL"}, 31 | "_check_for_inline_script": {"level": "WARN"}, 32 | "_check_for_pull_request_target": {"level": "FAIL"}, 33 | "_check_for_script_injection": {"level": "FAIL"}, 34 | "_check_for_self_hosted_runners": {"level": "WARN"}, 35 | "_check_for_aws_configure_credentials_non_oidc": {"level": "WARN"}, 36 | "_check_for_create_or_approve_pull_request": {"level": "FAIL"}, 37 | "_check_for_remote_script": {"level": "WARN"}, 38 | "_check_for_non_github_managed_actions": {"level": "WARN"}, 39 | } 40 | self.auxiliary_checks = [ 41 | "_check_for_missing_codeowners_file", 42 | "_check_for_missing_security_md_file", 43 | "_check_for_missing_gitignore_file", 44 | "_check_for_missing_dockerignore_file", 45 | ] 46 | self.action = {} 47 | self.jobs = {} 48 | 49 | self._run_aux_checks() 50 | 51 | def _print_failed_check_msg(self, check: str, level: str): 52 | color = None 53 | if level == "FAIL": 54 | color = Colors.RED 55 | elif level == "WARN": 56 | color = Colors.LIGHT_GREEN 57 | print( 58 | f"{color}{level}{Colors.END} {Colors.YELLOW}{check[1:]}{Colors.END}", 59 | ) 60 | 61 | def _action_has_required_elements(self) -> bool: 62 | passed = True 63 | # NOTE: a check for "permissions" is not done here because it is not required 64 | if not all(key in self.action for key in ["name", "on", "jobs"]): 65 | passed = False 66 | for job in self.jobs.keys(): 67 | if "steps" not in self.jobs[job]: 68 | passed = False 69 | break 70 | return passed 71 | 72 | def _check_for_3p_actions_without_hash(self) -> bool: 73 | passed = True 74 | for job in self.jobs.keys(): 75 | for step in self.jobs[job]["steps"]: 76 | if "uses" in step: 77 | uses = step["uses"] 78 | if search(analyzer.regex.ACTION_WITH_VERSION, uses): 79 | if self.verbose: 80 | print( 81 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} step using action('{uses}') with version number instead of a SHA hash" 82 | ) 83 | if passed: 84 | passed = False 85 | return passed 86 | 87 | def _check_for_inline_script(self) -> bool: 88 | passed = True 89 | for job in self.jobs.keys(): 90 | steps = self.jobs[job]["steps"] 91 | for step in steps: 92 | if "run" in step: 93 | if self.verbose: 94 | # NOTE: name is not required according to GitHub Docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#name 95 | if 'name' in step: 96 | print( 97 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} found inline script in job('{job}').step('{step['name']}')" 98 | ) 99 | else: 100 | print(f"{Colors.LIGHT_GRAY}INFO{Colors.END} found step with inline script in job('{job}')") 101 | passed = False 102 | return passed 103 | 104 | def _check_for_script_injection(self) -> bool: 105 | passed = True 106 | for job in self.jobs.keys(): 107 | steps = self.jobs[job]["steps"] 108 | for step in steps: 109 | if "run" in step: 110 | script = step["run"] 111 | variable = search(analyzer.regex.DANGEROUS_GITHUB_CONTEXT_VARIABLE, script) 112 | if variable: 113 | if self.verbose: 114 | print( 115 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} dangerous variable('{variable.group()}') in inline script" 116 | ) 117 | passed = False 118 | return passed 119 | 120 | def _check_for_allow_unsecure_commands(self) -> bool: 121 | passed = True 122 | for job in self.jobs.keys(): 123 | steps = self.jobs[job]["steps"] 124 | for step in steps: 125 | if "env" in step and "ACTIONS_ALLOW_UNSECURE_COMMANDS" in step["env"]: 126 | if self.verbose: 127 | if "name" in step: 128 | print( 129 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} step('{step['name']}') contains dangerous ACTIONS_ALLOW_UNSECURE_COMMANDS environment variable" 130 | ) 131 | else: 132 | print( 133 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} step contains dangerous ACTIONS_ALLOW_UNSECURE_COMMANDS environment variable" 134 | ) 135 | if passed: 136 | passed = False 137 | return passed 138 | 139 | def _check_for_pull_request_target(self) -> bool: 140 | passed = True 141 | event_triggers = self.action["on"] 142 | if type(event_triggers) in (list, dict): 143 | if "pull_request_target" in event_triggers: 144 | passed = False 145 | elif isinstance(event_triggers, str): 146 | if event_triggers == "pull_request_target": 147 | passed = False 148 | return passed 149 | 150 | def _check_for_remote_script(self) -> bool: 151 | passed = True 152 | for job in self.jobs.keys(): 153 | steps = self.jobs[job]["steps"] 154 | for step in steps: 155 | if "run" in step: 156 | script = step["run"] 157 | variable = search(analyzer.regex.POTENTIAL_REMOTE_SCRIPT, script) 158 | if variable: 159 | if self.verbose: 160 | print( 161 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} remote script('{variable.group()}') found in inline script" 162 | ) 163 | passed = False 164 | return passed 165 | 166 | def _check_for_cache_action(self) -> bool: 167 | passed = True 168 | for job in self.jobs.keys(): 169 | steps = self.jobs[job]["steps"] 170 | for step in steps: 171 | if "uses" in step: 172 | action = search(analyzer.regex.CACHE_ACTION, step["uses"]) 173 | if action: 174 | if self.verbose: 175 | print( 176 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} job('{job}') is using cache action('{action.group()}')" 177 | ) 178 | passed = False 179 | return passed 180 | 181 | def _check_for_upload_download_artifact_action(self) -> bool: 182 | passed = True 183 | for job in self.jobs.keys(): 184 | steps = self.jobs[job]["steps"] 185 | for step in steps: 186 | if "uses" in step: 187 | action = search(analyzer.regex.UPLOAD_DOWNLOAD_ARTIFACTS_ACTION, step["uses"]) 188 | if action: 189 | if self.verbose: 190 | print( 191 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} job('{job}') is using upload|download artifact action('{action.group()}')" 192 | ) 193 | passed = False 194 | return passed 195 | 196 | def _check_for_dangerous_write_permissions(self) -> bool: 197 | passed = True 198 | dangerous_scopes = ["contents", "deployments", "packages", "actions"] 199 | 200 | if "permissions" in self.action: 201 | permissions = self.action["permissions"] 202 | # check for write to all scopes 203 | if permissions == "write-all": 204 | passed = False 205 | return passed 206 | for scope in dangerous_scopes: 207 | if scope in permissions and permissions[scope] == "write": 208 | passed = False 209 | return passed 210 | 211 | for job in self.jobs.keys(): 212 | if "permissions" in self.jobs[job]: 213 | permissions = self.jobs[job]["permissions"] 214 | if permissions == "write-all": 215 | passed = False 216 | if self.verbose: 217 | print(f"{Colors.LIGHT_GRAY}INFO{Colors.END} job('{job}') contains 'write-all' permissions") 218 | for scope in dangerous_scopes: 219 | if scope in permissions and permissions[scope] == "write": 220 | if self.verbose: 221 | print( 222 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} write permissions set for dangerous scope('{scope}')" 223 | ) 224 | passed = False 225 | return passed 226 | return passed 227 | 228 | def _check_for_self_hosted_runners(self) -> bool: 229 | passed = True 230 | # NOTE: ***** default runners as of 7/17/23 ***** 231 | default_runners = [ 232 | "windows-latest", 233 | "windows-2022", 234 | "windows-2019", 235 | "ubuntu-latest", 236 | "ubuntu-22.04", 237 | "ubuntu-20.04", 238 | "macos-13", 239 | "macos-13-xl", 240 | "macos-latest", 241 | "macos-12", 242 | "macos-latest-xl", 243 | "macos-12-xl", 244 | "macos-11", 245 | ] 246 | for job in self.jobs.keys(): 247 | # TODO: Add verbosity to print which self-hosted runners were found. 248 | if "strategy" in self.jobs[job] and "matrix" in self.jobs[job]["strategy"]: 249 | matrix = self.jobs[job]["strategy"]["matrix"] 250 | if "runner" in matrix: 251 | if isinstance(matrix["runner"], list): 252 | if any(runner not in default_runners for runner in matrix["runner"]): 253 | passed = False 254 | break 255 | if "runs-on" in self.jobs[job]: 256 | runs_on = self.jobs[job]["runs-on"] 257 | type_of_runs_on = type(runs_on) 258 | if type_of_runs_on == list: 259 | if any(runner not in default_runners for runner in runs_on): 260 | passed = False 261 | return passed 262 | elif type_of_runs_on == dict: 263 | if "group" in runs_on: 264 | if runs_on["group"] not in default_runners: 265 | passed = False 266 | break 267 | elif type_of_runs_on == str: 268 | if runs_on not in default_runners: 269 | passed = False 270 | break 271 | return passed 272 | 273 | def _check_for_aws_configure_credentials_non_oidc(self) -> bool: 274 | passed = True 275 | # NOTE: if these are specifed in the configure-aws-credentials action 276 | # then the action will not use GitHub's OIDC provider 277 | # see this: https://github.com/aws-actions/configure-aws-credentials#assuming-a-role 278 | non_oidc_inputs = [ 279 | "aws-access-key-id", 280 | "web-identity-token-file", 281 | ] 282 | for job in self.jobs.keys(): 283 | steps = self.jobs[job]["steps"] 284 | for step in steps: 285 | if "uses" in step: 286 | action = search(analyzer.regex.CONFIGURE_AWS_CREDS_ACTION, step["uses"]) 287 | if action: 288 | if any(input in non_oidc_inputs for input in step["with"]): 289 | if self.verbose: 290 | if "name" in step: 291 | print( 292 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} found step('{step['name']}') not using OIDC with `configure-aws-credentials`" 293 | ) 294 | else: 295 | print( 296 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} found step not using OIDC with `configure-aws-credentials`" 297 | ) 298 | if passed: 299 | passed = False 300 | return passed 301 | 302 | def _check_for_create_or_approve_pull_request(self) -> bool: 303 | passed = True 304 | 305 | def __print_msg(job: str, step: dict): 306 | if self.verbose: 307 | if "name" in step: 308 | print( 309 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} job('{job}') has a step('{step['name']}') that creates or approves a pull request" 310 | ) 311 | else: 312 | print( 313 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} job('{job}') has a step that creates or approves a pull request" 314 | ) 315 | 316 | for job in self.jobs: 317 | steps = self.jobs[job]["steps"] 318 | for step in steps: 319 | if "run" in step: 320 | script = step["run"] 321 | match = search(analyzer.regex.GH_CLI_PR_CREATE_APPROVE, script, flags=DOTALL) or search( 322 | analyzer.regex.CURL_CREATE_APPROVE_PR, script, flags=DOTALL 323 | ) 324 | if match: 325 | __print_msg(job, step) 326 | passed = False 327 | if "uses" in step: 328 | action = search(analyzer.regex.GITHUB_SCRIPT_ACTION, step["uses"]) 329 | if action: 330 | if "script" in step["with"]: 331 | script = step["with"]["script"] 332 | match = search(analyzer.regex.GITHUB_SCRIPT_CREATE_APPROVE_PR, script, flags=DOTALL) 333 | if match: 334 | __print_msg(job, step) 335 | passed = False 336 | return passed 337 | 338 | def _check_for_non_github_managed_actions(self) -> bool: 339 | passed = True 340 | for job in self.jobs: 341 | for step in self.jobs[job]["steps"]: 342 | if "uses" in step: 343 | action = step["uses"].strip() 344 | if not search(analyzer.regex.GITHUB_MANAGED_ACTION, action): 345 | if self.verbose: 346 | print( 347 | f"{Colors.LIGHT_GRAY}INFO{Colors.END} using non GitHub-managed action('{action}') - make sure its safe to use!" 348 | ) 349 | passed = False 350 | return passed 351 | 352 | # ================================================================== 353 | # ======================== Auxiliary Checks ======================== 354 | # ================================================================== 355 | 356 | def _check_for_missing_codeowners_file(self) -> None: 357 | if not Path(".github/workflows/CODEOWNERS").exists(): 358 | print( 359 | f"{Colors.LIGHT_BLUE}AUXI{Colors.END} missing CODEOWNERS file" 360 | " which can provide additional protections for your workflow files." 361 | ) 362 | else: 363 | if self.verbose: 364 | print(f"{Colors.LIGHT_BLUE}AUXI{Colors.END} found CODEOWNERS file!") 365 | 366 | def _check_for_missing_security_md_file(self) -> None: 367 | security_md = 'SECURITY.md' 368 | if not Path(security_md).exists() or not Path(security_md.lower()).exists(): 369 | print( 370 | f"{Colors.LIGHT_BLUE}AUXI{Colors.END} missing SECURITY.md file" 371 | " which is crucial for researchers looking to report a finding." 372 | ) 373 | else: 374 | if self.verbose: 375 | print(f"{Colors.LIGHT_BLUE}AUXI{Colors.END} found SECURITY.md file!") 376 | 377 | def _check_for_missing_gitignore_file(self) -> None: 378 | if not Path('.gitignore').exists(): 379 | print( 380 | f"{Colors.LIGHT_BLUE}AUXI{Colors.END} missing .gitignore file - make sure you aren't commiting any sensitive folders/files." 381 | ) 382 | else: 383 | if self.verbose: 384 | print(f"{Colors.LIGHT_BLUE}AUXI{Colors.END} found .gitignore file!") 385 | 386 | def _check_for_missing_dockerignore_file(self) -> None: 387 | using_docker = False 388 | for f in Path(".").iterdir(): 389 | if f.is_file(): 390 | if f.suffix == ".dockerfile": 391 | using_docker = True 392 | elif f == "Dockerfile": 393 | using_docker = True 394 | if using_docker: 395 | if not Path(".dockerignore").exists(): 396 | print( 397 | f"{Colors.LIGHT_BLUE}AUXI{Colors.END} missing .dockerignore file - make sure you aren't commiting any sensitive folders/files into your containerized apps." 398 | ) 399 | else: 400 | if self.verbose: 401 | print(f"{Colors.LIGHT_BLUE}AUXI{Colors.END} found .dockerignore file!") 402 | 403 | def _run_aux_checks(self) -> None: 404 | """Runs auxiliary checks which are checks for security-related 405 | configurations/properties/mechanisms that contribute to more secure 406 | GitHub Actions workflows. 407 | """ 408 | # TODO: 409 | for check in self.auxiliary_checks: 410 | Analyzer.__dict__[check](self) 411 | 412 | def get_checks(self) -> list: 413 | """Returns list containing available checks. 414 | 415 | Returns: 416 | list: list() of available checks. 417 | """ 418 | return [*self.checks.keys()] 419 | 420 | def run_checks(self, action: dict) -> bool: 421 | """Run checks against a parsed Action YAML file as dict. 422 | 423 | Args: 424 | action (dict): the dict containing the parsed Action YAML file data. 425 | 426 | Returns: 427 | bool: True, if all checks passed, False, if any check fails. 428 | """ 429 | self.action = action 430 | self.jobs = self.action["jobs"] 431 | 432 | passed_all_checks = True 433 | fail_checks = [] 434 | if self._action_has_required_elements(): 435 | for check in self.checks: 436 | if self.ignore_warnings: 437 | if self.checks[check]["level"] == "WARN": 438 | continue 439 | if check[1:] in self.ignore_checks: 440 | continue 441 | if not Analyzer.__dict__[check](self): 442 | fail_checks.append(check) 443 | if passed_all_checks: 444 | passed_all_checks = False 445 | for check in fail_checks: 446 | self._print_failed_check_msg(check, self.checks[check]["level"]) 447 | return passed_all_checks 448 | --------------------------------------------------------------------------------