├── .github ├── FUNDING.yml └── workflows │ ├── actionlint.yaml │ ├── autofix.yaml │ ├── check-commit-signing.yaml │ ├── release.yaml │ ├── test.yaml │ └── workflow_call_test.yaml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── _typos.toml ├── aqua ├── aqua-checksums.json ├── aqua.yaml └── imports │ ├── cmdx.yaml │ ├── cosign.yaml │ ├── ghalint.yaml │ ├── golangci-lint.yaml │ ├── goreleaser.yaml │ ├── reviewdog.yaml │ └── typos.yaml ├── cmd ├── gen-jsonschema │ └── main.go └── ghalint │ └── main.go ├── cmdx.yaml ├── docs ├── codes │ ├── 001.md │ └── 002.md ├── install.md ├── policies │ ├── 001.md │ ├── 002.md │ ├── 003.md │ ├── 004.md │ ├── 005.md │ ├── 006.md │ ├── 007.md │ ├── 008.md │ ├── 009.md │ ├── 010.md │ ├── 011.md │ ├── 012.md │ └── 013.md └── usage.md ├── go.mod ├── go.sum ├── json-schema └── ghalint.json ├── pkg ├── cli │ ├── app.go │ ├── run.go │ └── run_action.go ├── config │ ├── config.go │ └── config_test.go ├── controller │ ├── act │ │ ├── controller.go │ │ ├── error.go │ │ └── run.go │ ├── controller.go │ ├── error.go │ └── run.go ├── log │ ├── log.go │ ├── log_test.go │ └── log_windows.go ├── policy │ ├── action_ref_should_be_full_length_commit_sha_policy.go │ ├── action_ref_should_be_full_length_commit_sha_policy_test.go │ ├── action_shell_is_required.go │ ├── action_shell_is_required_test.go │ ├── checkout_persist_credentials_should_be_false.go │ ├── checkout_persist_credentials_should_be_false_test.go │ ├── context.go │ ├── deny_inherit_secrets.go │ ├── deny_inherit_secrets_test.go │ ├── deny_job_container_latest_image.go │ ├── deny_job_container_latest_image_test.go │ ├── deny_read_all_policy.go │ ├── deny_read_all_policy_test.go │ ├── deny_write_all_policy.go │ ├── deny_write_all_policy_test.go │ ├── error.go │ ├── github_app_should_limit_permissions.go │ ├── github_app_should_limit_permissions_test.go │ ├── github_app_should_limit_repositories.go │ ├── github_app_should_limit_repositories_test.go │ ├── job_permissions_policy.go │ ├── job_permissions_policy_test.go │ ├── job_secrets_policy.go │ ├── job_secrets_policy_test.go │ ├── job_timeout_minutes_is_required.go │ ├── job_timeout_minutes_is_required_test.go │ ├── workflow_secrets_policy.go │ └── workflow_secrets_policy_test.go └── workflow │ ├── container.go │ ├── container_test.go │ ├── job_secrets.go │ ├── job_secrets_test.go │ ├── list_workflows.go │ ├── permissions.go │ ├── permissions_test.go │ ├── read_action.go │ ├── read_workflow.go │ └── workflow.go ├── renovate.json5 ├── scripts ├── coverage.sh └── generate-usage.sh ├── test-action.yaml └── test-workflow.yaml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository 2 | github: 3 | - suzuki-shunsuke 4 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: actionlint 3 | on: pull_request 4 | permissions: {} 5 | jobs: 6 | actionlint: 7 | runs-on: ubuntu-24.04 8 | if: failure() 9 | timeout-minutes: 10 10 | permissions: {} 11 | needs: 12 | - main 13 | steps: 14 | - run: exit 1 15 | main: 16 | uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@dbe6151b36d408b24ca5c41a34291b2b6d1bff76 # v2.0.1 17 | permissions: 18 | pull-requests: write 19 | contents: read 20 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: autofix.ci 3 | on: pull_request 4 | permissions: {} 5 | jobs: 6 | autofix: 7 | runs-on: ubuntu-24.04 8 | permissions: {} 9 | timeout-minutes: 15 10 | steps: 11 | - uses: suzuki-shunsuke/go-autofix-action@0bb6ca06b2f0d2d23c200bbbaa650897824a6cb9 # v0.1.7 12 | with: 13 | aqua_version: v2.51.2 14 | -------------------------------------------------------------------------------- /.github/workflows/check-commit-signing.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Check if all commits are signed 3 | on: 4 | pull_request_target: 5 | branches: [main] 6 | concurrency: 7 | group: ${{ github.workflow }}--${{ github.head_ref }} # github.ref is unavailable in case of pull_request_target 8 | cancel-in-progress: true 9 | jobs: 10 | check-commit-signing: 11 | uses: suzuki-shunsuke/check-commit-signing-workflow/.github/workflows/check.yaml@547eee345f56310a656f271ec5eaa900af46b0fb # v0.1.0 12 | permissions: 13 | contents: read 14 | pull-requests: write 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | on: 4 | push: 5 | tags: [v*] 6 | jobs: 7 | release: 8 | uses: suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@4602cd60ba10f19df17a074d76c518a9b8b979bb # v4.0.1 9 | with: 10 | aqua_version: v2.51.2 11 | go-version-file: go.mod 12 | permissions: 13 | contents: write 14 | id-token: write 15 | actions: read 16 | attestations: write 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test 3 | on: pull_request 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | cancel-in-progress: true 7 | permissions: {} 8 | jobs: 9 | test: 10 | uses: ./.github/workflows/workflow_call_test.yaml 11 | permissions: 12 | pull-requests: write 13 | contents: read 14 | status-check: 15 | runs-on: ubuntu-24.04 16 | if: failure() 17 | timeout-minutes: 10 18 | permissions: {} 19 | needs: 20 | - test 21 | steps: 22 | - run: exit 1 23 | -------------------------------------------------------------------------------- /.github/workflows/workflow_call_test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test (workflow_call) 3 | on: workflow_call 4 | env: 5 | AQUA_LOG_COLOR: always 6 | GHALINT_LOG_COLOR: always 7 | permissions: {} 8 | jobs: 9 | path-filter: 10 | # Get changed files to filter jobs 11 | outputs: 12 | renovate-config-validator: ${{steps.changes.outputs.renovate-config-validator}} 13 | runs-on: ubuntu-latest 14 | permissions: {} 15 | timeout-minutes: 10 16 | steps: 17 | - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 18 | id: changes 19 | with: 20 | filters: | 21 | renovate-config-validator: 22 | - renovate.json5 23 | 24 | renovate-config-validator: 25 | # Validate Renovate Configuration by renovate-config-validator. 26 | uses: suzuki-shunsuke/renovate-config-validator-workflow/.github/workflows/validate.yaml@e8effbd185cbe3874cddef63f48b8bdcfc9ada55 # v0.2.4 27 | needs: path-filter 28 | if: needs.path-filter.outputs.renovate-config-validator == 'true' 29 | permissions: 30 | contents: read 31 | 32 | test: 33 | uses: suzuki-shunsuke/go-test-workflow/.github/workflows/test.yaml@839af0761f35861b11a48615ce6476b98882a3e5 # v1.3.1 34 | with: 35 | aqua_version: v2.51.2 36 | go-version-file: go.mod 37 | permissions: 38 | pull-requests: write 39 | contents: read 40 | 41 | ghalint: 42 | runs-on: ubuntu-latest 43 | permissions: {} 44 | timeout-minutes: 20 45 | steps: 46 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 47 | with: 48 | persist-credentials: false 49 | - run: go run ./cmd/ghalint run 50 | 51 | typos: 52 | timeout-minutes: 15 53 | runs-on: ubuntu-latest 54 | permissions: {} 55 | steps: 56 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 57 | with: 58 | persist-credentials: false 59 | - uses: aquaproj/aqua-installer@9ebf656952a20c45a5d66606f083ff34f58b8ce0 # v4.0.0 60 | with: 61 | aqua_version: v2.51.2 62 | env: 63 | AQUA_GITHUB_TOKEN: ${{github.token}} 64 | - run: typos 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .coverage 3 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: all 4 | disable: 5 | - depguard 6 | - err113 7 | - exhaustruct 8 | - godot 9 | - ireturn 10 | - lll 11 | - musttag 12 | - nlreturn 13 | - tagalign 14 | - tagliatelle 15 | - varnamelen 16 | - wsl 17 | exclusions: 18 | generated: lax 19 | presets: 20 | - comments 21 | - common-false-positives 22 | - legacy 23 | - std-error-handling 24 | paths: 25 | - third_party$ 26 | - builtin$ 27 | - examples$ 28 | formatters: 29 | enable: 30 | - gci 31 | - gofmt 32 | - gofumpt 33 | - goimports 34 | exclusions: 35 | generated: lax 36 | paths: 37 | - third_party$ 38 | - builtin$ 39 | - examples$ 40 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: ghalint 3 | 4 | archives: 5 | - format_overrides: 6 | - goos: windows 7 | formats: [zip] 8 | 9 | env: 10 | - GO111MODULE=on 11 | 12 | before: 13 | hooks: 14 | - go mod tidy 15 | 16 | builds: 17 | - main: ./cmd/ghalint 18 | binary: ghalint 19 | env: 20 | - CGO_ENABLED=0 21 | goos: 22 | - windows 23 | - darwin 24 | - linux 25 | goarch: 26 | - amd64 27 | - arm64 28 | 29 | signs: 30 | - cmd: cosign 31 | artifacts: checksum 32 | signature: ${artifact}.sig 33 | certificate: ${artifact}.pem 34 | output: true 35 | args: 36 | - sign-blob 37 | - "-y" 38 | - --output-signature 39 | - ${signature} 40 | - --output-certificate 41 | - ${certificate} 42 | - --oidc-provider 43 | - github 44 | - ${artifact} 45 | 46 | release: 47 | prerelease: true # we update release note manually before releasing 48 | header: | 49 | [Pull Requests](https://github.com/suzuki-shunsuke/ghalint/pulls?q=is%3Apr+milestone%3A{{.Tag}}) | [Issues](https://github.com/suzuki-shunsuke/ghalint/issues?q=is%3Aissue+milestone%3A{{.Tag}}) | https://github.com/suzuki-shunsuke/ghalint/compare/{{.PreviousTag}}...{{.Tag}} 50 | 51 | brews: 52 | - 53 | # NOTE: make sure the url_template, the token and given repo (github or gitlab) owner and name are from the 54 | # same kind. We will probably unify this in the next major version like it is done with scoop. 55 | 56 | # GitHub/GitLab repository to push the formula to 57 | repository: 58 | owner: suzuki-shunsuke 59 | name: homebrew-ghalint 60 | # The project name and current git tag are used in the format string. 61 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 62 | # Your app's homepage. 63 | # Default is empty. 64 | homepage: https://github.com/suzuki-shunsuke/ghalint 65 | 66 | # Template of your app's description. 67 | # Default is empty. 68 | description: GitHub Actions linter 69 | license: MIT 70 | 71 | # Setting this will prevent goreleaser to actually try to commit the updated 72 | # formula - instead, the formula file will be stored on the dist folder only, 73 | # leaving the responsibility of publishing it to the user. 74 | # If set to auto, the release will not be uploaded to the homebrew tap 75 | # in case there is an indicator for prerelease in the tag e.g. v1.0.0-rc1 76 | # Default is false. 77 | skip_upload: true 78 | 79 | # So you can `brew test` your formula. 80 | # Default is empty. 81 | test: | 82 | system "#{bin}/ghalint --version" 83 | 84 | scoops: 85 | - description: GitHub Actions linter for security best practices. 86 | license: MIT 87 | skip_upload: true 88 | repository: 89 | owner: suzuki-shunsuke 90 | name: scoop-bucket 91 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Please read the following document. 4 | 5 | - https://github.com/suzuki-shunsuke/oss-contribution-guide 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Shunsuke Suzuki 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghalint 2 | 3 | [Install](docs/install.md) | [Policies](#policies) | [How to use](#how-to-use) | [Configuration](#configuration) 4 | 5 | GitHub Actions linter for security best practices. 6 | 7 | ```console 8 | $ ghalint run 9 | ERRO[0000] read a workflow file error="parse a workflow file as YAML: yaml: line 10: could not find expected ':'" program=ghalint version= workflow_file_path=.github/workflows/release.yaml 10 | ERRO[0000] github.token should not be set to workflow's env env_name=GITHUB_TOKEN policy_name=workflow_secrets program=ghalint version= workflow_file_path=.github/workflows/test.yaml 11 | ERRO[0000] secret should not be set to workflow's env env_name=DATADOG_API_KEY policy_name=workflow_secrets program=ghalint version= workflow_file_path=.github/workflows/test.yaml 12 | ``` 13 | 14 | ghalint is a command line tool to check GitHub Actions Workflows and action.yaml for security policy compliance. 15 | 16 | ## :bulb: We've ported ghalint to lintnet module 17 | 18 | - https://lintnet.github.io/ 19 | - https://github.com/lintnet-modules/ghalint 20 | 21 | lintnet is a general purpose linter powered by Jsonnet. 22 | We've ported ghalint to [the lintnet module](https://github.com/lintnet-modules/ghalint), so you can migrate ghalint to lintnet! 23 | 24 | ## Policies 25 | 26 | ### 1. Workflow Policies 27 | 28 | 1. [job_permissions](docs/policies/001.md): All jobs should have `permissions` 29 | 1. [deny_read_all_permission](docs/policies/002.md): `read-all` permission should not be used 30 | 1. [deny_write_all_permission](docs/policies/003.md): `write-all` permission should not be used 31 | 1. [deny_inherit_secrets](docs/policies/004.md): `secrets: inherit` should not be used 32 | 1. [workflow_secrets](docs/policies/005.md): Workflow should not set secrets to environment variables 33 | 1. [job_secrets](docs/policies/006.md): Job should not set secrets to environment variables 34 | 1. [deny_job_container_latest_image](docs/policies/007.md): Job's container image tag should not be `latest` 35 | 1. [action_ref_should_be_full_length_commit_sha](docs/policies/008.md): action's ref should be full length commit SHA 36 | 1. [github_app_should_limit_repositories](docs/policies/009.md): GitHub Actions issuing GitHub Access tokens from GitHub Apps should limit repositories 37 | 1. [github_app_should_limit_permissions](docs/policies/010.md): GitHub Actions issuing GitHub Access tokens from GitHub Apps should limit permissions 38 | 1. [job_timeout_minutes_is_required](docs/policies/012.md): All jobs should set [timeout-minutes](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes) 39 | 1. [checkout_persist_credentials_should_be_false](docs/policies/013.md): [actions/checkout](https://github.com/actions/checkout)'s input `persist-credentials` should be `false` 40 | 41 | ### 2. Action Policies 42 | 43 | 1. [action_ref_should_be_full_length_commit_sha](docs/policies/008.md): action's ref should be full length commit SHA 44 | 1. [github_app_should_limit_repositories](docs/policies/009.md): GitHub Actions issuing GitHub Access tokens from GitHub Apps should limit repositories 45 | 1. [github_app_should_limit_permissions](docs/policies/010.md): GitHub Actions issuing GitHub Access tokens from GitHub Apps should limit permissions 46 | 1. [action_shell_is_required](docs/policies/011.md): `shell` is required if `run` is set 47 | 1. [checkout_persist_credentials_should_be_false](docs/policies/013.md): [actions/checkout](https://github.com/actions/checkout)'s input `persist-credentials` should be `false` 48 | 49 | ## How to use 50 | 51 | ### 1. Validate workflows 52 | 53 | Run the command `ghalint run` on the repository root directory. 54 | 55 | ```sh 56 | ghalint run 57 | ``` 58 | 59 | Then ghalint validates workflow files `^\.github/workflows/.*\.ya?ml$`. 60 | 61 | ### 2. Validate action.yaml 62 | 63 | Run the command `ghalint run-action`. 64 | 65 | ```sh 66 | ghalint run-action 67 | ``` 68 | 69 | The alias `act` is available. 70 | 71 | ```sh 72 | ghalint act 73 | ``` 74 | 75 | Then ghalint validates action files `^([^/]+/){0,3}action\.ya?ml$` on the current directory. 76 | You can also specify file paths. 77 | 78 | ```sh 79 | ghalint act foo/action.yaml bar/action.yml 80 | ``` 81 | 82 | ## Configuration file 83 | 84 | Configuration file path: `^(\.|\.github/)?ghalint\.ya?ml$` 85 | 86 | You can specify the configuration file with the command line option `-config (-c)` or the environment variable `GHALINT_CONFIG`. 87 | 88 | ```sh 89 | ghalint -c foo.yaml run 90 | ``` 91 | 92 | ### JSON Schema 93 | 94 | - [ghalint.json](json-schema/ghalint.json) 95 | - https://raw.githubusercontent.com/suzuki-shunsuke/ghalint/refs/heads/main/json-schema/ghalint.json 96 | 97 | If you look for a CLI tool to validate configuration with JSON Schema, [ajv-cli](https://ajv.js.org/packages/ajv-cli.html) is useful. 98 | 99 | ```sh 100 | ajv --spec=draft2020 -s json-schema/ghalint.json -d ghalint.yaml 101 | ``` 102 | 103 | #### Input Complementation by YAML Language Server 104 | 105 | [Please see the comment too.](https://github.com/szksh-lab/.github/issues/67#issuecomment-2564960491) 106 | 107 | Version: `main` 108 | 109 | ```yaml 110 | # yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/ghalint/main/json-schema/ghalint.json 111 | ``` 112 | 113 | Or pinning version: 114 | 115 | ```yaml 116 | # yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/ghalint/v1.2.1/json-schema/ghalint.json 117 | ``` 118 | 119 | ### Disable policies 120 | 121 | You can disable the following policies. 122 | 123 | - [deny_inherit_secrets](docs/policies/004.md) 124 | - [job_secrets](docs/policies/006.md) 125 | - [action_ref_should_be_full_length_commit_sha](docs/policies/008.md) 126 | - [github_app_should_limit_repositories](docs/policies/009.md) 127 | 128 | e.g. 129 | 130 | ```yaml 131 | excludes: 132 | - policy_name: deny_inherit_secrets 133 | workflow_file_path: .github/workflows/actionlint.yaml 134 | job_name: actionlint 135 | - policy_name: job_secrets 136 | workflow_file_path: .github/workflows/actionlint.yaml 137 | job_name: actionlint 138 | - policy_name: action_ref_should_be_full_length_commit_sha 139 | action_name: slsa-framework/slsa-github-generator 140 | - policy_name: github_app_should_limit_repositories 141 | workflow_file_path: .github/workflows/test.yaml 142 | job_name: test 143 | step_id: create_token 144 | ``` 145 | 146 | ## Environment variables 147 | 148 | - `GHALINT_CONFIG`: Configuration file path 149 | - `GHALINT_LOG_LEVEL`: Log level One of `panic`, `fatal`, `error`, `warn`, `warning`, `info` (default), `debug`, `trace` 150 | - `GHALINT_LOG_COLOR`: Configure log color. One of `auto` (default), `always`, and `never`. 151 | 152 | 💡 If you want to enable log color in GitHub Actions, please try `GHALINT_LOG_COLOR=always` 153 | 154 | ```yaml 155 | env: 156 | GHALINT_LOG_COLOR: always 157 | ``` 158 | 159 | AS IS 160 | 161 | image 162 | 163 | TO BE 164 | 165 | image 166 | 167 | ## How does it works? 168 | 169 | ghalint reads GitHub Actions Workflows `^\.github/workflows/.*\.ya?ml$` and validates them. 170 | If there are violatation ghalint outputs error logs and fails. 171 | If there is no violation ghalint succeeds. 172 | 173 | ## LICENSE 174 | 175 | [MIT](LICENSE) 176 | -------------------------------------------------------------------------------- /_typos.toml: -------------------------------------------------------------------------------- 1 | [default.extend-words] 2 | ERRO = "ERRO" 3 | intoto = "intoto" 4 | -------------------------------------------------------------------------------- /aqua/aqua-checksums.json: -------------------------------------------------------------------------------- 1 | { 2 | "checksums": [ 3 | { 4 | "id": "github_release/github.com/crate-ci/typos/v1.32.0/typos-v1.32.0-aarch64-apple-darwin.tar.gz", 5 | "checksum": "9DA6598CC38A800273BB5D3C7866AD19995D65D69BD399D8B49A97F770C5F0DB", 6 | "algorithm": "sha256" 7 | }, 8 | { 9 | "id": "github_release/github.com/crate-ci/typos/v1.32.0/typos-v1.32.0-aarch64-unknown-linux-musl.tar.gz", 10 | "checksum": "53404115FE58E196506F0A5DCF7D9060CB462429458FAEE0AB3F6DD7EA461C37", 11 | "algorithm": "sha256" 12 | }, 13 | { 14 | "id": "github_release/github.com/crate-ci/typos/v1.32.0/typos-v1.32.0-x86_64-apple-darwin.tar.gz", 15 | "checksum": "8E70BCA52B726969DF784D5896D45CA4A39E30DF3259FFA283BD7F53F25E5DB4", 16 | "algorithm": "sha256" 17 | }, 18 | { 19 | "id": "github_release/github.com/crate-ci/typos/v1.32.0/typos-v1.32.0-x86_64-pc-windows-msvc.zip", 20 | "checksum": "18DD1D26DBD674EB467AB2BC1AF26D3EE2210F8620297F38949A364BBAD47541", 21 | "algorithm": "sha256" 22 | }, 23 | { 24 | "id": "github_release/github.com/crate-ci/typos/v1.32.0/typos-v1.32.0-x86_64-unknown-linux-musl.tar.gz", 25 | "checksum": "4C8E7E360FC855A6F9B721C2EAB22966A0F33AA0F284772B136EA8847F136864", 26 | "algorithm": "sha256" 27 | }, 28 | { 29 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-darwin-amd64.tar.gz", 30 | "checksum": "E091107C4CA7E283902343BA3A09D14FB56B86E071EFFD461CE9D67193EF580E", 31 | "algorithm": "sha256" 32 | }, 33 | { 34 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-darwin-arm64.tar.gz", 35 | "checksum": "90783FA092A0F64A4F7B7D419F3DA1F53207E300261773BABE962957240E9EA6", 36 | "algorithm": "sha256" 37 | }, 38 | { 39 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-linux-amd64.tar.gz", 40 | "checksum": "E55E0EB515936C0FBD178BCE504798A9BD2F0B191E5E357768B18FD5415EE541", 41 | "algorithm": "sha256" 42 | }, 43 | { 44 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-linux-arm64.tar.gz", 45 | "checksum": "582EB73880F4408D7FB89F12B502D577BD7B0B63D8C681DA92BB6B9D934D4363", 46 | "algorithm": "sha256" 47 | }, 48 | { 49 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-windows-amd64.zip", 50 | "checksum": "FD7298019C76CF414AB083491F87F6C0A3E537ED6D727D6FF9135E503D6F9C32", 51 | "algorithm": "sha256" 52 | }, 53 | { 54 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-windows-arm64.zip", 55 | "checksum": "0DC38C44D8270A0ED3267BCD3FBDCD8384761D04D0FD2D53B63FC502F0F39722", 56 | "algorithm": "sha256" 57 | }, 58 | { 59 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Darwin_all.tar.gz", 60 | "checksum": "82953B65C4B64E73B1077827663D97BF8E32592B4FC2CDB55C738BD484260A47", 61 | "algorithm": "sha256" 62 | }, 63 | { 64 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Linux_arm64.tar.gz", 65 | "checksum": "574E83F5F0FC97803FF734C9342F8FD446D77E5E7CCAC53DEBF09B4A8DBDED80", 66 | "algorithm": "sha256" 67 | }, 68 | { 69 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Linux_x86_64.tar.gz", 70 | "checksum": "A066FCD713684ABED0D750D7559F1A5D794FA2FAA8E8F1AD2EECEC8C373668A7", 71 | "algorithm": "sha256" 72 | }, 73 | { 74 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Windows_arm64.zip", 75 | "checksum": "EA19CAE5A322FEC6794252D3E9FE77D43201CC831D939964730E556BF3C1CC2C", 76 | "algorithm": "sha256" 77 | }, 78 | { 79 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Windows_x86_64.zip", 80 | "checksum": "F56E85F8FD52875102DFC2B01DC07FC174486CAEBBAC7E3AA9F29B4F0057D495", 81 | "algorithm": "sha256" 82 | }, 83 | { 84 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Darwin_arm64.tar.gz", 85 | "checksum": "A7FBF41913CE5B6F1872D10C136139B7A849190F4F1F0DC1ED4BF74C636F22A2", 86 | "algorithm": "sha256" 87 | }, 88 | { 89 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Darwin_x86_64.tar.gz", 90 | "checksum": "056DD0F43ECCB8651FB976B43AA91A1D34B2A0C3934F216997774A7CBC1F7EB1", 91 | "algorithm": "sha256" 92 | }, 93 | { 94 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Linux_arm64.tar.gz", 95 | "checksum": "BD0C4045B8F367F1CA6C0E7CFD80189CCD2A8CEAA22034ECBAD4AF0ACB3A3B82", 96 | "algorithm": "sha256" 97 | }, 98 | { 99 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Linux_x86_64.tar.gz", 100 | "checksum": "2C634DBC00BD4A86E4D4C47029D2AF9185FAB06643A9DF0AE10E7C4D644781B6", 101 | "algorithm": "sha256" 102 | }, 103 | { 104 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Windows_arm64.tar.gz", 105 | "checksum": "2DFD2C151AFF8B7D2DFDFC44FB47706667806AEA92F4F8238932BB89A0461D4A", 106 | "algorithm": "sha256" 107 | }, 108 | { 109 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Windows_x86_64.tar.gz", 110 | "checksum": "068726CA98BBEB5E47378AB0B630133741E17BA1FEB5654A24EC5E604446EDEF", 111 | "algorithm": "sha256" 112 | }, 113 | { 114 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-darwin-amd64", 115 | "checksum": "D61CC50F6F32C2B63CB81CD8A935E5DD1BE8520D639242031A1102092BEE44D4", 116 | "algorithm": "sha256" 117 | }, 118 | { 119 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-darwin-arm64", 120 | "checksum": "780DA3654D9601367B0D54686AC65CB9716578610CABE292D725C7008DE4DB85", 121 | "algorithm": "sha256" 122 | }, 123 | { 124 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-linux-amd64", 125 | "checksum": "1F6C194DD0891EB345B436BB71FF9F996768355F5E0CE02DDE88567029AC2188", 126 | "algorithm": "sha256" 127 | }, 128 | { 129 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-linux-arm64", 130 | "checksum": "080A998F9878F22DAFDB9AD54D5B2E2B8E7A38C53527250F9D89A6763A28D545", 131 | "algorithm": "sha256" 132 | }, 133 | { 134 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-windows-amd64.exe", 135 | "checksum": "2345667CBCF60767C1A6F678755CBB7465367761084E9D2CBB59AE0CC1A94437", 136 | "algorithm": "sha256" 137 | }, 138 | { 139 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_darwin_amd64.tar.gz", 140 | "checksum": "C7533B3D95241A4E7DE61C7240892DE19DBAAFD26EF44AD8020BC5000E24594D", 141 | "algorithm": "sha256" 142 | }, 143 | { 144 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_darwin_arm64.tar.gz", 145 | "checksum": "141AD7EB4E3410864FD1D5D3E2920BC6C6163CE5B663A872283B0F58CFEA331F", 146 | "algorithm": "sha256" 147 | }, 148 | { 149 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_linux_amd64.tar.gz", 150 | "checksum": "8DC530324176C3703C97E4FE355AF7ED82D4E6341219063856FD0A1594C6CC4B", 151 | "algorithm": "sha256" 152 | }, 153 | { 154 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_linux_arm64.tar.gz", 155 | "checksum": "8AA707E58144DD29CBC5A02EEE62842A0F54964F7CF6118B513A2FEAE1811C74", 156 | "algorithm": "sha256" 157 | }, 158 | { 159 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_windows_amd64.zip", 160 | "checksum": "26BF4BAA495AF54456BACF5A16416AD5B6C756F5661ABD56E73C08CBCEAD65FE", 161 | "algorithm": "sha256" 162 | }, 163 | { 164 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_windows_arm64.zip", 165 | "checksum": "4BB8F4F65EDCE1D3AAE86168075B2E2CD3377E9A72E5D5F51EE097BFABE5DEE2", 166 | "algorithm": "sha256" 167 | }, 168 | { 169 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.4.1/ghalint_1.4.1_darwin_amd64.tar.gz", 170 | "checksum": "AD0D5893D9A4B38F6F8D35DC003A2BEEA63FA2EA48FF91DDD301773AB5711B21", 171 | "algorithm": "sha256" 172 | }, 173 | { 174 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.4.1/ghalint_1.4.1_darwin_arm64.tar.gz", 175 | "checksum": "70DC52A85C207FCB40F1CDBA5F097CCEF7564C5D217E48C60541743CFC15239B", 176 | "algorithm": "sha256" 177 | }, 178 | { 179 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.4.1/ghalint_1.4.1_linux_amd64.tar.gz", 180 | "checksum": "6A8EAA2568FA1FED64D63CCDD4538C3E329B873A7D78F49D207E9FA2FA6A65BB", 181 | "algorithm": "sha256" 182 | }, 183 | { 184 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.4.1/ghalint_1.4.1_linux_arm64.tar.gz", 185 | "checksum": "1417F9B7CE201C69A959BD5E7DA56BFE4128D8C5333EEDB94038B731CA30A12C", 186 | "algorithm": "sha256" 187 | }, 188 | { 189 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.4.1/ghalint_1.4.1_windows_amd64.zip", 190 | "checksum": "3C1EB280BDE931AD793A732B32C802D54C6DF418502A55393D9DC8573282259E", 191 | "algorithm": "sha256" 192 | }, 193 | { 194 | "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.4.1/ghalint_1.4.1_windows_arm64.zip", 195 | "checksum": "0802008325A617634398E0D73BB240F75551B4859769D045E6A91ECC9D85B1AE", 196 | "algorithm": "sha256" 197 | }, 198 | { 199 | "id": "registries/github_content/github.com/aquaproj/aqua-registry/v4.374.0/registry.yaml", 200 | "checksum": "619BDA08E2B9259FEFE5DF052EBB1FEDABE96C58CCC41444938FFA3F3EEB5828456A80CF1859695E37B4F74CD1A45C5AC516C82DFE5752D010AADE352EB222E0", 201 | "algorithm": "sha512" 202 | } 203 | ] 204 | } 205 | -------------------------------------------------------------------------------- /aqua/aqua.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # yaml-language-server: $schema=https://raw.githubusercontent.com/aquaproj/aqua/main/json-schema/aqua-yaml.json 3 | # aqua - Declarative CLI Version Manager 4 | # https://aquaproj.github.io/ 5 | checksum: 6 | enabled: true 7 | require_checksum: true 8 | registries: 9 | - type: standard 10 | ref: v4.374.0 # renovate: depName=aquaproj/aqua-registry 11 | import_dir: imports 12 | -------------------------------------------------------------------------------- /aqua/imports/cmdx.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: suzuki-shunsuke/cmdx@v2.0.1 3 | -------------------------------------------------------------------------------- /aqua/imports/cosign.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: sigstore/cosign@v2.5.0 3 | -------------------------------------------------------------------------------- /aqua/imports/ghalint.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: suzuki-shunsuke/ghalint@v1.4.1 3 | -------------------------------------------------------------------------------- /aqua/imports/golangci-lint.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: golangci/golangci-lint@v2.1.6 3 | -------------------------------------------------------------------------------- /aqua/imports/goreleaser.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: goreleaser/goreleaser@v2.9.0 3 | -------------------------------------------------------------------------------- /aqua/imports/reviewdog.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: reviewdog/reviewdog@v0.20.3 3 | -------------------------------------------------------------------------------- /aqua/imports/typos.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: crate-ci/typos@v1.32.0 3 | -------------------------------------------------------------------------------- /cmd/gen-jsonschema/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/suzuki-shunsuke/gen-go-jsonschema/jsonschema" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 9 | ) 10 | 11 | func main() { 12 | if err := core(); err != nil { 13 | log.Fatal(err) 14 | } 15 | } 16 | 17 | func core() error { 18 | if err := jsonschema.Write(&config.Config{}, "json-schema/ghalint.json"); err != nil { 19 | return fmt.Errorf("create or update a JSON Schema: %w", err) 20 | } 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /cmd/ghalint/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "os/signal" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/spf13/afero" 11 | "github.com/suzuki-shunsuke/ghalint/pkg/cli" 12 | "github.com/suzuki-shunsuke/ghalint/pkg/controller" 13 | "github.com/suzuki-shunsuke/ghalint/pkg/log" 14 | "github.com/suzuki-shunsuke/logrus-error/logerr" 15 | "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" 16 | ) 17 | 18 | var ( 19 | version = "" 20 | commit = "" //nolint:gochecknoglobals 21 | date = "" //nolint:gochecknoglobals 22 | ) 23 | 24 | func main() { 25 | logE := log.New(version) 26 | if err := core(logE); err != nil { 27 | hasLogLevel := &controller.HasLogLevelError{} 28 | if errors.As(err, &hasLogLevel) { 29 | logerr.WithError(logE, hasLogLevel.Err).Log(hasLogLevel.LogLevel, "ghalint failed") 30 | os.Exit(1) 31 | } 32 | logerr.WithError(logE, err).Fatal("ghalint failed") 33 | } 34 | } 35 | 36 | func core(logE *logrus.Entry) error { 37 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 38 | defer stop() 39 | app := cli.New(&urfave.LDFlags{ 40 | Version: version, 41 | Commit: commit, 42 | Date: date, 43 | }, afero.NewOsFs(), logE) 44 | return app.Run(ctx, os.Args) //nolint:wrapcheck 45 | } 46 | -------------------------------------------------------------------------------- /cmdx.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # cmdx - task runner 3 | # https://github.com/suzuki-shunsuke/cmdx 4 | tasks: 5 | - name: test 6 | short: t 7 | description: test 8 | usage: test 9 | script: go test ./... -race -covermode=atomic 10 | - name: coverage 11 | short: c 12 | description: coverage test 13 | usage: coverage test 14 | script: "bash scripts/coverage.sh {{.target}}" 15 | args: 16 | - name: target 17 | - name: vet 18 | short: v 19 | description: go vet 20 | usage: go vet 21 | script: go vet ./... 22 | - name: lint 23 | short: l 24 | description: lint the go code 25 | usage: lint the go code 26 | script: golangci-lint run 27 | - name: install 28 | short: i 29 | description: go install 30 | usage: go install 31 | script: | 32 | sha="" 33 | if git diff --quiet; then 34 | sha=$(git rev-parse HEAD) 35 | fi 36 | go install \ 37 | -ldflags "-X main.version=v1.0.0-local -X main.commit=$sha -X main.date=$(date +"%Y-%m-%dT%H:%M:%SZ%:z" | tr -d '+')" \ 38 | ./cmd/ghalint 39 | - name: usage 40 | description: Update usage.md 41 | usage: Update usage.md 42 | script: bash scripts/generate-usage.sh 43 | - name: js 44 | description: Generate JSON Schema 45 | usage: Generate JSON Schema 46 | script: "go run ./cmd/gen-jsonschema" 47 | -------------------------------------------------------------------------------- /docs/codes/001.md: -------------------------------------------------------------------------------- 1 | # parse a workflow file as YAML: EOF 2 | 3 | ```console 4 | $ ghalint run 5 | ERRO[0000] read a workflow file error="parse a workflow file as YAML: EOF" program=ghalint version=0.2.6 workflow_file_path=.github/workflows/test.yaml 6 | ``` 7 | 8 | This error occurs if the workflow file has no YAML node. 9 | Probably this means the YAML file is empty or all codes are empty lines or commented out. 10 | 11 | ## How to solve 12 | 13 | 1. Fix the workflow file 14 | 1. Move or rename the workflow file to exclude it from targets of ghalint 15 | 16 | If this error occurs, probably the YAML file is invalid as a GitHub Actions Workflow. 17 | So this isn't a bug of ghalint. 18 | Please fix the workflow file. 19 | 20 | ref. https://github.com/suzuki-shunsuke/ghalint/issues/197#issuecomment-1782032909 21 | 22 | image 23 | 24 | > [Error: .github#L1](https://github.com/suzuki-shunsuke/test-github-action/commit/52b75ce5cf55aeff15394fb0cabdbaaa28fab847#annotation_15218437727) 25 | > No event triggers defined in `on` 26 | -------------------------------------------------------------------------------- /docs/codes/002.md: -------------------------------------------------------------------------------- 1 | # read a configuration file: parse configuration file as YAML: EOF 2 | 3 | ```console 4 | $ ghalint run 5 | FATA[0000] ghalint failed config_file=ghalint.yaml error="read a configuration file: parse configuration file as YAML: EOF" 6 | ``` 7 | 8 | This error occurs if the configuration file has no YAML node. 9 | Probably this means the YAML file is empty or all codes are empty lines or commented out. 10 | 11 | ## How to solve 12 | 13 | Please fix the configuration file. 14 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # Install 2 | 3 | ghalint is written in Go. So you only have to install a binary in your `PATH`. 4 | 5 | There are some ways to install ghalint. 6 | 7 | 1. [Homebrew](#homebrew) 8 | 1. [Scoop](#scoop) 9 | 1. [aqua](#aqua) 10 | 1. [GitHub Releases](#github-releases) 11 | 1. [Build an executable binary from source code yourself using Go](#build-an-executable-binary-from-source-code-yourself-using-go) 12 | 13 | ## Homebrew 14 | 15 | You can install ghalint using [Homebrew](https://brew.sh/). 16 | 17 | ```sh 18 | brew install suzuki-shunsuke/ghalint/ghalint 19 | ``` 20 | 21 | ## Scoop 22 | 23 | You can install ghalint using [Scoop](https://scoop.sh/). 24 | 25 | ```sh 26 | scoop bucket add suzuki-shunsuke https://github.com/suzuki-shunsuke/scoop-bucket 27 | scoop install ghalint 28 | ``` 29 | 30 | ## aqua 31 | 32 | You can install ghalint using [aqua](https://aquaproj.github.io/). 33 | 34 | ```sh 35 | aqua g -i suzuki-shunsuke/ghalint 36 | ``` 37 | 38 | ## Build an executable binary from source code yourself using Go 39 | 40 | ```sh 41 | go install github.com/suzuki-shunsuke/ghalint/cmd/ghalint@latest 42 | ``` 43 | 44 | ## GitHub Releases 45 | 46 | You can download an asset from [GitHub Releases](https://github.com/suzuki-shunsuke/ghalint/releases). 47 | Please unarchive it and install a pre built binary into `$PATH`. 48 | 49 | ### Verify downloaded assets from GitHub Releases 50 | 51 | You can verify downloaded assets using some tools. 52 | 53 | 1. [GitHub CLI](https://cli.github.com/) 54 | 1. [slsa-verifier](https://github.com/slsa-framework/slsa-verifier) 55 | 1. [Cosign](https://github.com/sigstore/cosign) 56 | 57 | ### 1. GitHub CLI 58 | 59 | You can install GitHub CLI by aqua. 60 | 61 | ```sh 62 | aqua g -i cli/cli 63 | ``` 64 | 65 | ```sh 66 | version=v1.2.0 67 | asset=ghalint_darwin_arm64.tar.gz 68 | gh release download -R suzuki-shunsuke/ghalint "$version" -p "$asset" 69 | gh attestation verify "$asset" \ 70 | -R suzuki-shunsuke/ghalint \ 71 | --signer-workflow suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml 72 | ``` 73 | 74 | ### 2. slsa-verifier 75 | 76 | You can install slsa-verifier by aqua. 77 | 78 | ```sh 79 | aqua g -i slsa-framework/slsa-verifier 80 | ``` 81 | 82 | ```sh 83 | version=v1.2.0 84 | asset=ghalint_darwin_arm64.tar.gz 85 | gh release download -R suzuki-shunsuke/ghalint "$version" -p "$asset" -p multiple.intoto.jsonl 86 | slsa-verifier verify-artifact "$asset" \ 87 | --provenance-path multiple.intoto.jsonl \ 88 | --source-uri github.com/suzuki-shunsuke/ghalint \ 89 | --source-tag "$version" 90 | ``` 91 | 92 | ### 3. Cosign 93 | 94 | You can install Cosign by aqua. 95 | 96 | ```sh 97 | aqua g -i sigstore/cosign 98 | ``` 99 | 100 | ```sh 101 | version=v1.2.0 102 | checksum_file="ghalint_${version#v}_checksums.txt" 103 | asset=ghalint_darwin_arm64.tar.gz 104 | gh release download "$version" \ 105 | -R suzuki-shunsuke/ghalint \ 106 | -p "$asset" \ 107 | -p "$checksum_file" \ 108 | -p "${checksum_file}.pem" \ 109 | -p "${checksum_file}.sig" 110 | cosign verify-blob \ 111 | --signature "${checksum_file}.sig" \ 112 | --certificate "${checksum_file}.pem" \ 113 | --certificate-identity-regexp 'https://github\.com/suzuki-shunsuke/go-release-workflow/\.github/workflows/release\.yaml@.*' \ 114 | --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ 115 | "$checksum_file" 116 | cat "$checksum_file" | sha256sum -c --ignore-missing 117 | ``` 118 | -------------------------------------------------------------------------------- /docs/policies/001.md: -------------------------------------------------------------------------------- 1 | # job_permissions 2 | 3 | All jobs should have the field [permissions](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idpermissions). 4 | 5 | ## Examples 6 | 7 | :x: 8 | 9 | ```yaml 10 | jobs: 11 | foo: # The job doesn't have `permissions` 12 | runs-on: ubuntu-latest 13 | steps: 14 | - run: echo hello 15 | ``` 16 | 17 | :o: 18 | 19 | ```yaml 20 | jobs: 21 | foo: 22 | runs-on: ubuntu-latest 23 | permissions: {} # Set permissions 24 | steps: 25 | - run: echo hello 26 | ``` 27 | 28 | ## Why? 29 | 30 | For least privilege. 31 | 32 | ## Exceptions 33 | 34 | 1. workflow's `permissions` is empty `{}` 35 | 36 | ```yaml 37 | permissions: {} # empty permissions 38 | jobs: 39 | foo: # The job is missing `permissions`, but it's okay because the workflow's `permissions` is empty 40 | runs-on: ubuntu-latest 41 | steps: 42 | - run: echo hello 43 | ``` 44 | 45 | 2. workflow has only one job and the workflow has `permissions` 46 | 47 | ```yaml 48 | permissions: 49 | contents: read 50 | jobs: 51 | foo: # The job is missing `permissions`, but it's okay because the workflow has permissions and the workflow has only one job. 52 | runs-on: ubuntu-latest 53 | steps: 54 | - run: echo hello 55 | ``` 56 | -------------------------------------------------------------------------------- /docs/policies/002.md: -------------------------------------------------------------------------------- 1 | # deny_read_all_permission 2 | 3 | [`read-all` permission](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#defining-access-for-the-github_token-scopes) should not be used. 4 | 5 | ## Examples 6 | 7 | :x: 8 | 9 | ```yaml 10 | name: test 11 | jobs: 12 | foo: 13 | runs-on: ubuntu-latest 14 | permissions: read-all # Don't use read-all 15 | steps: 16 | - run: echo foo 17 | ``` 18 | 19 | :o: 20 | 21 | ```yaml 22 | name: test 23 | jobs: 24 | foo: 25 | runs-on: ubuntu-latest 26 | permissions: 27 | contents: read 28 | steps: 29 | - run: echo foo 30 | ``` 31 | 32 | ## Why? 33 | 34 | For least privilege. 35 | You should grant only necessary permissions. 36 | -------------------------------------------------------------------------------- /docs/policies/003.md: -------------------------------------------------------------------------------- 1 | # deny_write_all_permission 2 | 3 | [`write-all` permission](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#defining-access-for-the-github_token-scopes) should not be used. 4 | 5 | ## Examples 6 | 7 | :x: 8 | 9 | ```yaml 10 | name: test 11 | jobs: 12 | foo: 13 | runs-on: ubuntu-latest 14 | permissions: write-all # Don't use write-all 15 | steps: 16 | - run: echo foo 17 | ``` 18 | 19 | :o: 20 | 21 | ```yaml 22 | name: test 23 | jobs: 24 | foo: 25 | runs-on: ubuntu-latest 26 | permissions: 27 | contents: write 28 | steps: 29 | - run: echo foo 30 | ``` 31 | 32 | ## Why? 33 | 34 | For least privilege. 35 | You should grant only necessary permissions. 36 | -------------------------------------------------------------------------------- /docs/policies/004.md: -------------------------------------------------------------------------------- 1 | # deny_inherit_secrets 2 | 3 | [`secrets: inherit`](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit) should not be used 4 | 5 | ## Examples 6 | 7 | :x: 8 | 9 | ```yaml 10 | jobs: 11 | release: 12 | uses: suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@v0.4.4 13 | secrets: inherit # `inherit` should not be used 14 | ``` 15 | 16 | :o: 17 | 18 | ```yaml 19 | jobs: 20 | release: 21 | uses: suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@v0.4.4 22 | secrets: # Only required secrets should be passed 23 | gh_app_id: ${{ secrets.APP_ID }} 24 | gh_app_private_key: ${{ secrets.APP_PRIVATE_KEY }} 25 | ``` 26 | 27 | ## Why? 28 | 29 | Secrets should be exposed to only required jobs. 30 | 31 | ## How to ignore the violation 32 | 33 | We don't recommend, but if you want to ignore the violation of this policy, please configure it with [the configuration file](../../README.md#configuration-file). 34 | 35 | e.g. 36 | 37 | ghalint.yaml 38 | 39 | ```yaml 40 | excludes: 41 | - policy_name: deny_inherit_secrets 42 | workflow_file_path: .github/workflows/actionlint.yaml 43 | job_name: actionlint 44 | ``` 45 | 46 | `policy_name`, `workflow_file_path`, and `job_name` are required. 47 | -------------------------------------------------------------------------------- /docs/policies/005.md: -------------------------------------------------------------------------------- 1 | # workflow_secrets 2 | 3 | Workflows should not set secrets to environment variables. 4 | 5 | ## Examples 6 | 7 | :x: 8 | 9 | ```yaml 10 | name: test 11 | env: 12 | GITHUB_TOKEN: ${{github.token}} # The secret should not be set to workflow's environment variables 13 | DATADOG_API_KEY: ${{secrets.DATADOG_API_KEY}} # The secret should not be set to workflow's environment variables 14 | jobs: 15 | foo: 16 | runs-on: ubuntu-latest 17 | permissions: {} 18 | steps: 19 | - run: echo foo 20 | bar: 21 | runs-on: ubuntu-latest 22 | permissions: {} 23 | steps: 24 | - run: echo bar 25 | ``` 26 | 27 | :o: 28 | 29 | ```yaml 30 | name: test 31 | jobs: 32 | foo: 33 | runs-on: ubuntu-latest 34 | permissions: {} 35 | env: 36 | GITHUB_TOKEN: ${{github.token}} 37 | steps: 38 | - run: echo foo 39 | bar: 40 | runs-on: ubuntu-latest 41 | permissions: {} 42 | env: 43 | DATADOG_API_KEY: ${{secrets.DATADOG_API_KEY}} 44 | steps: 45 | - run: echo bar 46 | ``` 47 | 48 | ## How to fix 49 | 50 | Set secrets to jobs or steps. 51 | 52 | ## Why? 53 | 54 | Secrets should be exposed to only necessary jobs or steps. 55 | 56 | ## Exceptions 57 | 58 | Workflow has only one job. 59 | -------------------------------------------------------------------------------- /docs/policies/006.md: -------------------------------------------------------------------------------- 1 | # job_secrets 2 | 3 | Job should not set secrets to environment variables. 4 | 5 | ## Examples 6 | 7 | :x: 8 | 9 | ```yaml 10 | jobs: 11 | foo: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | issues: write 15 | env: 16 | GITHUB_TOKEN: ${{github.token}} # secret is set in job 17 | steps: 18 | - run: echo foo 19 | - run: gh label create bug 20 | ``` 21 | 22 | :o: 23 | 24 | ```yaml 25 | jobs: 26 | foo: 27 | runs-on: ubuntu-latest 28 | permissions: 29 | issues: write 30 | steps: 31 | - run: echo foo 32 | - run: gh label create bug 33 | env: 34 | GITHUB_TOKEN: ${{github.token}} # secret is set in step 35 | ``` 36 | 37 | ## How to fix 38 | 39 | Set secrets to steps. 40 | 41 | ## Why? 42 | 43 | Secrets should be exposed to only necessary steps. 44 | 45 | ## Exceptions 46 | 47 | Job has only one step. 48 | 49 | ## How to ignore the violation 50 | 51 | We don't recommend, but if you want to ignore the violation of this policy, please configure it with [the configuration file](../../README.md#configuration-file). 52 | 53 | e.g. 54 | 55 | ghalint.yaml 56 | 57 | ```yaml 58 | excludes: 59 | - policy_name: job_secrets 60 | workflow_file_path: .github/workflows/actionlint.yaml 61 | job_name: actionlint 62 | ``` 63 | 64 | `policy_name`, `workflow_file_path`, and `job_name` are required. 65 | -------------------------------------------------------------------------------- /docs/policies/007.md: -------------------------------------------------------------------------------- 1 | # deny_job_container_latest_image 2 | 3 | Job's container image tag should not be `latest`. 4 | 5 | ## Examples 6 | 7 | :x: 8 | 9 | ```yaml 10 | jobs: 11 | container-test-job: 12 | runs-on: ubuntu-latest 13 | container: 14 | image: node:latest # latest tags should not be used 15 | ``` 16 | 17 | ⭕ 18 | 19 | ```yaml 20 | jobs: 21 | container-test-job: 22 | runs-on: ubuntu-latest 23 | container: 24 | image: node:10 # Ideally, hash is best 25 | ``` 26 | 27 | ## Why? 28 | 29 | Image tags should be pinned with tag or hash. 30 | -------------------------------------------------------------------------------- /docs/policies/008.md: -------------------------------------------------------------------------------- 1 | # action_ref_should_be_full_length_commit_sha 2 | 3 | action's ref should be full length commit SHA 4 | 5 | ## Examples 6 | 7 | :x: 8 | 9 | ``` 10 | actions/checkout@v3 11 | ``` 12 | 13 | ⭕ 14 | 15 | ``` 16 | actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 17 | ``` 18 | 19 | ## Why? 20 | 21 | https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions 22 | 23 | > Pinning an action to a full length commit SHA is currently the only way to use an action as an immutable release. 24 | > Pinning to a particular SHA helps mitigate the risk of a bad actor adding a backdoor to the action's repository, as they would need to generate a SHA-1 collision for a valid Git object payload 25 | 26 | ## Exclude 27 | 28 | Some actions and reusable workflows don't support pinning version. 29 | You can exclude those actions and reusable workflows. 30 | 31 | ghalint.yaml 32 | 33 | ```yaml 34 | excludes: 35 | # slsa-framework/slsa-github-generator doesn't support pinning version 36 | # > Invalid ref: 68bad40844440577b33778c9f29077a3388838e9. Expected ref of the form refs/tags/vX.Y.Z 37 | # https://github.com/slsa-framework/slsa-github-generator/issues/722 38 | - policy_name: action_ref_should_be_full_length_commit_sha 39 | action_name: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml 40 | ``` 41 | 42 | [#650](https://github.com/suzuki-shunsuke/ghalint/pull/650) As of v1.1.0, `action_name` supports a glob pattern. 43 | 44 | https://pkg.go.dev/path#Match 45 | 46 | ```yaml 47 | excludes: 48 | - policy_name: action_ref_should_be_full_length_commit_sha 49 | action_name: suzuki-shunsuke/tfaction/* # glob pattern 50 | ``` 51 | 52 | `policy_name` and `action_name` are mandatory. 53 | 54 | ## pinact 55 | 56 | https://github.com/suzuki-shunsuke/pinact 57 | 58 | [pinact](https://github.com/suzuki-shunsuke/pinact) is useful to convert tags to full length commit SHA. 59 | -------------------------------------------------------------------------------- /docs/policies/009.md: -------------------------------------------------------------------------------- 1 | # github_app_should_limit_repositories 2 | 3 | GitHub Actions issuing GitHub Access tokens from GitHub Apps should limit repositories. 4 | 5 | This policy supports the following actions. 6 | 7 | 1. https://github.com/tibdex/github-app-token 8 | 1. https://github.com/actions/create-github-app-token 9 | 10 | ## Examples 11 | 12 | ### tibdex/github-app-token 13 | 14 | https://github.com/tibdex/github-app-token 15 | 16 | :x: 17 | 18 | ```yaml 19 | - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 20 | with: 21 | app_id: ${{secrets.APP_ID}} 22 | private_key: ${{secrets.PRIVATE_KEY}} 23 | ``` 24 | 25 | ⭕ 26 | 27 | ```yaml 28 | - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 29 | with: 30 | app_id: ${{secrets.APP_ID}} 31 | private_key: ${{secrets.PRIVATE_KEY}} 32 | repositories: >- 33 | ["${{github.event.repository.name}}"] 34 | ``` 35 | 36 | ### actions/create-github-app-token 37 | 38 | https://github.com/actions/create-github-app-token 39 | 40 | :x: 41 | 42 | ```yaml 43 | - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 44 | with: 45 | app-id: ${{vars.APP_ID}} 46 | private-key: ${{secrets.PRIVATE_KEY}} 47 | owner: ${{github.repository_owner}} 48 | permission-issues: write 49 | ``` 50 | 51 | ⭕ 52 | 53 | ```yaml 54 | - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 55 | with: 56 | app-id: ${{vars.APP_ID}} 57 | private-key: ${{secrets.PRIVATE_KEY}} 58 | owner: ${{github.repository_owner}} 59 | repositories: "repo1,repo2" 60 | permission-issues: write 61 | ``` 62 | 63 | Or 64 | 65 | > If owner and repositories are empty, access will be scoped to only the current repository. 66 | 67 | ```yaml 68 | - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 69 | with: 70 | app-id: ${{vars.APP_ID}} 71 | private-key: ${{secrets.PRIVATE_KEY}} 72 | permission-issues: write 73 | ``` 74 | 75 | ## Why? 76 | 77 | The scope of access tokens should be limited. 78 | 79 | ## How to ignore the violation 80 | 81 | We don't recommend, but if you want to ignore the violation of this policy, please configure it with [the configuration file](../../README.md#configuration-file). 82 | 83 | e.g. 84 | 85 | ghalint.yaml 86 | 87 | ```yaml 88 | excludes: 89 | - policy_name: github_app_should_limit_repositories 90 | workflow_file_path: .github/workflows/actionlint.yaml 91 | job_name: actionlint 92 | step_id: create_token 93 | ``` 94 | 95 | - workflow: `policy_name`, `workflow_file_path`, `job_name`, `step_id` are required. 96 | - action: `policy_name`, `action_file_path`, `step_id` are required. 97 | -------------------------------------------------------------------------------- /docs/policies/010.md: -------------------------------------------------------------------------------- 1 | # github_app_should_limit_permissions 2 | 3 | GitHub Actions issuing GitHub Access tokens from GitHub Apps should limit permissions. 4 | 5 | This policy supports the following actions. 6 | 7 | 1. https://github.com/tibdex/github-app-token 8 | 1. https://github.com/actions/create-github-app-token 9 | 10 | > [!NOTE] 11 | > This policy has supported [actions/create-github-app-token](https://github.com/actions/create-github-app-token) since ghalint v1.3.0. 12 | > [actions/create-github-app-token](https://github.com/actions/create-github-app-token) has supported custom permissions since [v1.12.0](https://github.com/actions/create-github-app-token/releases/tag/v1.12.0). 13 | > If you use old create-github-app-token, please update it to v1.12.0 or later. 14 | 15 | ## Examples 16 | 17 | ### tibdex/github-app-token 18 | 19 | https://github.com/tibdex/github-app-token 20 | 21 | :x: 22 | 23 | ```yaml 24 | - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 25 | with: 26 | app_id: ${{secrets.APP_ID}} 27 | private_key: ${{secrets.PRIVATE_KEY}} 28 | repositories: >- 29 | ["${{github.event.repository.name}}"] 30 | ``` 31 | 32 | ⭕ 33 | 34 | ```yaml 35 | - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 36 | with: 37 | app_id: ${{secrets.APP_ID}} 38 | private_key: ${{secrets.PRIVATE_KEY}} 39 | repositories: >- 40 | ["${{github.event.repository.name}}"] 41 | permissions: >- 42 | { 43 | "contents": "read" 44 | } 45 | ``` 46 | 47 | ### actions/create-github-app-token 48 | 49 | :x: 50 | 51 | ```yaml 52 | - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 53 | with: 54 | app-id: ${{vars.APP_ID}} 55 | private-key: ${{secrets.PRIVATE_KEY}} 56 | ``` 57 | 58 | ⭕ 59 | 60 | ```yaml 61 | - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 62 | with: 63 | app-id: ${{vars.APP_ID}} 64 | private-key: ${{secrets.PRIVATE_KEY}} 65 | permission-issues: write 66 | ``` 67 | 68 | ## Why? 69 | 70 | The scope of access tokens should be limited. 71 | -------------------------------------------------------------------------------- /docs/policies/011.md: -------------------------------------------------------------------------------- 1 | # action_shell_is_required 2 | 3 | `shell` is required if `run` is set 4 | 5 | ## Examples 6 | 7 | :x: 8 | 9 | ```yaml 10 | - run: echo hello 11 | ``` 12 | 13 | ⭕ 14 | 15 | ```yaml 16 | - run: echo hello 17 | shell: bash 18 | ``` 19 | 20 | ## Why? 21 | 22 | > Required if run is set. 23 | 24 | https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsshell 25 | -------------------------------------------------------------------------------- /docs/policies/012.md: -------------------------------------------------------------------------------- 1 | # job_timeout_minutes_is_required 2 | 3 | All jobs should set [timeout-minutes](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes). 4 | 5 | ## Examples 6 | 7 | :x: 8 | 9 | ```yaml 10 | jobs: 11 | foo: # The job doesn't have `timeout-minutes` 12 | runs-on: ubuntu-latest 13 | steps: 14 | - run: echo hello 15 | ``` 16 | 17 | :o: 18 | 19 | ```yaml 20 | jobs: 21 | foo: 22 | runs-on: ubuntu-latest 23 | timeout-minutes: 30 24 | steps: 25 | - run: echo hello 26 | ``` 27 | 28 | ## :bulb: Set `timeout-minutes` by `ghatm` 29 | 30 | https://github.com/suzuki-shunsuke/ghatm 31 | 32 | It's so bothersome to fix a lot of workflow files by hand. 33 | [ghatm](https://github.com/suzuki-shunsuke/ghatm) is a command line tool to fix them automatically. 34 | 35 | ## Why? 36 | 37 | https://exercism.org/docs/building/github/gha-best-practices#h-set-timeouts-for-workflows 38 | 39 | > By default, GitHub Actions kills workflows after 6 hours if they have not finished by then. Many workflows don't need nearly as much time to finish, but sometimes unexpected errors occur or a job hangs until the workflow run is killed 6 hours after starting it. Therefore it's recommended to specify a shorter timeout. 40 | > 41 | > The ideal timeout depends on the individual workflow but 30 minutes is typically more than enough for the workflows used in Exercism repos. 42 | > 43 | > This has the following advantages: 44 | > 45 | > PRs won't be pending CI for half the day, issues can be caught early or workflow runs can be restarted. 46 | > The number of overall parallel builds is limited, hanging jobs will not cause issues for other PRs if they are cancelled early. 47 | 48 | ## Exceptions 49 | 50 | 1. All steps set `timeout-minutes` 51 | 52 | ```yaml 53 | jobs: 54 | foo: # The job is missing `timeout-minutes`, but it's okay because all steps set timeout-minutes 55 | runs-on: ubuntu-latest 56 | steps: 57 | - run: echo hello 58 | timeout-minutes: 5 59 | - run: echo bar 60 | timeout-minutes: 5 61 | ``` 62 | 63 | 2. A job uses a reusable workflow 64 | 65 | When a reusable workflow is called with `uses`, `timeout-minutes` is not available. 66 | 67 | ```yaml 68 | jobs: 69 | foo: 70 | uses: suzuki-shunsuke/renovate-config-validator-workflow/.github/workflows/validate.yaml@v0.2.3 71 | ``` 72 | -------------------------------------------------------------------------------- /docs/policies/013.md: -------------------------------------------------------------------------------- 1 | # checkout_persist_credentials_should_be_false 2 | 3 | [actions/checkout](https://github.com/actions/checkout)'s input `persist-credentials` should be `false`. 4 | 5 | ## Examples 6 | 7 | :x: 8 | 9 | ```yaml 10 | jobs: 11 | foo: 12 | runs-on: ubuntu-latest 13 | steps: 14 | # persist-credentials is not set 15 | - uses: actions/checkout@v4 16 | 17 | bar: 18 | runs-on: ubuntu-latest 19 | steps: 20 | # persist-credentials is true 21 | - uses: actions/checkout@v4 22 | with: 23 | persist-credentials: "true" 24 | ``` 25 | 26 | :o: 27 | 28 | ```yaml 29 | jobs: 30 | foo: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | with: 35 | persist-credentials: "false" 36 | ``` 37 | 38 | ## Why? 39 | 40 | https://github.com/actions/checkout/issues/485 41 | 42 | Persisting token allows every step after `actions/checkout` to access token. 43 | This is a security risk. 44 | 45 | ## :bulb: Fix using suzuki-shunsuke/disable-checkout-persist-credentials 46 | 47 | Adding `persist-credentials: false` by hand is bothersome. 48 | You can do this automatically using suzuki-shunsuke/disable-checkout-persist-credentials. 49 | 50 | https://github.com/suzuki-shunsuke/disable-checkout-persist-credentials 51 | 52 | ## How to ignore the violation 53 | 54 | If you need to persist token in a specific job, please configure it with [the configuration file](../../README.md#configuration-file). 55 | 56 | e.g. 57 | 58 | ghalint.yaml 59 | 60 | ```yaml 61 | excludes: 62 | - policy_name: checkout_persist_credentials_should_be_false 63 | workflow_file_path: .github/workflows/actionlint.yaml 64 | job_name: actionlint 65 | ``` 66 | 67 | - workflow: `policy_name`, `workflow_file_path`, `job_name` are required 68 | - action: `policy_name` and `action_file_path` are required 69 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | 4 | 5 | ```console 6 | $ ghalint --help 7 | NAME: 8 | ghalint - GitHub Actions linter 9 | 10 | USAGE: 11 | ghalint [global options] [command [command options]] 12 | 13 | VERSION: 14 | 1.4.1 15 | 16 | COMMANDS: 17 | run lint GitHub Actions Workflows 18 | run-action, act lint actions 19 | version Show version 20 | help, h Shows a list of commands or help for one command 21 | completion Output shell completion script for bash, zsh, fish, or Powershell 22 | 23 | GLOBAL OPTIONS: 24 | --log-color string log color. auto(default)|always|never [$GHALINT_LOG_COLOR] 25 | --log-level string log level [$GHALINT_LOG_LEVEL] 26 | --config string, -c string configuration file path [$GHALINT_CONFIG] 27 | --help, -h show help 28 | --version, -v print the version 29 | ``` 30 | 31 | ## ghalint run 32 | 33 | ```console 34 | $ ghalint run --help 35 | NAME: 36 | ghalint run - lint GitHub Actions Workflows 37 | 38 | USAGE: 39 | ghalint run 40 | 41 | OPTIONS: 42 | --help, -h show help 43 | ``` 44 | 45 | ## ghalint run-action 46 | 47 | ```console 48 | $ ghalint run-action --help 49 | NAME: 50 | ghalint run-action - lint actions 51 | 52 | USAGE: 53 | ghalint run-action 54 | 55 | OPTIONS: 56 | --help, -h show help 57 | ``` 58 | 59 | ## ghalint version 60 | 61 | ```console 62 | $ ghalint version --help 63 | NAME: 64 | ghalint version - Show version 65 | 66 | USAGE: 67 | ghalint version 68 | 69 | OPTIONS: 70 | --json, -j Output version in JSON format (default: false) 71 | --help, -h show help 72 | ``` 73 | 74 | ## ghalint completion 75 | 76 | ```console 77 | $ ghalint completion --help 78 | NAME: 79 | ghalint completion - Output shell completion script for bash, zsh, fish, or Powershell 80 | 81 | USAGE: 82 | ghalint completion 83 | 84 | DESCRIPTION: 85 | Output shell completion script for bash, zsh, fish, or Powershell. 86 | Source the output to enable completion. 87 | 88 | # .bashrc 89 | source <(ghalint completion bash) 90 | 91 | # .zshrc 92 | source <(ghalint completion zsh) 93 | 94 | # fish 95 | ghalint completion fish > ~/.config/fish/completions/ghalint.fish 96 | 97 | # Powershell 98 | Output the script to path/to/autocomplete/ghalint.ps1 an run it. 99 | 100 | 101 | OPTIONS: 102 | --help, -h show help 103 | ``` -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/suzuki-shunsuke/ghalint 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/mattn/go-colorable v0.1.14 7 | github.com/sirupsen/logrus v1.9.3 8 | github.com/spf13/afero v1.14.0 9 | github.com/suzuki-shunsuke/gen-go-jsonschema v0.1.0 10 | github.com/suzuki-shunsuke/logrus-error v0.1.4 11 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.0.5 12 | github.com/urfave/cli/v3 v3.3.3 13 | gopkg.in/yaml.v3 v3.0.1 14 | ) 15 | 16 | require ( 17 | github.com/bahlo/generic-list-go v0.2.0 // indirect 18 | github.com/buger/jsonparser v1.1.1 // indirect 19 | github.com/invopop/jsonschema v0.12.0 // indirect 20 | github.com/mailru/easyjson v0.7.7 // indirect 21 | github.com/mattn/go-isatty v0.0.20 // indirect 22 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 23 | golang.org/x/sys v0.29.0 // indirect 24 | golang.org/x/text v0.23.0 // indirect 25 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= 2 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= 3 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 4 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= 9 | github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= 10 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 11 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 12 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 13 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 14 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 15 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 16 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 17 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 18 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 19 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 20 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 21 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 22 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 23 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 24 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 25 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 26 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= 27 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 28 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 29 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 30 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 31 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 32 | github.com/suzuki-shunsuke/gen-go-jsonschema v0.1.0 h1:g7askc+nskCkKRWTVOdsAT8nMhwiaVT6Dmlnh6uvITM= 33 | github.com/suzuki-shunsuke/gen-go-jsonschema v0.1.0/go.mod h1:yFO7h5wwFejxi6jbtazqmk7b/JSBxHcit8DGwb1bhg0= 34 | github.com/suzuki-shunsuke/logrus-error v0.1.4 h1:nWo98uba1fANHdZ9Y5pJ2RKs/PpVjrLzRp5m+mRb9KE= 35 | github.com/suzuki-shunsuke/logrus-error v0.1.4/go.mod h1:WsVvvw6SKSt08/fB2qbnsKIMJA4K1MYCUprqsBJbMiM= 36 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.0.5 h1:ETCRtbqi2D0NTwGHBPTc1IRIa8mFPLt936J2FA1iy10= 37 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.0.5/go.mod h1:XzvaaJ7L21jH7CqwZj4FlYCRJYqiEUHMqiFp54je7/M= 38 | github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 39 | github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 40 | github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= 41 | github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= 42 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 45 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 46 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 47 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 48 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 49 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 50 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 51 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 52 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 53 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 54 | -------------------------------------------------------------------------------- /json-schema/ghalint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://github.com/suzuki-shunsuke/ghalint/pkg/config/config", 4 | "$ref": "#/$defs/Config", 5 | "$defs": { 6 | "Config": { 7 | "properties": { 8 | "excludes": { 9 | "items": { 10 | "$ref": "#/$defs/Exclude" 11 | }, 12 | "type": "array" 13 | } 14 | }, 15 | "additionalProperties": false, 16 | "type": "object" 17 | }, 18 | "Exclude": { 19 | "properties": { 20 | "policy_name": { 21 | "type": "string" 22 | }, 23 | "workflow_file_path": { 24 | "type": "string" 25 | }, 26 | "action_file_path": { 27 | "type": "string" 28 | }, 29 | "job_name": { 30 | "type": "string" 31 | }, 32 | "action_name": { 33 | "type": "string" 34 | }, 35 | "step_id": { 36 | "type": "string" 37 | } 38 | }, 39 | "additionalProperties": false, 40 | "type": "object", 41 | "required": [ 42 | "policy_name" 43 | ] 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pkg/cli/app.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | "github.com/spf13/afero" 6 | "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" 7 | "github.com/urfave/cli/v3" 8 | ) 9 | 10 | type Runner struct { 11 | flags *urfave.LDFlags 12 | fs afero.Fs 13 | logE *logrus.Entry 14 | } 15 | 16 | func New(flags *urfave.LDFlags, fs afero.Fs, logE *logrus.Entry) *cli.Command { 17 | runner := &Runner{ 18 | flags: flags, 19 | fs: fs, 20 | logE: logE, 21 | } 22 | return urfave.Command(logE, flags, &cli.Command{ 23 | Name: "ghalint", 24 | Usage: "GitHub Actions linter", 25 | Flags: []cli.Flag{ 26 | &cli.StringFlag{ 27 | Name: "log-color", 28 | Usage: "log color. auto(default)|always|never", 29 | Sources: cli.EnvVars( 30 | "GHALINT_LOG_COLOR", 31 | ), 32 | }, 33 | &cli.StringFlag{ 34 | Name: "log-level", 35 | Usage: "log level", 36 | Sources: cli.EnvVars( 37 | "GHALINT_LOG_LEVEL", 38 | ), 39 | }, 40 | &cli.StringFlag{ 41 | Name: "config", 42 | Aliases: []string{"c"}, 43 | Usage: "configuration file path", 44 | Sources: cli.EnvVars( 45 | "GHALINT_CONFIG", 46 | ), 47 | }, 48 | }, 49 | Commands: []*cli.Command{ 50 | { 51 | Name: "run", 52 | Usage: "lint GitHub Actions Workflows", 53 | Action: runner.Run, 54 | Flags: []cli.Flag{}, 55 | }, 56 | { 57 | Name: "run-action", 58 | Aliases: []string{ 59 | "act", 60 | }, 61 | Usage: "lint actions", 62 | Action: runner.RunAction, 63 | Flags: []cli.Flag{}, 64 | }, 65 | }, 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /pkg/cli/run.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/suzuki-shunsuke/ghalint/pkg/controller" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/log" 8 | "github.com/urfave/cli/v3" 9 | ) 10 | 11 | func (r *Runner) Run(ctx context.Context, cmd *cli.Command) error { 12 | logE := r.logE 13 | 14 | if color := cmd.String("log-color"); color != "" { 15 | log.SetColor(color, logE) 16 | } 17 | 18 | if logLevel := cmd.String("log-level"); logLevel != "" { 19 | log.SetLevel(logLevel, logE) 20 | } 21 | 22 | ctrl := controller.New(r.fs) 23 | 24 | return ctrl.Run(ctx, logE, cmd.String("config")) //nolint:wrapcheck 25 | } 26 | -------------------------------------------------------------------------------- /pkg/cli/run_action.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/suzuki-shunsuke/ghalint/pkg/controller/act" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/log" 8 | "github.com/urfave/cli/v3" 9 | ) 10 | 11 | func (r *Runner) RunAction(ctx context.Context, cmd *cli.Command) error { 12 | logE := r.logE 13 | 14 | if color := cmd.String("log-color"); color != "" { 15 | log.SetColor(color, logE) 16 | } 17 | 18 | if logLevel := cmd.String("log-level"); logLevel != "" { 19 | log.SetLevel(logLevel, logE) 20 | } 21 | 22 | ctrl := act.New(r.fs) 23 | 24 | return ctrl.Run(ctx, logE, cmd.String("config"), cmd.Args().Slice()...) //nolint:wrapcheck 25 | } 26 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "path" 8 | "path/filepath" 9 | 10 | "github.com/sirupsen/logrus" 11 | "github.com/spf13/afero" 12 | "github.com/suzuki-shunsuke/logrus-error/logerr" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | type Config struct { 17 | Excludes []*Exclude `json:"excludes,omitempty"` 18 | } 19 | 20 | type Exclude struct { 21 | PolicyName string `json:"policy_name" yaml:"policy_name"` 22 | WorkflowFilePath string `json:"workflow_file_path,omitempty" yaml:"workflow_file_path"` 23 | ActionFilePath string `json:"action_file_path,omitempty" yaml:"action_file_path"` 24 | JobName string `json:"job_name,omitempty" yaml:"job_name"` 25 | ActionName string `json:"action_name,omitempty" yaml:"action_name"` 26 | StepID string `json:"step_id,omitempty" yaml:"step_id"` 27 | } 28 | 29 | func (e *Exclude) FilePath() string { 30 | if e.WorkflowFilePath != "" { 31 | return e.WorkflowFilePath 32 | } 33 | return e.ActionFilePath 34 | } 35 | 36 | func Find(fs afero.Fs) string { 37 | filePaths := []string{ 38 | "ghalint.yaml", 39 | ".ghalint.yaml", 40 | ".github/ghalint.yaml", 41 | "ghalint.yml", 42 | ".ghalint.yml", 43 | ".github/ghalint.yml", 44 | } 45 | 46 | for _, filePath := range filePaths { 47 | if _, err := fs.Stat(filePath); err == nil { 48 | return filePath 49 | } 50 | } 51 | return "" 52 | } 53 | 54 | func Read(fs afero.Fs, cfg *Config, filePath string) error { 55 | f, err := fs.Open(filePath) 56 | if err != nil { 57 | return fmt.Errorf("open a configuration file: %w", err) 58 | } 59 | defer f.Close() 60 | if err := yaml.NewDecoder(f).Decode(cfg); err != nil { 61 | err := fmt.Errorf("parse configuration file as YAML: %w", err) 62 | if errors.Is(err, io.EOF) { 63 | return logerr.WithFields(err, logrus.Fields{ //nolint:wrapcheck 64 | "reference": "https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/codes/002.md", 65 | }) 66 | } 67 | return err 68 | } 69 | return nil 70 | } 71 | 72 | func Validate(cfg *Config) error { 73 | for _, exclude := range cfg.Excludes { 74 | if err := validate(exclude); err != nil { 75 | return err 76 | } 77 | } 78 | return nil 79 | } 80 | 81 | func ConvertPath(cfg *Config) { 82 | for _, exclude := range cfg.Excludes { 83 | convertPath(exclude) 84 | } 85 | } 86 | 87 | func convertPath(exclude *Exclude) { 88 | exclude.WorkflowFilePath = filepath.FromSlash(exclude.WorkflowFilePath) 89 | exclude.ActionFilePath = filepath.FromSlash(exclude.ActionFilePath) 90 | } 91 | 92 | func validate(exclude *Exclude) error { //nolint:cyclop 93 | if exclude.PolicyName == "" { 94 | return errors.New(`policy_name is required`) 95 | } 96 | switch exclude.PolicyName { 97 | case "action_ref_should_be_full_length_commit_sha": 98 | if exclude.ActionName == "" { 99 | return errors.New(`action_name is required to exclude action_ref_should_be_full_length_commit_sha`) 100 | } 101 | if _, err := path.Match(exclude.ActionName, ""); err != nil { 102 | return fmt.Errorf("action_name must be a glob pattern: %w", logerr.WithFields(err, logrus.Fields{ 103 | "pattern_reference": "https://pkg.go.dev/path#Match", 104 | })) 105 | } 106 | case "job_secrets": 107 | if exclude.WorkflowFilePath == "" { 108 | return errors.New(`workflow_file_path is required to exclude job_secrets`) 109 | } 110 | if exclude.JobName == "" { 111 | return errors.New(`job_name is required to exclude job_secrets`) 112 | } 113 | case "deny_inherit_secrets": 114 | if exclude.WorkflowFilePath == "" { 115 | return errors.New(`workflow_file_path is required to exclude deny_inherit_secrets`) 116 | } 117 | if exclude.JobName == "" { 118 | return errors.New(`job_name is required to exclude deny_inherit_secrets`) 119 | } 120 | case "github_app_should_limit_repositories": 121 | if exclude.WorkflowFilePath == "" && exclude.ActionFilePath == "" { 122 | return errors.New(`workflow_file_path or action_file_path is required to exclude github_app_should_limit_repositories`) 123 | } 124 | if exclude.WorkflowFilePath != "" && exclude.JobName == "" { 125 | return errors.New(`job_name is required to exclude github_app_should_limit_repositories`) 126 | } 127 | if exclude.StepID == "" { 128 | return errors.New(`step_id is required to exclude github_app_should_limit_repositories`) 129 | } 130 | case "checkout_persist_credentials_should_be_false": 131 | if exclude.WorkflowFilePath == "" && exclude.ActionFilePath == "" { 132 | return errors.New(`workflow_file_path or action_file_path is required to exclude checkout_persist_credentials_should_be_false`) 133 | } 134 | if exclude.WorkflowFilePath != "" && exclude.JobName == "" { 135 | return errors.New(`job_name is required to exclude checkout_persist_credentials_should_be_false`) 136 | } 137 | default: 138 | return logerr.WithFields(errors.New(`the policy can't be excluded`), logrus.Fields{ //nolint:wrapcheck 139 | "policy_name": exclude.PolicyName, 140 | }) 141 | } 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 7 | ) 8 | 9 | func TestValidate(t *testing.T) { //nolint:funlen 10 | t.Parallel() 11 | data := []struct { 12 | name string 13 | cfg *config.Config 14 | isErr bool 15 | }{ 16 | { 17 | name: "policy_name is required", 18 | cfg: &config.Config{ 19 | Excludes: []*config.Exclude{ 20 | {}, 21 | }, 22 | }, 23 | isErr: true, 24 | }, 25 | { 26 | name: "action_name is required", 27 | cfg: &config.Config{ 28 | Excludes: []*config.Exclude{ 29 | { 30 | PolicyName: "action_ref_should_be_full_length_commit_sha", 31 | }, 32 | }, 33 | }, 34 | isErr: true, 35 | }, 36 | { 37 | name: "workflow_file_path is required", 38 | cfg: &config.Config{ 39 | Excludes: []*config.Exclude{ 40 | { 41 | PolicyName: "job_secrets", 42 | }, 43 | }, 44 | }, 45 | isErr: true, 46 | }, 47 | { 48 | name: "job_name is required", 49 | cfg: &config.Config{ 50 | Excludes: []*config.Exclude{ 51 | { 52 | PolicyName: "job_secrets", 53 | WorkflowFilePath: ".github/workflows/foo.yaml", 54 | }, 55 | }, 56 | }, 57 | isErr: true, 58 | }, 59 | { 60 | name: "disallowed policy", 61 | cfg: &config.Config{ 62 | Excludes: []*config.Exclude{ 63 | { 64 | PolicyName: "deny_read_all_permission", 65 | WorkflowFilePath: ".github/workflows/foo.yaml", 66 | JobName: "foo", 67 | }, 68 | }, 69 | }, 70 | isErr: true, 71 | }, 72 | } 73 | for _, d := range data { 74 | t.Run(d.name, func(t *testing.T) { 75 | t.Parallel() 76 | if err := config.Validate(d.cfg); err != nil { 77 | if d.isErr { 78 | return 79 | } 80 | t.Fatal(err) 81 | } 82 | if d.isErr { 83 | t.Fatal("error must be returned") 84 | } 85 | }) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /pkg/controller/act/controller.go: -------------------------------------------------------------------------------- 1 | package act 2 | 3 | import ( 4 | "github.com/spf13/afero" 5 | ) 6 | 7 | type Controller struct { 8 | fs afero.Fs 9 | } 10 | 11 | func New(fs afero.Fs) *Controller { 12 | return &Controller{ 13 | fs: fs, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pkg/controller/act/error.go: -------------------------------------------------------------------------------- 1 | package act 2 | 3 | import "github.com/sirupsen/logrus" 4 | 5 | type HasLogLevelError struct { 6 | LogLevel logrus.Level 7 | Err error 8 | } 9 | 10 | func (e *HasLogLevelError) Error() string { 11 | return e.Err.Error() 12 | } 13 | 14 | func (e *HasLogLevelError) Unwrap() error { 15 | return e.Err 16 | } 17 | 18 | func debugError(err error) *HasLogLevelError { 19 | return &HasLogLevelError{ 20 | LogLevel: logrus.DebugLevel, 21 | Err: err, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /pkg/controller/act/run.go: -------------------------------------------------------------------------------- 1 | package act 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/spf13/afero" 10 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 11 | "github.com/suzuki-shunsuke/ghalint/pkg/controller" 12 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 13 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 14 | "github.com/suzuki-shunsuke/logrus-error/logerr" 15 | ) 16 | 17 | func (c *Controller) Run(_ context.Context, logE *logrus.Entry, cfgFilePath string, args ...string) error { 18 | cfg := &config.Config{} 19 | if err := c.readConfig(cfg, cfgFilePath); err != nil { 20 | return err 21 | } 22 | 23 | filePaths, err := c.listFiles(args...) 24 | if err != nil { 25 | return fmt.Errorf("find action files: %w", err) 26 | } 27 | stepPolicies := []controller.StepPolicy{ 28 | &policy.GitHubAppShouldLimitRepositoriesPolicy{}, 29 | &policy.GitHubAppShouldLimitPermissionsPolicy{}, 30 | &policy.ActionShellIsRequiredPolicy{}, 31 | policy.NewActionRefShouldBeSHAPolicy(), 32 | &policy.CheckoutPersistCredentialShouldBeFalsePolicy{}, 33 | } 34 | failed := false 35 | for _, filePath := range filePaths { 36 | logE := logE.WithField("action_file_path", filePath) 37 | if c.validateAction(logE, cfg, stepPolicies, filePath) { 38 | failed = true 39 | } 40 | } 41 | if failed { 42 | return debugError(errors.New("some action files are invalid")) 43 | } 44 | return nil 45 | } 46 | 47 | func (c *Controller) listFiles(args ...string) ([]string, error) { 48 | if len(args) != 0 { 49 | return args, nil 50 | } 51 | 52 | patterns := []string{ 53 | "action.yaml", 54 | "action.yml", 55 | "*/action.yaml", 56 | "*/action.yml", 57 | "*/*/action.yaml", 58 | "*/*/action.yml", 59 | "*/*/*/action.yaml", 60 | "*/*/*/action.yml", 61 | } 62 | 63 | files := []string{} 64 | for _, pattern := range patterns { 65 | matches, err := afero.Glob(c.fs, pattern) 66 | if err != nil { 67 | return nil, fmt.Errorf("check if the action file exists: %w", err) 68 | } 69 | files = append(files, matches...) 70 | } 71 | return files, nil 72 | } 73 | 74 | func (c *Controller) validateAction(logE *logrus.Entry, cfg *config.Config, stepPolicies []controller.StepPolicy, filePath string) bool { 75 | action := &workflow.Action{} 76 | if err := workflow.ReadAction(c.fs, filePath, action); err != nil { 77 | logerr.WithError(logE, err).Error("read an action file") 78 | return true 79 | } 80 | 81 | stepCtx := &policy.StepContext{ 82 | FilePath: filePath, 83 | Action: action, 84 | } 85 | 86 | return c.applyStepPolicies(logE, cfg, stepCtx, action, stepPolicies) 87 | } 88 | 89 | type Policy interface { 90 | Name() string 91 | ID() string 92 | } 93 | 94 | func withPolicyReference(logE *logrus.Entry, p Policy) *logrus.Entry { 95 | return logE.WithFields(logrus.Fields{ 96 | "policy_name": p.Name(), 97 | "reference": fmt.Sprintf("https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/policies/%s.md", p.ID()), 98 | }) 99 | } 100 | 101 | func (c *Controller) applyStepPolicies(logE *logrus.Entry, cfg *config.Config, stepCtx *policy.StepContext, action *workflow.Action, stepPolicies []controller.StepPolicy) bool { 102 | failed := false 103 | for _, stepPolicy := range stepPolicies { 104 | logE := withPolicyReference(logE, stepPolicy) 105 | if c.applyStepPolicy(logE, cfg, stepCtx, action, stepPolicy) { 106 | failed = true 107 | } 108 | } 109 | return failed 110 | } 111 | 112 | func (c *Controller) applyStepPolicy(logE *logrus.Entry, cfg *config.Config, stepCtx *policy.StepContext, action *workflow.Action, stepPolicy controller.StepPolicy) bool { 113 | failed := false 114 | for _, step := range action.Runs.Steps { 115 | logE := logE 116 | if step.ID != "" { 117 | logE = logE.WithField("step_id", step.ID) 118 | } 119 | if step.Name != "" { 120 | logE = logE.WithField("step_name", step.Name) 121 | } 122 | if err := stepPolicy.ApplyStep(logE, cfg, stepCtx, step); err != nil { 123 | if err.Error() != "" { 124 | logerr.WithError(logE, err).Error("the step violates policies") 125 | } 126 | failed = true 127 | } 128 | } 129 | return failed 130 | } 131 | 132 | func (c *Controller) readConfig(cfg *config.Config, cfgFilePath string) error { 133 | if cfgFilePath == "" { 134 | if c := config.Find(c.fs); c != "" { 135 | cfgFilePath = c 136 | } 137 | } 138 | if cfgFilePath != "" { 139 | if err := config.Read(c.fs, cfg, cfgFilePath); err != nil { 140 | return fmt.Errorf("read a configuration file: %w", logerr.WithFields(err, logrus.Fields{ 141 | "config_file": cfgFilePath, 142 | })) 143 | } 144 | if err := config.Validate(cfg); err != nil { 145 | return fmt.Errorf("validate a configuration file: %w", logerr.WithFields(err, logrus.Fields{ 146 | "config_file": cfgFilePath, 147 | })) 148 | } 149 | config.ConvertPath(cfg) 150 | } 151 | return nil 152 | } 153 | -------------------------------------------------------------------------------- /pkg/controller/controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | "github.com/spf13/afero" 6 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | type Controller struct { 12 | fs afero.Fs 13 | } 14 | 15 | func New(fs afero.Fs) *Controller { 16 | return &Controller{ 17 | fs: fs, 18 | } 19 | } 20 | 21 | type WorkflowPolicy interface { 22 | Name() string 23 | ID() string 24 | ApplyWorkflow(logE *logrus.Entry, cfg *config.Config, wfCtx *policy.WorkflowContext, wf *workflow.Workflow) error 25 | } 26 | 27 | type JobPolicy interface { 28 | Name() string 29 | ID() string 30 | ApplyJob(logE *logrus.Entry, cfg *config.Config, jobCtx *policy.JobContext, job *workflow.Job) error 31 | } 32 | 33 | type StepPolicy interface { 34 | Name() string 35 | ID() string 36 | ApplyStep(logE *logrus.Entry, cfg *config.Config, stepCtx *policy.StepContext, step *workflow.Step) error 37 | } 38 | -------------------------------------------------------------------------------- /pkg/controller/error.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import "github.com/sirupsen/logrus" 4 | 5 | type HasLogLevelError struct { 6 | LogLevel logrus.Level 7 | Err error 8 | } 9 | 10 | func (e *HasLogLevelError) Error() string { 11 | return e.Err.Error() 12 | } 13 | 14 | func (e *HasLogLevelError) Unwrap() error { 15 | return e.Err 16 | } 17 | 18 | func debugError(err error) *HasLogLevelError { 19 | return &HasLogLevelError{ 20 | LogLevel: logrus.DebugLevel, 21 | Err: err, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /pkg/controller/run.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 10 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 11 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 12 | "github.com/suzuki-shunsuke/logrus-error/logerr" 13 | ) 14 | 15 | func (c *Controller) Run(_ context.Context, logE *logrus.Entry, cfgFilePath string) error { 16 | cfg := &config.Config{} 17 | if err := c.readConfig(cfg, cfgFilePath); err != nil { 18 | return err 19 | } 20 | 21 | filePaths, err := workflow.List(c.fs) 22 | if err != nil { 23 | return fmt.Errorf("find workflow files: %w", err) 24 | } 25 | wfPolicies := []WorkflowPolicy{ 26 | policy.NewWorkflowSecretsPolicy(), 27 | } 28 | jobPolicies := []JobPolicy{ 29 | &policy.JobPermissionsPolicy{}, 30 | &policy.JobTimeoutMinutesIsRequiredPolicy{}, 31 | policy.NewJobSecretsPolicy(), 32 | &policy.DenyInheritSecretsPolicy{}, 33 | &policy.DenyJobContainerLatestImagePolicy{}, 34 | policy.NewActionRefShouldBeSHAPolicy(), 35 | &policy.DenyReadAllPermissionPolicy{}, 36 | &policy.DenyWriteAllPermissionPolicy{}, 37 | } 38 | stepPolicies := []StepPolicy{ 39 | &policy.GitHubAppShouldLimitRepositoriesPolicy{}, 40 | &policy.GitHubAppShouldLimitPermissionsPolicy{}, 41 | policy.NewActionRefShouldBeSHAPolicy(), 42 | &policy.CheckoutPersistCredentialShouldBeFalsePolicy{}, 43 | } 44 | failed := false 45 | for _, filePath := range filePaths { 46 | logE := logE.WithField("workflow_file_path", filePath) 47 | if c.validateWorkflow(logE, cfg, wfPolicies, jobPolicies, stepPolicies, filePath) { 48 | failed = true 49 | } 50 | } 51 | if failed { 52 | return debugError(errors.New("some workflow files are invalid")) 53 | } 54 | return nil 55 | } 56 | 57 | func (c *Controller) validateWorkflow(logE *logrus.Entry, cfg *config.Config, wfPolicies []WorkflowPolicy, jobPolicies []JobPolicy, stepPolicies []StepPolicy, filePath string) bool { 58 | wf := &workflow.Workflow{ 59 | FilePath: filePath, 60 | } 61 | if err := workflow.Read(c.fs, filePath, wf); err != nil { 62 | logerr.WithError(logE, err).Error("read a workflow file") 63 | return true 64 | } 65 | 66 | wfCtx := &policy.WorkflowContext{ 67 | FilePath: filePath, 68 | Workflow: wf, 69 | } 70 | 71 | failed := false 72 | for _, wfPolicy := range wfPolicies { 73 | logE := withPolicyReference(logE, wfPolicy) 74 | if err := wfPolicy.ApplyWorkflow(logE, cfg, wfCtx, wf); err != nil { 75 | if err.Error() != "" { 76 | logerr.WithError(logE, err).Error("the workflow violates policies") 77 | } 78 | failed = true 79 | continue 80 | } 81 | } 82 | 83 | if c.applyJobPolicies(logE, cfg, wfCtx, jobPolicies) { 84 | failed = true 85 | } 86 | 87 | if c.applyStepPolicies(logE, cfg, wfCtx, wf.Jobs, stepPolicies) { 88 | failed = true 89 | } 90 | 91 | return failed 92 | } 93 | 94 | type Policy interface { 95 | Name() string 96 | ID() string 97 | } 98 | 99 | func withPolicyReference(logE *logrus.Entry, p Policy) *logrus.Entry { 100 | return logE.WithFields(logrus.Fields{ 101 | "policy_name": p.Name(), 102 | "reference": fmt.Sprintf("https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/policies/%s.md", p.ID()), 103 | }) 104 | } 105 | 106 | func (c *Controller) applyJobPolicies(logE *logrus.Entry, cfg *config.Config, wfCtx *policy.WorkflowContext, jobPolicies []JobPolicy) bool { 107 | failed := false 108 | for _, jobPolicy := range jobPolicies { 109 | logE := withPolicyReference(logE, jobPolicy) 110 | if c.applyJobPolicy(logE, cfg, wfCtx, jobPolicy) { 111 | failed = true 112 | } 113 | } 114 | return failed 115 | } 116 | 117 | func (c *Controller) applyJobPolicy(logE *logrus.Entry, cfg *config.Config, wfCtx *policy.WorkflowContext, jobPolicy JobPolicy) bool { 118 | failed := false 119 | for jobName, job := range wfCtx.Workflow.Jobs { 120 | jobCtx := &policy.JobContext{ 121 | Workflow: wfCtx, 122 | Name: jobName, 123 | } 124 | logE := logE.WithField("job_name", jobName) 125 | if err := jobPolicy.ApplyJob(logE, cfg, jobCtx, job); err != nil { 126 | failed = true 127 | if err.Error() != "" { 128 | logerr.WithError(logE, err).Error("the job violates policies") 129 | } 130 | } 131 | } 132 | return failed 133 | } 134 | 135 | func (c *Controller) applyStepPolicies(logE *logrus.Entry, cfg *config.Config, wfCtx *policy.WorkflowContext, jobs map[string]*workflow.Job, stepPolicies []StepPolicy) bool { 136 | failed := false 137 | for _, stepPolicy := range stepPolicies { 138 | logE := withPolicyReference(logE, stepPolicy) 139 | if c.applyStepPolicy(logE, cfg, wfCtx, jobs, stepPolicy) { 140 | failed = true 141 | } 142 | } 143 | return failed 144 | } 145 | 146 | func (c *Controller) applyStepPolicy(logE *logrus.Entry, cfg *config.Config, wfCtx *policy.WorkflowContext, jobs map[string]*workflow.Job, stepPolicy StepPolicy) bool { 147 | failed := false 148 | for jobName, job := range jobs { 149 | stepCtx := &policy.StepContext{ 150 | FilePath: wfCtx.FilePath, 151 | Job: &policy.JobContext{ 152 | Name: jobName, 153 | Workflow: wfCtx, 154 | Job: job, 155 | }, 156 | } 157 | logE := logE.WithField("job_name", jobName) 158 | for _, step := range job.Steps { 159 | logE := logE 160 | if step.ID != "" { 161 | logE = logE.WithField("step_id", step.ID) 162 | } 163 | if step.Name != "" { 164 | logE = logE.WithField("step_name", step.Name) 165 | } 166 | if err := stepPolicy.ApplyStep(logE, cfg, stepCtx, step); err != nil { 167 | if err.Error() != "" { 168 | logerr.WithError(logE, err).Error("the step violates policies") 169 | } 170 | failed = true 171 | } 172 | } 173 | } 174 | return failed 175 | } 176 | 177 | func (c *Controller) readConfig(cfg *config.Config, cfgFilePath string) error { 178 | if cfgFilePath == "" { 179 | if c := config.Find(c.fs); c != "" { 180 | cfgFilePath = c 181 | } 182 | } 183 | if cfgFilePath != "" { 184 | if err := config.Read(c.fs, cfg, cfgFilePath); err != nil { 185 | return fmt.Errorf("read a configuration file: %w", logerr.WithFields(err, logrus.Fields{ 186 | "config_file": cfgFilePath, 187 | })) 188 | } 189 | if err := config.Validate(cfg); err != nil { 190 | return fmt.Errorf("validate a configuration file: %w", logerr.WithFields(err, logrus.Fields{ 191 | "config_file": cfgFilePath, 192 | })) 193 | } 194 | config.ConvertPath(cfg) 195 | } 196 | return nil 197 | } 198 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | ) 6 | 7 | func New(version string) *logrus.Entry { 8 | return logrus.WithFields(logrus.Fields{ 9 | "version": version, 10 | "program": "ghalint", 11 | }) 12 | } 13 | 14 | func SetLevel(level string, logE *logrus.Entry) { 15 | if level == "" { 16 | return 17 | } 18 | lvl, err := logrus.ParseLevel(level) 19 | if err != nil { 20 | logE.WithField("log_level", level).WithError(err).Error("the log level is invalid") 21 | return 22 | } 23 | logE.Logger.Level = lvl 24 | } 25 | 26 | func SetColor(color string, logE *logrus.Entry) { 27 | switch color { 28 | case "", "auto": 29 | return 30 | case "always": 31 | logrus.SetFormatter(&logrus.TextFormatter{ 32 | ForceColors: true, 33 | }) 34 | case "never": 35 | logrus.SetFormatter(&logrus.TextFormatter{ 36 | DisableColors: true, 37 | }) 38 | default: 39 | logE.WithField("log_color", color).Error("log_color is invalid") 40 | return 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /pkg/log/log_test.go: -------------------------------------------------------------------------------- 1 | package log_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/suzuki-shunsuke/ghalint/pkg/log" 7 | ) 8 | 9 | func TestNew(t *testing.T) { 10 | t.Parallel() 11 | if logE := log.New("v1.6.0"); logE == nil { 12 | t.Fatal("logE must not be nil") 13 | } 14 | } 15 | 16 | func TestSetLevel(t *testing.T) { 17 | t.Parallel() 18 | logE := log.New("v1.6.0") 19 | log.SetLevel("debug", logE) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/log/log_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package log 5 | 6 | import ( 7 | "github.com/mattn/go-colorable" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | func init() { 12 | logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true}) 13 | logrus.SetOutput(colorable.NewColorableStdout()) 14 | } 15 | -------------------------------------------------------------------------------- /pkg/policy/action_ref_should_be_full_length_commit_sha_policy.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "errors" 5 | "path" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 11 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 12 | "github.com/suzuki-shunsuke/logrus-error/logerr" 13 | ) 14 | 15 | type ActionRefShouldBeSHAPolicy struct { 16 | sha1Pattern *regexp.Regexp 17 | sha256Pattern *regexp.Regexp 18 | } 19 | 20 | func NewActionRefShouldBeSHAPolicy() *ActionRefShouldBeSHAPolicy { 21 | return &ActionRefShouldBeSHAPolicy{ 22 | sha1Pattern: regexp.MustCompile(`\b[0-9a-f]{40}\b`), 23 | sha256Pattern: regexp.MustCompile(`\b[0-9a-f]{64}\b`), 24 | } 25 | } 26 | 27 | func (p *ActionRefShouldBeSHAPolicy) Name() string { 28 | return "action_ref_should_be_full_length_commit_sha" 29 | } 30 | 31 | func (p *ActionRefShouldBeSHAPolicy) ID() string { 32 | return "008" 33 | } 34 | 35 | func (p *ActionRefShouldBeSHAPolicy) ApplyJob(_ *logrus.Entry, cfg *config.Config, _ *JobContext, job *workflow.Job) error { 36 | return p.apply(cfg, job.Uses) 37 | } 38 | 39 | func (p *ActionRefShouldBeSHAPolicy) ApplyStep(_ *logrus.Entry, cfg *config.Config, _ *StepContext, step *workflow.Step) error { 40 | return p.apply(cfg, step.Uses) 41 | } 42 | 43 | func (p *ActionRefShouldBeSHAPolicy) apply(cfg *config.Config, uses string) error { 44 | action := p.checkUses(uses) 45 | if action == "" || p.excluded(action, cfg.Excludes) { 46 | return nil 47 | } 48 | return logerr.WithFields(errors.New("action ref should be full length SHA"), logrus.Fields{ //nolint:wrapcheck 49 | "action": action, 50 | }) 51 | } 52 | 53 | func (p *ActionRefShouldBeSHAPolicy) checkUses(uses string) string { 54 | if uses == "" { 55 | return "" 56 | } 57 | if ref, ok := strings.CutPrefix(uses, "docker://"); ok { 58 | repoAndTag, digest, hasDigest := strings.Cut(ref, "@sha256:") 59 | if hasDigest && p.sha256Pattern.MatchString(digest) { 60 | return "" 61 | } 62 | repo := repoAndTag 63 | lastColon := strings.LastIndex(repoAndTag, ":") 64 | lastSlash := strings.LastIndex(repoAndTag, "/") 65 | if lastColon != -1 && lastColon > lastSlash { 66 | repo = repoAndTag[:lastColon] 67 | } 68 | return "docker://" + repo 69 | } 70 | action, tag, ok := strings.Cut(uses, "@") 71 | if !ok { 72 | return "" 73 | } 74 | if p.sha1Pattern.MatchString(tag) { 75 | return "" 76 | } 77 | return action 78 | } 79 | 80 | func (p *ActionRefShouldBeSHAPolicy) excluded(action string, excludes []*config.Exclude) bool { 81 | for _, exclude := range excludes { 82 | if exclude.PolicyName != p.Name() { 83 | continue 84 | } 85 | if f, _ := path.Match(exclude.ActionName, action); f { 86 | return true 87 | } 88 | } 89 | return false 90 | } 91 | -------------------------------------------------------------------------------- /pkg/policy/action_ref_should_be_full_length_commit_sha_policy_test.go: -------------------------------------------------------------------------------- 1 | package policy_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 9 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 10 | ) 11 | 12 | func TestActionRefShouldBeSHAPolicy_ApplyJob(t *testing.T) { //nolint:funlen 13 | t.Parallel() 14 | data := []struct { 15 | name string 16 | cfg *config.Config 17 | job *workflow.Job 18 | isErr bool 19 | }{ 20 | { 21 | name: "exclude", 22 | cfg: &config.Config{ 23 | Excludes: []*config.Exclude{ 24 | { 25 | PolicyName: "action_ref_should_be_full_length_commit_sha", 26 | ActionName: "slsa-framework/slsa-github-generator", 27 | }, 28 | { 29 | PolicyName: "action_ref_should_be_full_length_commit_sha", 30 | ActionName: "suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml", 31 | }, 32 | }, 33 | }, 34 | job: &workflow.Job{ 35 | Steps: []*workflow.Step{ 36 | { 37 | Uses: "slsa-framework/slsa-github-generator@v1.5.0", 38 | }, 39 | }, 40 | }, 41 | }, 42 | { 43 | name: "job error", 44 | isErr: true, 45 | cfg: &config.Config{}, 46 | job: &workflow.Job{ 47 | Uses: "suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@v0.4.4", 48 | }, 49 | }, 50 | { 51 | name: "docker image with digest", 52 | cfg: &config.Config{}, 53 | job: &workflow.Job{ 54 | Uses: "docker://rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9", 55 | }, 56 | }, 57 | { 58 | name: "docker image with digest (no tag)", 59 | cfg: &config.Config{}, 60 | job: &workflow.Job{ 61 | Uses: "docker://rhysd/actionlint@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9", 62 | }, 63 | }, 64 | { 65 | name: "docker image with port and digest", 66 | cfg: &config.Config{}, 67 | job: &workflow.Job{ 68 | Uses: "docker://registry.example.com:5000/myimage:1.0.0@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9", 69 | }, 70 | }, 71 | { 72 | name: "docker image with tag", 73 | isErr: true, 74 | cfg: &config.Config{}, 75 | job: &workflow.Job{ 76 | Uses: "docker://rhysd/actionlint:latest", 77 | }, 78 | }, 79 | { 80 | name: "docker image with port and tag", 81 | isErr: true, 82 | cfg: &config.Config{}, 83 | job: &workflow.Job{ 84 | Uses: "docker://registry.example.com:5000/myimage:latest", 85 | }, 86 | }, 87 | { 88 | name: "exclude docker image with tag", 89 | cfg: &config.Config{ 90 | Excludes: []*config.Exclude{ 91 | { 92 | PolicyName: "action_ref_should_be_full_length_commit_sha", 93 | ActionName: "docker://rhysd/actionlint", 94 | }, 95 | }, 96 | }, 97 | job: &workflow.Job{ 98 | Uses: "docker://rhysd/actionlint:latest", 99 | }, 100 | }, 101 | { 102 | name: "exclude docker image with port and tag", 103 | cfg: &config.Config{ 104 | Excludes: []*config.Exclude{ 105 | { 106 | PolicyName: "action_ref_should_be_full_length_commit_sha", 107 | ActionName: "docker://registry.example.com:5000/myimage", 108 | }, 109 | }, 110 | }, 111 | job: &workflow.Job{ 112 | Uses: "docker://registry.example.com:5000/myimage:latest", 113 | }, 114 | }, 115 | } 116 | p := policy.NewActionRefShouldBeSHAPolicy() 117 | logE := logrus.NewEntry(logrus.New()) 118 | for _, d := range data { 119 | t.Run(d.name, func(t *testing.T) { 120 | t.Parallel() 121 | if err := p.ApplyJob(logE, d.cfg, nil, d.job); err != nil { 122 | if d.isErr { 123 | return 124 | } 125 | t.Fatal(err) 126 | } 127 | if d.isErr { 128 | t.Fatal("error must be returned") 129 | } 130 | }) 131 | } 132 | } 133 | 134 | func TestActionRefShouldBeSHAPolicy_ApplyStep(t *testing.T) { //nolint:funlen 135 | t.Parallel() 136 | data := []struct { 137 | name string 138 | cfg *config.Config 139 | step *workflow.Step 140 | isErr bool 141 | }{ 142 | { 143 | name: "exclude", 144 | cfg: &config.Config{ 145 | Excludes: []*config.Exclude{ 146 | { 147 | PolicyName: "action_ref_should_be_full_length_commit_sha", 148 | ActionName: "slsa-framework/slsa-github-generator", 149 | }, 150 | { 151 | PolicyName: "action_ref_should_be_full_length_commit_sha", 152 | ActionName: "suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml", 153 | }, 154 | }, 155 | }, 156 | step: &workflow.Step{ 157 | Uses: "slsa-framework/slsa-github-generator@v1.5.0", 158 | }, 159 | }, 160 | { 161 | name: "exclude with glob pattern", 162 | cfg: &config.Config{ 163 | Excludes: []*config.Exclude{ 164 | { 165 | PolicyName: "action_ref_should_be_full_length_commit_sha", 166 | ActionName: "slsa-framework/*", 167 | }, 168 | }, 169 | }, 170 | step: &workflow.Step{ 171 | Uses: "slsa-framework/slsa-github-generator@v1.5.0", 172 | }, 173 | }, 174 | { 175 | name: "step error", 176 | isErr: true, 177 | cfg: &config.Config{ 178 | Excludes: []*config.Exclude{ 179 | { 180 | PolicyName: "action_ref_should_be_full_length_commit_sha", 181 | ActionName: "actions/checkout", 182 | }, 183 | }, 184 | }, 185 | step: &workflow.Step{ 186 | Uses: "slsa-framework/slsa-github-generator@v1.5.0", 187 | ID: "generate", 188 | Name: "Generate SLSA Provenance", 189 | }, 190 | }, 191 | { 192 | name: "docker image with digest", 193 | cfg: &config.Config{}, 194 | step: &workflow.Step{ 195 | Uses: "docker://rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9", 196 | }, 197 | }, 198 | { 199 | name: "docker image with digest (no tag)", 200 | cfg: &config.Config{}, 201 | step: &workflow.Step{ 202 | Uses: "docker://rhysd/actionlint@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9", 203 | }, 204 | }, 205 | { 206 | name: "docker image with port and digest", 207 | cfg: &config.Config{}, 208 | step: &workflow.Step{ 209 | Uses: "docker://registry.example.com:5000/myimage:1.0.0@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9", 210 | }, 211 | }, 212 | { 213 | name: "docker image with tag", 214 | isErr: true, 215 | cfg: &config.Config{}, 216 | step: &workflow.Step{ 217 | Uses: "docker://rhysd/actionlint:latest", 218 | }, 219 | }, 220 | { 221 | name: "docker image with port and tag", 222 | isErr: true, 223 | cfg: &config.Config{}, 224 | step: &workflow.Step{ 225 | Uses: "docker://registry.example.com:5000/myimage:latest", 226 | }, 227 | }, 228 | { 229 | name: "exclude docker image with tag", 230 | cfg: &config.Config{ 231 | Excludes: []*config.Exclude{ 232 | { 233 | PolicyName: "action_ref_should_be_full_length_commit_sha", 234 | ActionName: "docker://rhysd/actionlint", 235 | }, 236 | }, 237 | }, 238 | step: &workflow.Step{ 239 | Uses: "docker://rhysd/actionlint:latest", 240 | }, 241 | }, 242 | { 243 | name: "exclude docker image with port and tag", 244 | cfg: &config.Config{ 245 | Excludes: []*config.Exclude{ 246 | { 247 | PolicyName: "action_ref_should_be_full_length_commit_sha", 248 | ActionName: "docker://registry.example.com:5000/myimage", 249 | }, 250 | }, 251 | }, 252 | step: &workflow.Step{ 253 | Uses: "docker://registry.example.com:5000/myimage:latest", 254 | }, 255 | }, 256 | } 257 | p := policy.NewActionRefShouldBeSHAPolicy() 258 | logE := logrus.NewEntry(logrus.New()) 259 | for _, d := range data { 260 | t.Run(d.name, func(t *testing.T) { 261 | t.Parallel() 262 | if err := p.ApplyStep(logE, d.cfg, nil, d.step); err != nil { 263 | if d.isErr { 264 | return 265 | } 266 | t.Fatal(err) 267 | } 268 | if d.isErr { 269 | t.Fatal("error must be returned") 270 | } 271 | }) 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /pkg/policy/action_shell_is_required.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | type ActionShellIsRequiredPolicy struct{} 12 | 13 | func (p *ActionShellIsRequiredPolicy) Name() string { 14 | return "action_shell_is_required" 15 | } 16 | 17 | func (p *ActionShellIsRequiredPolicy) ID() string { 18 | return "011" 19 | } 20 | 21 | func (p *ActionShellIsRequiredPolicy) ApplyStep(_ *logrus.Entry, _ *config.Config, _ *StepContext, step *workflow.Step) error { 22 | if step.Run != "" && step.Shell == "" { 23 | return errors.New("shell is required if run is set") 24 | } 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /pkg/policy/action_shell_is_required_test.go: -------------------------------------------------------------------------------- 1 | package policy_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | func TestActionShellIsRequiredPolicy_ApplyStep(t *testing.T) { 12 | t.Parallel() 13 | data := []struct { 14 | name string 15 | step *workflow.Step 16 | isErr bool 17 | }{ 18 | { 19 | name: "pass", 20 | step: &workflow.Step{ 21 | Run: "echo hello", 22 | Shell: "bash", 23 | }, 24 | }, 25 | { 26 | name: "step error", 27 | isErr: true, 28 | step: &workflow.Step{ 29 | Run: "echo hello", 30 | }, 31 | }, 32 | } 33 | p := &policy.ActionShellIsRequiredPolicy{} 34 | logE := logrus.NewEntry(logrus.New()) 35 | for _, d := range data { 36 | t.Run(d.name, func(t *testing.T) { 37 | t.Parallel() 38 | if err := p.ApplyStep(logE, nil, nil, d.step); err != nil { 39 | if d.isErr { 40 | return 41 | } 42 | t.Fatal(err) 43 | } 44 | if d.isErr { 45 | t.Fatal("error must be returned") 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pkg/policy/checkout_persist_credentials_should_be_false.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 9 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 10 | ) 11 | 12 | type CheckoutPersistCredentialShouldBeFalsePolicy struct{} 13 | 14 | func (p *CheckoutPersistCredentialShouldBeFalsePolicy) Name() string { 15 | return "checkout_persist_credentials_should_be_false" 16 | } 17 | 18 | func (p *CheckoutPersistCredentialShouldBeFalsePolicy) ID() string { 19 | return "013" 20 | } 21 | 22 | func (p *CheckoutPersistCredentialShouldBeFalsePolicy) ApplyStep(_ *logrus.Entry, cfg *config.Config, stepCtx *StepContext, step *workflow.Step) error { 23 | if p.excluded(stepCtx, cfg.Excludes) { 24 | return nil 25 | } 26 | if !strings.HasPrefix(step.Uses, "actions/checkout@") { 27 | return nil 28 | } 29 | f, ok := step.With["persist-credentials"] 30 | if !ok { 31 | return errors.New("persist-credentials should be false") 32 | } 33 | if f != "false" { 34 | return errors.New("persist-credentials should be false") 35 | } 36 | return nil 37 | } 38 | 39 | func (p *CheckoutPersistCredentialShouldBeFalsePolicy) excluded(stepCtx *StepContext, excludes []*config.Exclude) bool { 40 | for _, exclude := range excludes { 41 | if exclude.PolicyName != p.Name() { 42 | continue 43 | } 44 | if stepCtx.Action != nil { 45 | if exclude.ActionFilePath != stepCtx.FilePath { 46 | continue 47 | } 48 | return true 49 | } 50 | if exclude.JobName != stepCtx.Job.Name { 51 | continue 52 | } 53 | if exclude.WorkflowFilePath != stepCtx.Job.Workflow.FilePath { 54 | continue 55 | } 56 | return true 57 | } 58 | return false 59 | } 60 | -------------------------------------------------------------------------------- /pkg/policy/checkout_persist_credentials_should_be_false_test.go: -------------------------------------------------------------------------------- 1 | package policy_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 9 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 10 | ) 11 | 12 | func TestCheckoutPersistCredentialShouldBeFalsePolicy_ApplyStep(t *testing.T) { //nolint:funlen 13 | t.Parallel() 14 | data := []struct { 15 | name string 16 | cfg *config.Config 17 | step *workflow.Step 18 | stepCtx *policy.StepContext 19 | isErr bool 20 | }{ 21 | { 22 | name: "exclude", 23 | cfg: &config.Config{ 24 | Excludes: []*config.Exclude{ 25 | { 26 | PolicyName: "checkout_persist_credentials_should_be_false", 27 | WorkflowFilePath: ".github/workflows/test.yml", 28 | JobName: "test", 29 | }, 30 | }, 31 | }, 32 | stepCtx: &policy.StepContext{ 33 | Job: &policy.JobContext{ 34 | Name: "test", 35 | Workflow: &policy.WorkflowContext{ 36 | FilePath: ".github/workflows/test.yml", 37 | }, 38 | }, 39 | }, 40 | step: &workflow.Step{ 41 | Uses: "actions/checkout@v4", 42 | }, 43 | }, 44 | { 45 | name: "persist-credentials is not set", 46 | cfg: &config.Config{ 47 | Excludes: []*config.Exclude{ 48 | { 49 | PolicyName: "checkout_persist_credentials_should_be_false", 50 | JobName: "test-2", 51 | WorkflowFilePath: ".github/workflows/test.yml", 52 | }, 53 | }, 54 | }, 55 | stepCtx: &policy.StepContext{ 56 | Job: &policy.JobContext{ 57 | Name: "test", 58 | Workflow: &policy.WorkflowContext{ 59 | FilePath: ".github/workflows/test.yml", 60 | }, 61 | }, 62 | }, 63 | step: &workflow.Step{ 64 | Uses: "actions/checkout@v4", 65 | }, 66 | isErr: true, 67 | }, 68 | { 69 | name: "persist-credentials is true", 70 | cfg: &config.Config{ 71 | Excludes: []*config.Exclude{ 72 | { 73 | PolicyName: "checkout_persist_credentials_should_be_false", 74 | JobName: "test-2", 75 | }, 76 | }, 77 | }, 78 | stepCtx: &policy.StepContext{ 79 | Job: &policy.JobContext{ 80 | Name: "test", 81 | Workflow: &policy.WorkflowContext{ 82 | FilePath: ".github/workflows/test.yml", 83 | }, 84 | }, 85 | }, 86 | step: &workflow.Step{ 87 | Uses: "actions/checkout@v4", 88 | With: map[string]string{ 89 | "persist-credentials": "true", 90 | }, 91 | }, 92 | isErr: true, 93 | }, 94 | { 95 | name: "persist-credentials is false", 96 | cfg: &config.Config{ 97 | Excludes: []*config.Exclude{ 98 | { 99 | PolicyName: "checkout_persist_credentials_should_be_false", 100 | JobName: "test-2", 101 | }, 102 | }, 103 | }, 104 | stepCtx: &policy.StepContext{ 105 | Job: &policy.JobContext{ 106 | Name: "test", 107 | Workflow: &policy.WorkflowContext{ 108 | FilePath: ".github/workflows/test.yml", 109 | }, 110 | }, 111 | }, 112 | step: &workflow.Step{ 113 | Uses: "actions/checkout@v4", 114 | With: map[string]string{ 115 | "persist-credentials": "false", 116 | }, 117 | }, 118 | }, 119 | { 120 | name: "not checkout", 121 | cfg: &config.Config{ 122 | Excludes: []*config.Exclude{}, 123 | }, 124 | stepCtx: &policy.StepContext{ 125 | Job: &policy.JobContext{ 126 | Name: "test", 127 | Workflow: &policy.WorkflowContext{ 128 | FilePath: ".github/workflows/test.yml", 129 | }, 130 | }, 131 | }, 132 | step: &workflow.Step{ 133 | Uses: "actions/cache@v4", 134 | }, 135 | }, 136 | } 137 | p := &policy.CheckoutPersistCredentialShouldBeFalsePolicy{} 138 | logE := logrus.NewEntry(logrus.New()) 139 | for _, d := range data { 140 | t.Run(d.name, func(t *testing.T) { 141 | t.Parallel() 142 | if err := p.ApplyStep(logE, d.cfg, d.stepCtx, d.step); err != nil { 143 | if d.isErr { 144 | return 145 | } 146 | t.Fatal(err) 147 | } 148 | if d.isErr { 149 | t.Fatal("error must be returned") 150 | } 151 | }) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /pkg/policy/context.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 4 | 5 | type WorkflowContext struct { 6 | FilePath string 7 | Workflow *workflow.Workflow 8 | } 9 | 10 | type JobContext struct { 11 | Name string 12 | Workflow *WorkflowContext 13 | Job *workflow.Job 14 | } 15 | 16 | type StepContext struct { 17 | FilePath string 18 | Action *workflow.Action 19 | Job *JobContext 20 | } 21 | -------------------------------------------------------------------------------- /pkg/policy/deny_inherit_secrets.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | type DenyInheritSecretsPolicy struct{} 12 | 13 | func (p *DenyInheritSecretsPolicy) Name() string { 14 | return "deny_inherit_secrets" 15 | } 16 | 17 | func (p *DenyInheritSecretsPolicy) ID() string { 18 | return "004" 19 | } 20 | 21 | func (p *DenyInheritSecretsPolicy) ApplyJob(_ *logrus.Entry, cfg *config.Config, jobCtx *JobContext, job *workflow.Job) error { 22 | if checkExcludes(p.Name(), jobCtx, cfg) { 23 | return nil 24 | } 25 | if job.Secrets.Inherit() { 26 | return errors.New("`secrets: inherit` should not be used. Only required secrets should be passed explicitly") 27 | } 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /pkg/policy/deny_inherit_secrets_test.go: -------------------------------------------------------------------------------- 1 | //nolint:funlen 2 | package policy_test 3 | 4 | import ( 5 | "testing" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 9 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 10 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | func TestDenyInheritSecretsPolicy_ApplyJob(t *testing.T) { 15 | t.Parallel() 16 | data := []struct { 17 | name string 18 | job string 19 | cfg *config.Config 20 | jobCtx *policy.JobContext 21 | isErr bool 22 | }{ 23 | { 24 | name: "exclude", 25 | cfg: &config.Config{ 26 | Excludes: []*config.Exclude{ 27 | { 28 | PolicyName: "deny_inherit_secrets", 29 | WorkflowFilePath: ".github/workflows/test.yaml", 30 | JobName: "foo", 31 | }, 32 | }, 33 | }, 34 | jobCtx: &policy.JobContext{ 35 | Workflow: &policy.WorkflowContext{ 36 | FilePath: ".github/workflows/test.yaml", 37 | }, 38 | Name: "foo", 39 | }, 40 | job: `secrets: inherit`, 41 | }, 42 | { 43 | name: "not exclude", 44 | cfg: &config.Config{ 45 | Excludes: []*config.Exclude{ 46 | { 47 | PolicyName: "deny_inherit_secrets", 48 | WorkflowFilePath: ".github/workflows/test.yaml", 49 | JobName: "bar", 50 | }, 51 | }, 52 | }, 53 | jobCtx: &policy.JobContext{ 54 | Workflow: &policy.WorkflowContext{ 55 | FilePath: ".github/workflows/test.yaml", 56 | }, 57 | Name: "foo", 58 | }, 59 | job: `secrets: inherit`, 60 | isErr: true, 61 | }, 62 | { 63 | name: "error", 64 | job: `secrets: inherit`, 65 | cfg: &config.Config{}, 66 | jobCtx: &policy.JobContext{ 67 | Workflow: &policy.WorkflowContext{ 68 | FilePath: ".github/workflows/test.yaml", 69 | }, 70 | Name: "foo", 71 | }, 72 | isErr: true, 73 | }, 74 | { 75 | name: "pass", 76 | cfg: &config.Config{}, 77 | jobCtx: &policy.JobContext{ 78 | Workflow: &policy.WorkflowContext{ 79 | FilePath: ".github/workflows/test.yaml", 80 | }, 81 | Name: "foo", 82 | }, 83 | job: `secrets: 84 | foo: ${{secrets.API_KEY}}`, 85 | }, 86 | } 87 | p := &policy.DenyInheritSecretsPolicy{} 88 | logE := logrus.NewEntry(logrus.New()) 89 | for _, d := range data { 90 | t.Run(d.name, func(t *testing.T) { 91 | t.Parallel() 92 | job := &workflow.Job{} 93 | if err := yaml.Unmarshal([]byte(d.job), job); err != nil { 94 | t.Fatal(err) 95 | } 96 | if err := p.ApplyJob(logE, d.cfg, d.jobCtx, job); err != nil { 97 | if d.isErr { 98 | return 99 | } 100 | t.Fatal(err) 101 | } 102 | if d.isErr { 103 | t.Fatal("error must be returned") 104 | } 105 | }) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /pkg/policy/deny_job_container_latest_image.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 9 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 10 | ) 11 | 12 | type DenyJobContainerLatestImagePolicy struct{} 13 | 14 | func (p *DenyJobContainerLatestImagePolicy) Name() string { 15 | return "deny_job_container_latest_image" 16 | } 17 | 18 | func (p *DenyJobContainerLatestImagePolicy) ID() string { 19 | return "007" 20 | } 21 | 22 | func (p *DenyJobContainerLatestImagePolicy) ApplyJob(_ *logrus.Entry, _ *config.Config, _ *JobContext, job *workflow.Job) error { 23 | if job.Container == nil { 24 | return nil 25 | } 26 | if job.Container.Image == "" { 27 | return errors.New("job container should have image") 28 | } 29 | _, tag, ok := strings.Cut(job.Container.Image, ":") 30 | if !ok { 31 | return errors.New("job container image should be :") 32 | } 33 | if tag == "latest" { 34 | return errors.New("job container image tag should not be `latest`") 35 | } 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /pkg/policy/deny_job_container_latest_image_test.go: -------------------------------------------------------------------------------- 1 | package policy_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | func TestDenyJobContainerLatestImagePolicy_ApplyJob(t *testing.T) { 12 | t.Parallel() 13 | data := []struct { 14 | name string 15 | job *workflow.Job 16 | isErr bool 17 | }{ 18 | { 19 | name: "pass", 20 | job: &workflow.Job{ 21 | Container: &workflow.Container{ 22 | Image: "node:18", 23 | }, 24 | }, 25 | }, 26 | { 27 | name: "job container should have image", 28 | job: &workflow.Job{ 29 | Container: &workflow.Container{}, 30 | }, 31 | isErr: true, 32 | }, 33 | { 34 | name: "job container image should have tag", 35 | job: &workflow.Job{ 36 | Container: &workflow.Container{ 37 | Image: "node", 38 | }, 39 | }, 40 | isErr: true, 41 | }, 42 | { 43 | name: "latest", 44 | job: &workflow.Job{ 45 | Container: &workflow.Container{ 46 | Image: "node:latest", 47 | }, 48 | }, 49 | isErr: true, 50 | }, 51 | } 52 | p := &policy.DenyJobContainerLatestImagePolicy{} 53 | logE := logrus.NewEntry(logrus.New()) 54 | for _, d := range data { 55 | t.Run(d.name, func(t *testing.T) { 56 | t.Parallel() 57 | if err := p.ApplyJob(logE, nil, nil, d.job); err != nil { 58 | if !d.isErr { 59 | t.Fatal(err) 60 | } 61 | return 62 | } 63 | if d.isErr { 64 | t.Fatal("error must be returned") 65 | } 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/policy/deny_read_all_policy.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | type DenyReadAllPermissionPolicy struct{} 12 | 13 | func (p *DenyReadAllPermissionPolicy) Name() string { 14 | return "deny_read_all_permission" 15 | } 16 | 17 | func (p *DenyReadAllPermissionPolicy) ID() string { 18 | return "002" 19 | } 20 | 21 | func (p *DenyReadAllPermissionPolicy) ApplyJob(_ *logrus.Entry, _ *config.Config, jobCtx *JobContext, job *workflow.Job) error { 22 | wfReadAll := jobCtx.Workflow.Workflow.Permissions.ReadAll() 23 | if job.Permissions.ReadAll() { 24 | return errors.New("don't use read-all permission") 25 | } 26 | if job.Permissions.IsNil() && wfReadAll { 27 | return errors.New("don't use read-all permission") 28 | } 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/policy/deny_read_all_policy_test.go: -------------------------------------------------------------------------------- 1 | package policy_test //nolint:dupl 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | func TestDenyReadAllPermissionPolicy_ApplyJob(t *testing.T) { 12 | t.Parallel() 13 | data := []struct { 14 | name string 15 | jobCtx *policy.JobContext 16 | job *workflow.Job 17 | isErr bool 18 | }{ 19 | { 20 | name: "don't use read-all", 21 | job: &workflow.Job{ 22 | Permissions: workflow.NewPermissions(true, false, nil), 23 | }, 24 | isErr: true, 25 | }, 26 | { 27 | name: "job permissions is null and workflow permissions is read-all", 28 | jobCtx: &policy.JobContext{ 29 | Workflow: &policy.WorkflowContext{ 30 | Workflow: &workflow.Workflow{ 31 | Permissions: workflow.NewPermissions(true, false, nil), 32 | }, 33 | }, 34 | }, 35 | job: &workflow.Job{}, 36 | isErr: true, 37 | }, 38 | { 39 | name: "pass", 40 | job: &workflow.Job{ 41 | Permissions: workflow.NewPermissions(false, false, map[string]string{ 42 | "contents": "read", 43 | }), 44 | }, 45 | }, 46 | } 47 | p := &policy.DenyReadAllPermissionPolicy{} 48 | logE := logrus.NewEntry(logrus.New()) 49 | for _, d := range data { 50 | if d.jobCtx == nil { 51 | d.jobCtx = &policy.JobContext{ 52 | Workflow: &policy.WorkflowContext{ 53 | Workflow: &workflow.Workflow{}, 54 | }, 55 | } 56 | } 57 | t.Run(d.name, func(t *testing.T) { 58 | t.Parallel() 59 | if err := p.ApplyJob(logE, nil, d.jobCtx, d.job); err != nil { 60 | if !d.isErr { 61 | t.Fatal(err) 62 | } 63 | return 64 | } 65 | if d.isErr { 66 | t.Fatal("error must be returned") 67 | } 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /pkg/policy/deny_write_all_policy.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | type DenyWriteAllPermissionPolicy struct{} 12 | 13 | func (p *DenyWriteAllPermissionPolicy) Name() string { 14 | return "deny_write_all_permission" 15 | } 16 | 17 | func (p *DenyWriteAllPermissionPolicy) ID() string { 18 | return "003" 19 | } 20 | 21 | func (p *DenyWriteAllPermissionPolicy) ApplyJob(_ *logrus.Entry, _ *config.Config, jobCtx *JobContext, job *workflow.Job) error { 22 | wfWriteAll := jobCtx.Workflow.Workflow.Permissions.WriteAll() 23 | if job.Permissions.WriteAll() { 24 | return errors.New("don't use write-all permission") 25 | } 26 | if job.Permissions.IsNil() && wfWriteAll { 27 | return errors.New("don't use write-all permission") 28 | } 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/policy/deny_write_all_policy_test.go: -------------------------------------------------------------------------------- 1 | package policy_test //nolint:dupl 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | func TestDenyWriteAllPermissionPolicy_ApplyJob(t *testing.T) { 12 | t.Parallel() 13 | data := []struct { 14 | name string 15 | jobCtx *policy.JobContext 16 | job *workflow.Job 17 | isErr bool 18 | }{ 19 | { 20 | name: "don't use write-all", 21 | job: &workflow.Job{ 22 | Permissions: workflow.NewPermissions(false, true, nil), 23 | }, 24 | isErr: true, 25 | }, 26 | { 27 | name: "job permissions is null and workflow permissions is write-all", 28 | jobCtx: &policy.JobContext{ 29 | Workflow: &policy.WorkflowContext{ 30 | Workflow: &workflow.Workflow{ 31 | Permissions: workflow.NewPermissions(false, true, nil), 32 | }, 33 | }, 34 | }, 35 | job: &workflow.Job{}, 36 | isErr: true, 37 | }, 38 | { 39 | name: "pass", 40 | job: &workflow.Job{ 41 | Permissions: workflow.NewPermissions(false, false, map[string]string{ 42 | "contents": "write", 43 | }), 44 | }, 45 | }, 46 | } 47 | p := &policy.DenyWriteAllPermissionPolicy{} 48 | logE := logrus.NewEntry(logrus.New()) 49 | for _, d := range data { 50 | if d.jobCtx == nil { 51 | d.jobCtx = &policy.JobContext{ 52 | Workflow: &policy.WorkflowContext{ 53 | Workflow: &workflow.Workflow{}, 54 | }, 55 | } 56 | } 57 | t.Run(d.name, func(t *testing.T) { 58 | t.Parallel() 59 | if err := p.ApplyJob(logE, nil, d.jobCtx, d.job); err != nil { 60 | if !d.isErr { 61 | t.Fatal(err) 62 | } 63 | return 64 | } 65 | if d.isErr { 66 | t.Fatal("error must be returned") 67 | } 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /pkg/policy/error.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import "errors" 4 | 5 | var ( 6 | errPermissionHyphenIsRequired = errors.New("an input `permission-*` is required") 7 | errPermissionsIsRequired = errors.New("the input `permissions` is required") 8 | errRepositoriesIsRequired = errors.New("the input `repositories` is required") 9 | errEmpty = errors.New("") 10 | ) 11 | -------------------------------------------------------------------------------- /pkg/policy/github_app_should_limit_permissions.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | "github.com/suzuki-shunsuke/logrus-error/logerr" 10 | ) 11 | 12 | type GitHubAppShouldLimitPermissionsPolicy struct{} 13 | 14 | func (p *GitHubAppShouldLimitPermissionsPolicy) Name() string { 15 | return "github_app_should_limit_permissions" 16 | } 17 | 18 | func (p *GitHubAppShouldLimitPermissionsPolicy) ID() string { 19 | return "010" 20 | } 21 | 22 | func (p *GitHubAppShouldLimitPermissionsPolicy) ApplyStep(_ *logrus.Entry, _ *config.Config, _ *StepContext, step *workflow.Step) (ge error) { //nolint:cyclop 23 | action := p.checkUses(step.Uses) 24 | if action == "" { 25 | return nil 26 | } 27 | defer func() { 28 | if ge != nil { 29 | ge = logerr.WithFields(ge, logrus.Fields{ 30 | "action": action, 31 | }) 32 | } 33 | }() 34 | 35 | switch action { 36 | case "tibdex/github-app-token": 37 | if step.With == nil { 38 | return errPermissionsIsRequired 39 | } 40 | if _, ok := step.With["permissions"]; !ok { 41 | return errPermissionsIsRequired 42 | } 43 | case "actions/create-github-app-token": 44 | if step.With == nil { 45 | return errPermissionsIsRequired 46 | } 47 | err := errPermissionHyphenIsRequired 48 | for k := range step.With { 49 | if strings.HasPrefix(k, "permission-") { 50 | err = nil 51 | break 52 | } 53 | } 54 | if err != nil { 55 | return err 56 | } 57 | } 58 | return nil 59 | } 60 | 61 | func (p *GitHubAppShouldLimitPermissionsPolicy) checkUses(uses string) string { 62 | if uses == "" { 63 | return "" 64 | } 65 | action, _, _ := strings.Cut(uses, "@") 66 | return action 67 | } 68 | -------------------------------------------------------------------------------- /pkg/policy/github_app_should_limit_permissions_test.go: -------------------------------------------------------------------------------- 1 | package policy_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 9 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 10 | ) 11 | 12 | func TestGitHubAppShouldLimitPermissionsPolicy_ApplyStep(t *testing.T) { //nolint:funlen 13 | t.Parallel() 14 | data := []struct { 15 | name string 16 | cfg *config.Config 17 | stepCtx *policy.StepContext 18 | step *workflow.Step 19 | isErr bool 20 | }{ 21 | { 22 | name: "tibdex/github-app-token fail", 23 | isErr: true, 24 | cfg: &config.Config{}, 25 | step: &workflow.Step{ 26 | Uses: "tibdex/github-app-token@v2", 27 | ID: "token", 28 | With: map[string]string{ 29 | "app_id": "xxx", 30 | "private_key": "xxx", 31 | }, 32 | }, 33 | }, 34 | { 35 | name: "tibdex/github-app-token success", 36 | cfg: &config.Config{}, 37 | step: &workflow.Step{ 38 | Uses: "tibdex/github-app-token@v2", 39 | ID: "token", 40 | With: map[string]string{ 41 | "app_id": "xxx", 42 | "private_key": "xxx", 43 | "permissions": "{}", 44 | }, 45 | }, 46 | }, 47 | { 48 | name: "actions/create-github-app-token fail", 49 | isErr: true, 50 | cfg: &config.Config{}, 51 | step: &workflow.Step{ 52 | Uses: "actions/create-github-app-token@v1.12.0", 53 | ID: "token", 54 | With: map[string]string{ 55 | "app-id": "xxx", 56 | "private-key": "xxx", 57 | }, 58 | }, 59 | }, 60 | { 61 | name: "actions/create-github-app-token succeed", 62 | cfg: &config.Config{}, 63 | step: &workflow.Step{ 64 | Uses: "actions/create-github-app-token@v1.12.0", 65 | ID: "token", 66 | With: map[string]string{ 67 | "app-id": "xxx", 68 | "private-key": "xxx", 69 | "permission-issues": "write", 70 | }, 71 | }, 72 | }, 73 | } 74 | p := &policy.GitHubAppShouldLimitPermissionsPolicy{} 75 | logE := logrus.NewEntry(logrus.New()) 76 | for _, d := range data { 77 | if d.stepCtx == nil { 78 | d.stepCtx = &policy.StepContext{ 79 | FilePath: ".github/workflows/test.yaml", 80 | Job: &policy.JobContext{ 81 | Name: "test", 82 | Workflow: &policy.WorkflowContext{ 83 | FilePath: ".github/workflows/test.yaml", 84 | }, 85 | }, 86 | } 87 | } 88 | t.Run(d.name, func(t *testing.T) { 89 | t.Parallel() 90 | if err := p.ApplyStep(logE, d.cfg, d.stepCtx, d.step); err != nil { 91 | if d.isErr { 92 | return 93 | } 94 | t.Fatal(err) 95 | } 96 | if d.isErr { 97 | t.Fatal("error must be returned") 98 | } 99 | }) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /pkg/policy/github_app_should_limit_repositories.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | "github.com/suzuki-shunsuke/logrus-error/logerr" 10 | ) 11 | 12 | type GitHubAppShouldLimitRepositoriesPolicy struct{} 13 | 14 | func (p *GitHubAppShouldLimitRepositoriesPolicy) Name() string { 15 | return "github_app_should_limit_repositories" 16 | } 17 | 18 | func (p *GitHubAppShouldLimitRepositoriesPolicy) ID() string { 19 | return "009" 20 | } 21 | 22 | func (p *GitHubAppShouldLimitRepositoriesPolicy) ApplyStep(logE *logrus.Entry, cfg *config.Config, stepCtx *StepContext, step *workflow.Step) (ge error) { //nolint:cyclop 23 | action := p.checkUses(step.Uses) 24 | if action == "" { 25 | return nil 26 | } 27 | defer func() { 28 | if ge != nil { 29 | ge = logerr.WithFields(ge, logrus.Fields{ 30 | "action": action, 31 | }) 32 | } 33 | }() 34 | if p.excluded(cfg, stepCtx, step) { 35 | logE.Debug("this step is ignored") 36 | return nil 37 | } 38 | if action == "tibdex/github-app-token" { 39 | if step.With == nil { 40 | return errRepositoriesIsRequired 41 | } 42 | if _, ok := step.With["repositories"]; !ok { 43 | return errRepositoriesIsRequired 44 | } 45 | return nil 46 | } 47 | if action == "actions/create-github-app-token" { 48 | if step.With == nil { 49 | return errRepositoriesIsRequired 50 | } 51 | if _, ok := step.With["repositories"]; ok { 52 | return nil 53 | } 54 | if _, ok := step.With["owner"]; ok { 55 | return errRepositoriesIsRequired 56 | } 57 | return nil 58 | } 59 | return nil 60 | } 61 | 62 | func (p *GitHubAppShouldLimitRepositoriesPolicy) checkUses(uses string) string { 63 | if uses == "" { 64 | return "" 65 | } 66 | action, _, _ := strings.Cut(uses, "@") 67 | return action 68 | } 69 | 70 | func (p *GitHubAppShouldLimitRepositoriesPolicy) excluded(cfg *config.Config, stepCtx *StepContext, step *workflow.Step) bool { 71 | for _, exclude := range cfg.Excludes { 72 | if exclude.PolicyName != p.Name() { 73 | continue 74 | } 75 | if exclude.FilePath() != stepCtx.FilePath { 76 | continue 77 | } 78 | if stepCtx.Job != nil && exclude.JobName != stepCtx.Job.Name { 79 | continue 80 | } 81 | if exclude.StepID != step.ID { 82 | continue 83 | } 84 | return true 85 | } 86 | return false 87 | } 88 | -------------------------------------------------------------------------------- /pkg/policy/github_app_should_limit_repositories_test.go: -------------------------------------------------------------------------------- 1 | package policy_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 9 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 10 | ) 11 | 12 | func TestGitHubAppShouldLimitRepositoriesPolicy_ApplyStep(t *testing.T) { //nolint:funlen 13 | t.Parallel() 14 | data := []struct { 15 | name string 16 | cfg *config.Config 17 | stepCtx *policy.StepContext 18 | step *workflow.Step 19 | isErr bool 20 | }{ 21 | { 22 | name: "tibdex/github-app-token fail", 23 | isErr: true, 24 | cfg: &config.Config{}, 25 | step: &workflow.Step{ 26 | Uses: "tibdex/github-app-token@v2", 27 | ID: "token", 28 | With: map[string]string{ 29 | "app_id": "xxx", 30 | "private_key": "xxx", 31 | }, 32 | }, 33 | }, 34 | { 35 | name: "tibdex/github-app-token success", 36 | cfg: &config.Config{}, 37 | step: &workflow.Step{ 38 | Uses: "tibdex/github-app-token@v2", 39 | ID: "token", 40 | With: map[string]string{ 41 | "app_id": "xxx", 42 | "private_key": "xxx", 43 | "repositories": "{}", 44 | }, 45 | }, 46 | }, 47 | { 48 | name: "actions/create-github-app-token fail", 49 | isErr: true, 50 | cfg: &config.Config{}, 51 | step: &workflow.Step{ 52 | Uses: "actions/create-github-app-token@v2", 53 | ID: "token", 54 | With: map[string]string{ 55 | "app-id": "xxx", 56 | "private-key": "xxx", 57 | "owner": "xxx", 58 | }, 59 | }, 60 | }, 61 | { 62 | name: "actions/create-github-app-token success", 63 | cfg: &config.Config{}, 64 | step: &workflow.Step{ 65 | Uses: "actions/create-github-app-token@v2", 66 | ID: "token", 67 | With: map[string]string{ 68 | "app-id": "xxx", 69 | "private-key": "xxx", 70 | "owner": "xxx", 71 | "repositories": "foo,bar", 72 | }, 73 | }, 74 | }, 75 | { 76 | name: "actions/create-github-app-token success no owner", 77 | cfg: &config.Config{}, 78 | step: &workflow.Step{ 79 | Uses: "actions/create-github-app-token@v2", 80 | ID: "token", 81 | With: map[string]string{ 82 | "app-id": "xxx", 83 | "private-key": "xxx", 84 | }, 85 | }, 86 | }, 87 | { 88 | name: "exclude", 89 | cfg: &config.Config{ 90 | Excludes: []*config.Exclude{ 91 | { 92 | PolicyName: "github_app_should_limit_repositories", 93 | WorkflowFilePath: ".github/workflows/test.yaml", 94 | JobName: "test", 95 | StepID: "token", 96 | }, 97 | }, 98 | }, 99 | stepCtx: &policy.StepContext{ 100 | FilePath: ".github/workflows/test.yaml", 101 | Job: &policy.JobContext{ 102 | Name: "test", 103 | Workflow: &policy.WorkflowContext{ 104 | FilePath: ".github/workflows/test.yaml", 105 | }, 106 | }, 107 | }, 108 | step: &workflow.Step{ 109 | Uses: "tibdex/github-app-token@v2", 110 | ID: "token", 111 | With: map[string]string{ 112 | "app_id": "xxx", 113 | "private_key": "xxx", 114 | }, 115 | }, 116 | }, 117 | { 118 | name: "exclude action", 119 | cfg: &config.Config{ 120 | Excludes: []*config.Exclude{ 121 | { 122 | PolicyName: "github_app_should_limit_repositories", 123 | ActionFilePath: "foo/action.yaml", 124 | StepID: "token", 125 | }, 126 | }, 127 | }, 128 | stepCtx: &policy.StepContext{ 129 | FilePath: "foo/action.yaml", 130 | }, 131 | step: &workflow.Step{ 132 | Uses: "tibdex/github-app-token@v2", 133 | ID: "token", 134 | With: map[string]string{ 135 | "app_id": "xxx", 136 | "private_key": "xxx", 137 | }, 138 | }, 139 | }, 140 | } 141 | p := &policy.GitHubAppShouldLimitRepositoriesPolicy{} 142 | logE := logrus.NewEntry(logrus.New()) 143 | for _, d := range data { 144 | if d.stepCtx == nil { 145 | d.stepCtx = &policy.StepContext{ 146 | FilePath: ".github/workflows/test.yaml", 147 | Job: &policy.JobContext{ 148 | Name: "test", 149 | Workflow: &policy.WorkflowContext{ 150 | FilePath: ".github/workflows/test.yaml", 151 | }, 152 | }, 153 | } 154 | } 155 | t.Run(d.name, func(t *testing.T) { 156 | t.Parallel() 157 | if err := p.ApplyStep(logE, d.cfg, d.stepCtx, d.step); err != nil { 158 | if d.isErr { 159 | return 160 | } 161 | t.Fatal(err) 162 | } 163 | if d.isErr { 164 | t.Fatal("error must be returned") 165 | } 166 | }) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /pkg/policy/job_permissions_policy.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | type JobPermissionsPolicy struct{} 12 | 13 | func (p *JobPermissionsPolicy) Name() string { 14 | return "job_permissions" 15 | } 16 | 17 | func (p *JobPermissionsPolicy) ID() string { 18 | return "001" 19 | } 20 | 21 | func (p *JobPermissionsPolicy) ApplyJob(_ *logrus.Entry, _ *config.Config, jobCtx *JobContext, job *workflow.Job) error { 22 | wf := jobCtx.Workflow.Workflow 23 | wfPermissions := wf.Permissions.Permissions() 24 | if wfPermissions != nil && len(wfPermissions) == 0 { 25 | // workflow's permissions is `{}` 26 | return nil 27 | } 28 | if len(wf.Jobs) < 2 && wfPermissions != nil { 29 | // workflow permissions is set and there is only one job 30 | return nil 31 | } 32 | if job.Permissions.IsNil() { 33 | return errors.New("job should have permissions") 34 | } 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /pkg/policy/job_permissions_policy_test.go: -------------------------------------------------------------------------------- 1 | package policy_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | func TestJobPermissionsPolicy_ApplyJob(t *testing.T) { //nolint:funlen 12 | t.Parallel() 13 | data := []struct { 14 | name string 15 | jobCtx *policy.JobContext 16 | job *workflow.Job 17 | isErr bool 18 | }{ 19 | { 20 | name: "workflow permissions is empty", 21 | job: &workflow.Job{}, 22 | jobCtx: &policy.JobContext{ 23 | Workflow: &policy.WorkflowContext{ 24 | Workflow: &workflow.Workflow{ 25 | Permissions: workflow.NewPermissions(false, false, map[string]string{}), 26 | Jobs: map[string]*workflow.Job{ 27 | "foo": {}, 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | { 34 | name: "workflow has only one job", 35 | jobCtx: &policy.JobContext{ 36 | Workflow: &policy.WorkflowContext{ 37 | Workflow: &workflow.Workflow{ 38 | Permissions: workflow.NewPermissions(false, false, map[string]string{ 39 | "contents": "read", 40 | }), 41 | Jobs: map[string]*workflow.Job{ 42 | "foo": {}, 43 | }, 44 | }, 45 | }, 46 | }, 47 | job: &workflow.Job{}, 48 | }, 49 | { 50 | name: "job should have permissions", 51 | jobCtx: &policy.JobContext{ 52 | Workflow: &policy.WorkflowContext{ 53 | Workflow: &workflow.Workflow{ 54 | Permissions: &workflow.Permissions{}, 55 | Jobs: map[string]*workflow.Job{ 56 | "foo": {}, 57 | "bar": {}, 58 | }, 59 | }, 60 | }, 61 | }, 62 | job: &workflow.Job{}, 63 | isErr: true, 64 | }, 65 | } 66 | p := &policy.JobPermissionsPolicy{} 67 | logE := logrus.NewEntry(logrus.New()) 68 | for _, d := range data { 69 | t.Run(d.name, func(t *testing.T) { 70 | t.Parallel() 71 | if err := p.ApplyJob(logE, nil, d.jobCtx, d.job); err != nil { 72 | if !d.isErr { 73 | t.Fatal(err) 74 | } 75 | return 76 | } 77 | if d.isErr { 78 | t.Fatal("error must be returned") 79 | } 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /pkg/policy/job_secrets_policy.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 9 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 10 | "github.com/suzuki-shunsuke/logrus-error/logerr" 11 | ) 12 | 13 | type JobSecretsPolicy struct { 14 | secretPattern *regexp.Regexp 15 | githubTokenPattern *regexp.Regexp 16 | } 17 | 18 | func NewJobSecretsPolicy() *JobSecretsPolicy { 19 | return &JobSecretsPolicy{ 20 | secretPattern: regexp.MustCompile(`\${{ *secrets\.[^ ]+ *}}`), 21 | githubTokenPattern: regexp.MustCompile(`\${{ *github\.token+ *}}`), 22 | } 23 | } 24 | 25 | func (p *JobSecretsPolicy) Name() string { 26 | return "job_secrets" 27 | } 28 | 29 | func (p *JobSecretsPolicy) ID() string { 30 | return "006" 31 | } 32 | 33 | func checkExcludes(policyName string, jobCtx *JobContext, cfg *config.Config) bool { 34 | for _, exclude := range cfg.Excludes { 35 | if exclude.PolicyName == policyName && jobCtx.Workflow.FilePath == exclude.WorkflowFilePath && jobCtx.Name == exclude.JobName { 36 | return true 37 | } 38 | } 39 | return false 40 | } 41 | 42 | func (p *JobSecretsPolicy) ApplyJob(_ *logrus.Entry, cfg *config.Config, jobCtx *JobContext, job *workflow.Job) error { 43 | if checkExcludes(p.Name(), jobCtx, cfg) { 44 | return nil 45 | } 46 | if len(job.Steps) < 2 { //nolint:mnd 47 | return nil 48 | } 49 | for envName, envValue := range job.Env { 50 | if p.secretPattern.MatchString(envValue) { 51 | return logerr.WithFields(errors.New("secret should not be set to job's env"), logrus.Fields{ //nolint:wrapcheck 52 | "env_name": envName, 53 | }) 54 | } 55 | if p.githubTokenPattern.MatchString(envValue) { 56 | return logerr.WithFields(errors.New("github.token should not be set to job's env"), logrus.Fields{ //nolint:wrapcheck 57 | "env_name": envName, 58 | }) 59 | } 60 | } 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /pkg/policy/job_secrets_policy_test.go: -------------------------------------------------------------------------------- 1 | package policy_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 9 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 10 | ) 11 | 12 | func TestJobSecretsPolicy_ApplyJob(t *testing.T) { //nolint:funlen 13 | t.Parallel() 14 | data := []struct { 15 | name string 16 | cfg *config.Config 17 | jobCtx *policy.JobContext 18 | job *workflow.Job 19 | isErr bool 20 | }{ 21 | { 22 | name: "exclude", 23 | cfg: &config.Config{ 24 | Excludes: []*config.Exclude{ 25 | { 26 | PolicyName: "job_secrets", 27 | WorkflowFilePath: ".github/workflows/test.yaml", 28 | JobName: "foo", 29 | }, 30 | }, 31 | }, 32 | jobCtx: &policy.JobContext{ 33 | Workflow: &policy.WorkflowContext{ 34 | FilePath: ".github/workflows/test.yaml", 35 | }, 36 | Name: "foo", 37 | }, 38 | job: &workflow.Job{ 39 | Env: map[string]string{ 40 | "GITHUB_TOKEN": "${{github.token}}", 41 | }, 42 | Steps: []*workflow.Step{ 43 | {}, 44 | {}, 45 | }, 46 | }, 47 | }, 48 | { 49 | name: "job has only one step", 50 | cfg: &config.Config{}, 51 | jobCtx: &policy.JobContext{}, 52 | job: &workflow.Job{ 53 | Env: map[string]string{ 54 | "GITHUB_TOKEN": "${{github.token}}", 55 | }, 56 | Steps: []*workflow.Step{ 57 | {}, 58 | }, 59 | }, 60 | }, 61 | { 62 | name: "secret should not be set to job's env", 63 | cfg: &config.Config{}, 64 | jobCtx: &policy.JobContext{}, 65 | job: &workflow.Job{ 66 | Env: map[string]string{ 67 | "GITHUB_TOKEN": "${{secrets.GITHUB_TOKEN}}", 68 | }, 69 | Steps: []*workflow.Step{ 70 | {}, 71 | {}, 72 | }, 73 | }, 74 | isErr: true, 75 | }, 76 | { 77 | name: "github token should not be set to job's env", 78 | cfg: &config.Config{}, 79 | jobCtx: &policy.JobContext{}, 80 | job: &workflow.Job{ 81 | Env: map[string]string{ 82 | "GITHUB_TOKEN": "${{github.token}}", 83 | }, 84 | Steps: []*workflow.Step{ 85 | {}, 86 | {}, 87 | }, 88 | }, 89 | isErr: true, 90 | }, 91 | { 92 | name: "pass", 93 | cfg: &config.Config{}, 94 | jobCtx: &policy.JobContext{}, 95 | job: &workflow.Job{ 96 | Env: map[string]string{ 97 | "FOO": "foo", 98 | }, 99 | Steps: []*workflow.Step{ 100 | {}, 101 | {}, 102 | }, 103 | }, 104 | }, 105 | } 106 | p := policy.NewJobSecretsPolicy() 107 | logE := logrus.NewEntry(logrus.New()) 108 | for _, d := range data { 109 | t.Run(d.name, func(t *testing.T) { 110 | t.Parallel() 111 | if err := p.ApplyJob(logE, d.cfg, d.jobCtx, d.job); err != nil { 112 | if !d.isErr { 113 | t.Fatal(err) 114 | } 115 | return 116 | } 117 | if d.isErr { 118 | t.Fatal("error must be returned") 119 | } 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /pkg/policy/job_timeout_minutes_is_required.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | type JobTimeoutMinutesIsRequiredPolicy struct{} 12 | 13 | func (p *JobTimeoutMinutesIsRequiredPolicy) Name() string { 14 | return "job_timeout_minutes_is_required" 15 | } 16 | 17 | func (p *JobTimeoutMinutesIsRequiredPolicy) ID() string { 18 | return "012" 19 | } 20 | 21 | func (p *JobTimeoutMinutesIsRequiredPolicy) ApplyJob(_ *logrus.Entry, _ *config.Config, _ *JobContext, job *workflow.Job) error { 22 | if job.TimeoutMinutes != nil { 23 | return nil 24 | } 25 | if job.Uses != "" { 26 | // when a reusable workflow is called with "uses", "timeout-minutes" is not available. 27 | return nil 28 | } 29 | for _, step := range job.Steps { 30 | if step.TimeoutMinutes == nil { 31 | return errors.New("job's timeout-minutes is required") 32 | } 33 | } 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/policy/job_timeout_minutes_is_required_test.go: -------------------------------------------------------------------------------- 1 | package policy_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | func TestJobTimeoutMinutesIsRequiredPolicy_ApplyJob(t *testing.T) { //nolint:funlen 12 | t.Parallel() 13 | data := []struct { 14 | name string 15 | job *workflow.Job 16 | isErr bool 17 | }{ 18 | { 19 | name: "normal", 20 | job: &workflow.Job{ 21 | TimeoutMinutes: 30, 22 | Steps: []*workflow.Step{ 23 | { 24 | Run: "echo hello", 25 | }, 26 | }, 27 | }, 28 | }, 29 | { 30 | name: "expression is used", 31 | job: &workflow.Job{ 32 | TimeoutMinutes: "${{ matrix.timeout-minutes }}", 33 | Steps: []*workflow.Step{ 34 | { 35 | Run: "echo hello", 36 | }, 37 | }, 38 | }, 39 | }, 40 | { 41 | name: "workflow using reusable workflow", 42 | job: &workflow.Job{ 43 | Uses: "suzuki-shunsuke/renovate-config-validator-workflow/.github/workflows/validate.yaml@v0.2.3", 44 | }, 45 | }, 46 | { 47 | name: "job should have timeout-minutes", 48 | job: &workflow.Job{ 49 | Steps: []*workflow.Step{ 50 | { 51 | Run: "echo hello", 52 | }, 53 | }, 54 | }, 55 | isErr: true, 56 | }, 57 | { 58 | name: "all steps have timeout-minutes", 59 | job: &workflow.Job{ 60 | Steps: []*workflow.Step{ 61 | { 62 | Run: "echo hello", 63 | TimeoutMinutes: 60, 64 | }, 65 | { 66 | Run: "echo hello", 67 | TimeoutMinutes: 60, 68 | }, 69 | }, 70 | }, 71 | }, 72 | { 73 | name: "expression is used in step's timeout-minutes", 74 | job: &workflow.Job{ 75 | Steps: []*workflow.Step{ 76 | { 77 | Run: "echo hello", 78 | TimeoutMinutes: "${{ matrix.timeout-minutes }}", 79 | }, 80 | { 81 | Run: "echo hello", 82 | TimeoutMinutes: 60, 83 | }, 84 | }, 85 | }, 86 | }, 87 | } 88 | p := &policy.JobTimeoutMinutesIsRequiredPolicy{} 89 | logE := logrus.NewEntry(logrus.New()) 90 | for _, d := range data { 91 | t.Run(d.name, func(t *testing.T) { 92 | t.Parallel() 93 | if err := p.ApplyJob(logE, nil, nil, d.job); err != nil { 94 | if !d.isErr { 95 | t.Fatal(err) 96 | } 97 | return 98 | } 99 | if d.isErr { 100 | t.Fatal("error must be returned") 101 | } 102 | }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /pkg/policy/workflow_secrets_policy.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 9 | ) 10 | 11 | type WorkflowSecretsPolicy struct { 12 | secretPattern *regexp.Regexp 13 | githubTokenPattern *regexp.Regexp 14 | } 15 | 16 | func NewWorkflowSecretsPolicy() *WorkflowSecretsPolicy { 17 | return &WorkflowSecretsPolicy{ 18 | secretPattern: regexp.MustCompile(`\${{ *secrets\.[^ ]+ *}}`), 19 | githubTokenPattern: regexp.MustCompile(`\${{ *github\.token+ *}}`), 20 | } 21 | } 22 | 23 | func (p *WorkflowSecretsPolicy) Name() string { 24 | return "workflow_secrets" 25 | } 26 | 27 | func (p *WorkflowSecretsPolicy) ID() string { 28 | return "005" 29 | } 30 | 31 | func (p *WorkflowSecretsPolicy) ApplyWorkflow(logE *logrus.Entry, _ *config.Config, _ *WorkflowContext, wf *workflow.Workflow) error { 32 | if len(wf.Jobs) < 2 { //nolint:mnd 33 | return nil 34 | } 35 | failed := false 36 | for envName, envValue := range wf.Env { 37 | if p.secretPattern.MatchString(envValue) { 38 | failed = true 39 | logE.WithField("env_name", envName).Error("secret should not be set to workflow's env") 40 | } 41 | if p.githubTokenPattern.MatchString(envValue) { 42 | failed = true 43 | logE.WithField("env_name", envName).Error("github.token should not be set to workflow's env") 44 | } 45 | } 46 | if failed { 47 | return errEmpty 48 | } 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /pkg/policy/workflow_secrets_policy_test.go: -------------------------------------------------------------------------------- 1 | package policy_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/ghalint/pkg/config" 8 | "github.com/suzuki-shunsuke/ghalint/pkg/policy" 9 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 10 | ) 11 | 12 | func TestWorkflowSecretsPolicy_ApplyWorkflow(t *testing.T) { //nolint:funlen 13 | t.Parallel() 14 | data := []struct { 15 | name string 16 | cfg *config.Config 17 | wf *workflow.Workflow 18 | isErr bool 19 | }{ 20 | { 21 | name: "workflow has only one job", 22 | cfg: &config.Config{}, 23 | wf: &workflow.Workflow{ 24 | FilePath: ".github/workflows/test.yaml", 25 | Env: map[string]string{ 26 | "GITHUB_TOKEN": "${{github.token}}", 27 | }, 28 | Jobs: map[string]*workflow.Job{ 29 | "foo": {}, 30 | }, 31 | }, 32 | }, 33 | { 34 | name: "secret should not be set to workflow's env", 35 | cfg: &config.Config{}, 36 | wf: &workflow.Workflow{ 37 | FilePath: ".github/workflows/test.yaml", 38 | Env: map[string]string{ 39 | "GITHUB_TOKEN": "${{secrets.GITHUB_TOKEN}}", 40 | }, 41 | Jobs: map[string]*workflow.Job{ 42 | "foo": {}, 43 | "bar": {}, 44 | }, 45 | }, 46 | isErr: true, 47 | }, 48 | { 49 | name: "github token should not be set to workflow's env", 50 | cfg: &config.Config{}, 51 | wf: &workflow.Workflow{ 52 | FilePath: ".github/workflows/test.yaml", 53 | Env: map[string]string{ 54 | "GITHUB_TOKEN": "${{github.token}}", 55 | }, 56 | Jobs: map[string]*workflow.Job{ 57 | "foo": {}, 58 | "bar": {}, 59 | }, 60 | }, 61 | isErr: true, 62 | }, 63 | { 64 | name: "pass", 65 | cfg: &config.Config{}, 66 | wf: &workflow.Workflow{ 67 | FilePath: ".github/workflows/test.yaml", 68 | Env: map[string]string{ 69 | "FOO": "foo", 70 | }, 71 | Jobs: map[string]*workflow.Job{ 72 | "foo": {}, 73 | "bar": {}, 74 | }, 75 | }, 76 | }, 77 | } 78 | p := policy.NewWorkflowSecretsPolicy() 79 | logE := logrus.NewEntry(logrus.New()) 80 | for _, d := range data { 81 | t.Run(d.name, func(t *testing.T) { 82 | t.Parallel() 83 | if err := p.ApplyWorkflow(logE, d.cfg, nil, d.wf); err != nil { 84 | if !d.isErr { 85 | t.Fatal(err) 86 | } 87 | return 88 | } 89 | if d.isErr { 90 | t.Fatal("error must be returned") 91 | } 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /pkg/workflow/container.go: -------------------------------------------------------------------------------- 1 | package workflow 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | type Container struct { 8 | Image string 9 | } 10 | 11 | func (c *Container) UnmarshalYAML(unmarshal func(interface{}) error) error { 12 | var val interface{} 13 | if err := unmarshal(&val); err != nil { 14 | return err 15 | } 16 | return convContainer(val, c) 17 | } 18 | 19 | func convContainer(src interface{}, c *Container) error { //nolint:cyclop 20 | switch p := src.(type) { 21 | case string: 22 | c.Image = p 23 | return nil 24 | case map[interface{}]interface{}: 25 | for k, v := range p { 26 | key, ok := k.(string) 27 | if !ok { 28 | continue 29 | } 30 | if key != "image" { 31 | continue 32 | } 33 | image, ok := v.(string) 34 | if !ok { 35 | return errors.New("image must be a string") 36 | } 37 | c.Image = image 38 | return nil 39 | } 40 | return nil 41 | case map[string]interface{}: 42 | for k, v := range p { 43 | if k != "image" { 44 | continue 45 | } 46 | image, ok := v.(string) 47 | if !ok { 48 | return errors.New("image must be a string") 49 | } 50 | c.Image = image 51 | return nil 52 | } 53 | return nil 54 | default: 55 | return errors.New("container must be a map or string") 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pkg/workflow/container_test.go: -------------------------------------------------------------------------------- 1 | package workflow_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | func TestContainer_UnmarshalYAML(t *testing.T) { 11 | t.Parallel() 12 | data := []struct { 13 | name string 14 | yaml string 15 | image string 16 | }{ 17 | { 18 | name: "normal", 19 | yaml: "image: node:18", 20 | image: "node:18", 21 | }, 22 | { 23 | name: "string", 24 | yaml: "node:18", 25 | image: "node:18", 26 | }, 27 | } 28 | for _, d := range data { 29 | t.Run(d.name, func(t *testing.T) { 30 | t.Parallel() 31 | c := &workflow.Container{} 32 | if err := yaml.Unmarshal([]byte(d.yaml), c); err != nil { 33 | t.Fatal(err) 34 | } 35 | if d.image != c.Image { 36 | t.Fatalf("got %v, wanted %v", c.Image, d.image) 37 | } 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/workflow/job_secrets.go: -------------------------------------------------------------------------------- 1 | package workflow 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/logrus-error/logerr" 8 | ) 9 | 10 | type JobSecrets struct { 11 | m map[string]string 12 | inherit bool 13 | } 14 | 15 | func (js *JobSecrets) Secrets() map[string]string { 16 | return js.m 17 | } 18 | 19 | func (js *JobSecrets) Inherit() bool { 20 | return js != nil && js.inherit 21 | } 22 | 23 | func (js *JobSecrets) UnmarshalYAML(unmarshal func(interface{}) error) error { 24 | var val interface{} 25 | if err := unmarshal(&val); err != nil { 26 | return err 27 | } 28 | return convJobSecrets(val, js) 29 | } 30 | 31 | func convJobSecrets(src interface{}, dest *JobSecrets) error { //nolint:cyclop 32 | switch p := src.(type) { 33 | case string: 34 | switch p { 35 | case "inherit": 36 | dest.inherit = true 37 | return nil 38 | default: 39 | return logerr.WithFields(errors.New("job secrets must be a map or `inherit`"), logrus.Fields{ //nolint:wrapcheck 40 | "secrets": p, 41 | }) 42 | } 43 | case map[interface{}]interface{}: 44 | m := make(map[string]string, len(p)) 45 | for k, v := range p { 46 | ks, ok := k.(string) 47 | if !ok { 48 | return errors.New("secrets key must be string") 49 | } 50 | vs, ok := v.(string) 51 | if !ok { 52 | return errors.New("secrets value must be string") 53 | } 54 | m[ks] = vs 55 | } 56 | dest.m = m 57 | return nil 58 | case map[string]interface{}: 59 | m := make(map[string]string, len(p)) 60 | for k, v := range p { 61 | vs, ok := v.(string) 62 | if !ok { 63 | return errors.New("secrets value must be string") 64 | } 65 | m[k] = vs 66 | } 67 | dest.m = m 68 | return nil 69 | default: 70 | return errors.New("secrets must be map[string]string or 'inherit'") 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /pkg/workflow/job_secrets_test.go: -------------------------------------------------------------------------------- 1 | package workflow_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | func TestJobSecrets_UnmarshalYAML(t *testing.T) { 11 | t.Parallel() 12 | data := []struct { 13 | name string 14 | yaml string 15 | inherit bool 16 | }{ 17 | { 18 | name: "not inherit", 19 | yaml: `token: ${{github.token}}`, 20 | }, 21 | { 22 | name: "inherit", 23 | yaml: `inherit`, 24 | inherit: true, 25 | }, 26 | } 27 | for _, d := range data { 28 | t.Run(d.name, func(t *testing.T) { 29 | t.Parallel() 30 | js := &workflow.JobSecrets{} 31 | if err := yaml.Unmarshal([]byte(d.yaml), js); err != nil { 32 | t.Fatal(err) 33 | } 34 | inherit := js.Inherit() 35 | if d.inherit != inherit { 36 | t.Fatalf("got %v, wanted %v", inherit, d.inherit) 37 | } 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/workflow/list_workflows.go: -------------------------------------------------------------------------------- 1 | package workflow 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/afero" 7 | ) 8 | 9 | func List(fs afero.Fs) ([]string, error) { 10 | files, err := afero.Glob(fs, ".github/workflows/*.yml") 11 | if err != nil { 12 | return nil, fmt.Errorf("find .github/workflows/*.yml: %w", err) 13 | } 14 | files2, err := afero.Glob(fs, ".github/workflows/*.yaml") 15 | if err != nil { 16 | return nil, fmt.Errorf("find .github/workflows/*.yaml: %w", err) 17 | } 18 | return append(files, files2...), nil 19 | } 20 | -------------------------------------------------------------------------------- /pkg/workflow/permissions.go: -------------------------------------------------------------------------------- 1 | package workflow 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/suzuki-shunsuke/logrus-error/logerr" 8 | ) 9 | 10 | type Permissions struct { 11 | m map[string]string 12 | readAll bool 13 | writeAll bool 14 | } 15 | 16 | func NewPermissions(readAll, writeAll bool, m map[string]string) *Permissions { 17 | return &Permissions{ 18 | m: m, 19 | readAll: readAll, 20 | writeAll: writeAll, 21 | } 22 | } 23 | 24 | func (ps *Permissions) Permissions() map[string]string { 25 | if ps == nil { 26 | return nil 27 | } 28 | return ps.m 29 | } 30 | 31 | func (ps *Permissions) ReadAll() bool { 32 | if ps == nil { 33 | return false 34 | } 35 | return ps.readAll 36 | } 37 | 38 | func (ps *Permissions) WriteAll() bool { 39 | if ps == nil { 40 | return false 41 | } 42 | return ps.writeAll 43 | } 44 | 45 | func (ps *Permissions) IsNil() bool { 46 | if ps == nil { 47 | return true 48 | } 49 | return ps.m == nil && !ps.readAll && !ps.writeAll 50 | } 51 | 52 | func (ps *Permissions) UnmarshalYAML(unmarshal func(interface{}) error) error { 53 | var val interface{} 54 | if err := unmarshal(&val); err != nil { 55 | return err 56 | } 57 | return convPermissions(val, ps) 58 | } 59 | 60 | func convPermissions(src interface{}, dest *Permissions) error { //nolint:cyclop 61 | switch p := src.(type) { 62 | case string: 63 | switch p { 64 | case "read-all": 65 | dest.readAll = true 66 | return nil 67 | case "write-all": 68 | dest.writeAll = true 69 | return nil 70 | default: 71 | return logerr.WithFields(errors.New("unknown permissions"), logrus.Fields{ //nolint:wrapcheck 72 | "permission": p, 73 | }) 74 | } 75 | case map[interface{}]interface{}: 76 | m := make(map[string]string, len(p)) 77 | for k, v := range p { 78 | ks, ok := k.(string) 79 | if !ok { 80 | return errors.New("permissions key must be string") 81 | } 82 | vs, ok := v.(string) 83 | if !ok { 84 | return errors.New("permissions value must be string") 85 | } 86 | m[ks] = vs 87 | } 88 | dest.m = m 89 | return nil 90 | case map[string]interface{}: 91 | m := make(map[string]string, len(p)) 92 | for k, v := range p { 93 | vs, ok := v.(string) 94 | if !ok { 95 | return errors.New("permissions value must be string") 96 | } 97 | m[k] = vs 98 | } 99 | dest.m = m 100 | return nil 101 | default: 102 | return errors.New("permissions must be map[string]string or 'read-all' or 'write-all'") 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /pkg/workflow/permissions_test.go: -------------------------------------------------------------------------------- 1 | package workflow_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/suzuki-shunsuke/ghalint/pkg/workflow" 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | func TestPermissions_UnmarshalYAML(t *testing.T) { 11 | t.Parallel() 12 | data := []struct { 13 | name string 14 | yaml string 15 | readAll bool 16 | writeAll bool 17 | }{ 18 | { 19 | name: "not read-all and write-all", 20 | yaml: `contents: read`, 21 | }, 22 | { 23 | name: "read-all", 24 | yaml: `read-all`, 25 | readAll: true, 26 | }, 27 | { 28 | name: "write-all", 29 | yaml: `write-all`, 30 | writeAll: true, 31 | }, 32 | } 33 | for _, d := range data { 34 | t.Run(d.name, func(t *testing.T) { 35 | t.Parallel() 36 | p := &workflow.Permissions{} 37 | if err := yaml.Unmarshal([]byte(d.yaml), p); err != nil { 38 | t.Fatal(err) 39 | } 40 | readAll := p.ReadAll() 41 | writeAll := p.WriteAll() 42 | if d.readAll != readAll { 43 | t.Fatalf("readAll got %v, wanted %v", readAll, d.readAll) 44 | } 45 | if d.writeAll != writeAll { 46 | t.Fatalf("writeAll got %v, wanted %v", writeAll, d.writeAll) 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/workflow/read_action.go: -------------------------------------------------------------------------------- 1 | package workflow 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/spf13/afero" 10 | "github.com/suzuki-shunsuke/logrus-error/logerr" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | func ReadAction(fs afero.Fs, p string, action *Action) error { 15 | f, err := fs.Open(p) 16 | if err != nil { 17 | return fmt.Errorf("open an action file: %w", err) 18 | } 19 | defer f.Close() 20 | if err := yaml.NewDecoder(f).Decode(action); err != nil { 21 | err := fmt.Errorf("parse an action file as YAML: %w", err) 22 | if errors.Is(err, io.EOF) { 23 | return logerr.WithFields(err, logrus.Fields{ //nolint:wrapcheck 24 | "reference": "https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/codes/001.md", 25 | }) 26 | } 27 | return err 28 | } 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/workflow/read_workflow.go: -------------------------------------------------------------------------------- 1 | package workflow 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/spf13/afero" 10 | "github.com/suzuki-shunsuke/logrus-error/logerr" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | func Read(fs afero.Fs, p string, wf *Workflow) error { 15 | f, err := fs.Open(p) 16 | if err != nil { 17 | return fmt.Errorf("open a workflow file: %w", err) 18 | } 19 | defer f.Close() 20 | if err := yaml.NewDecoder(f).Decode(wf); err != nil { 21 | err := fmt.Errorf("parse a workflow file as YAML: %w", err) 22 | if errors.Is(err, io.EOF) { 23 | return logerr.WithFields(err, logrus.Fields{ //nolint:wrapcheck 24 | "reference": "https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/codes/001.md", 25 | }) 26 | } 27 | return err 28 | } 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/workflow/workflow.go: -------------------------------------------------------------------------------- 1 | package workflow 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | type Workflow struct { 11 | FilePath string `yaml:"-"` 12 | Jobs map[string]*Job 13 | Env map[string]string 14 | Permissions *Permissions 15 | } 16 | 17 | type Job struct { 18 | Permissions *Permissions 19 | Env map[string]string 20 | Steps []*Step 21 | Secrets *JobSecrets 22 | Container *Container 23 | Uses string 24 | TimeoutMinutes any `yaml:"timeout-minutes"` 25 | } 26 | 27 | type Step struct { 28 | Uses string 29 | ID string 30 | Name string 31 | Run string 32 | Shell string 33 | With With 34 | TimeoutMinutes any `yaml:"timeout-minutes"` 35 | } 36 | 37 | type With map[string]string 38 | 39 | func (w With) UnmarshalYAML(b []byte) error { 40 | a := map[string]any{} 41 | if err := yaml.Unmarshal(b, &a); err != nil { 42 | return err //nolint:wrapcheck 43 | } 44 | for k, v := range a { 45 | switch c := v.(type) { 46 | case string: 47 | w[k] = c 48 | case int: 49 | w[k] = strconv.Itoa(c) 50 | case float64: 51 | w[k] = fmt.Sprint(c) 52 | case bool: 53 | w[k] = strconv.FormatBool(c) 54 | default: 55 | return fmt.Errorf("unsupported type: %T", c) 56 | } 57 | } 58 | return nil 59 | } 60 | 61 | type Action struct { 62 | Runs *Runs 63 | } 64 | 65 | type Runs struct { 66 | Image string 67 | Steps []*Step 68 | } 69 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | extends: [ 3 | "github>suzuki-shunsuke/renovate-config#3.2.1", 4 | "github>suzuki-shunsuke/renovate-config:nolimit#3.2.1", 5 | "github>aquaproj/aqua-renovate-config#2.8.1", 6 | "github>aquaproj/aqua-renovate-config:file#2.8.1(aqua/imports/.*\\.ya?ml)", 7 | ], 8 | } 9 | -------------------------------------------------------------------------------- /scripts/coverage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | set -o pipefail 5 | 6 | cd "$(dirname "$0")/.." 7 | 8 | if [ $# -eq 0 ]; then 9 | target="$(go list ./... | fzf)" 10 | profile=.coverage/$target/coverage.txt 11 | mkdir -p .coverage/"$target" 12 | elif [ $# -eq 1 ]; then 13 | target=$1 14 | mkdir -p .coverage/"$target" 15 | profile=.coverage/$target/coverage.txt 16 | target=./$target 17 | else 18 | echo "too many arguments are given: $*" >&2 19 | exit 1 20 | fi 21 | 22 | go test "$target" -coverprofile="$profile" -covermode=atomic 23 | go tool cover -html="$profile" 24 | -------------------------------------------------------------------------------- /scripts/generate-usage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | help=$(ghalint help-all) 8 | 9 | echo -n "# Usage 10 | 11 | 12 | 13 | $help" > docs/usage.md 14 | -------------------------------------------------------------------------------- /test-action.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | description: test 3 | inputs: 4 | github_token: 5 | description: "" 6 | required: false 7 | default: ${{ github.token }} 8 | runs: 9 | using: composite 10 | steps: 11 | # checkout_persist_credentials_should_be_false 12 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 13 | 14 | # action_ref_should_be_full_length_commit_sha 15 | - uses: tibdex/github-app-token@v2.1.0 16 | id: token1 17 | with: 18 | app_id: ${{secrets.APP_ID}} 19 | private_key: ${{secrets.PRIVATE_KEY}} 20 | # github_app_should_limit_repositories 21 | # github_app_should_limit_permissions 22 | 23 | - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 24 | id: token2 25 | with: 26 | app_id: ${{secrets.APP_ID}} 27 | private_key: ${{secrets.PRIVATE_KEY}} 28 | repositories: >- 29 | ["${{github.event.repository.name}}"] 30 | permissions: >- 31 | { 32 | "contents": "write" 33 | } 34 | 35 | - uses: actions/create-github-app-token@46e4a501e119d39574a54e53a06c9a705efc55c9 # v1.6.1 36 | id: token3 37 | with: 38 | app-id: ${{vars.APP_ID}} 39 | private-key: ${{secrets.PRIVATE_KEY}} 40 | owner: ${{github.repository_owner}} 41 | # github_app_should_limit_repositories 42 | 43 | - uses: actions/create-github-app-token@46e4a501e119d39574a54e53a06c9a705efc55c9 # v1.6.1 44 | id: token4 45 | with: 46 | app-id: ${{vars.APP_ID}} 47 | private-key: ${{secrets.PRIVATE_KEY}} 48 | owner: ${{github.repository_owner}} 49 | repositories: "repo1,repo2" 50 | 51 | - uses: actions/create-github-app-token@46e4a501e119d39574a54e53a06c9a705efc55c9 # v1.6.1 52 | id: token5 53 | with: 54 | app-id: ${{vars.APP_ID}} 55 | private-key: ${{secrets.PRIVATE_KEY}} 56 | 57 | - run: echo hello 58 | # action_shell_is_required 59 | -------------------------------------------------------------------------------- /test-workflow.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: pull_request 3 | env: 4 | # Workflow should not set secrets to environment variables 5 | FOO: bar 6 | GITHUB_TOKEN: ${{github.token}} 7 | API_KEY: ${{secrets.API_KEY}} 8 | jobs: 9 | release: 10 | # action_ref_should_be_full_length_commit_sha 11 | uses: suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@v0.5.0 12 | # deny_inherit_secrets 13 | secrets: inherit 14 | permissions: {} 15 | 16 | foo: 17 | # job_permissions 18 | runs-on: ubuntu-latest 19 | env: 20 | # job_secrets 21 | FOO: bar 22 | GITHUB_TOKEN: ${{github.token}} 23 | API_KEY: ${{secrets.API_KEY}} 24 | steps: 25 | # checkout_persist_credentials_should_be_false 26 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 27 | - run: echo hello 28 | - run: echo hello 29 | 30 | read-all: 31 | runs-on: ubuntu-latest 32 | # deny_read_all_permission 33 | permissions: read-all 34 | env: 35 | # If the job has only one job, it's okay to set secrets to job's environment variables 36 | FOO: bar 37 | GITHUB_TOKEN: ${{github.token}} 38 | API_KEY: ${{secrets.API_KEY}} 39 | steps: 40 | - run: echo hello 41 | 42 | write-all: 43 | runs-on: ubuntu-latest 44 | # deny_write_all_permission 45 | permissions: write-all 46 | steps: 47 | # action_ref_should_be_full_length_commit_sha 48 | - uses: tibdex/github-app-token@v2.1.0 49 | id: token1 50 | with: 51 | app_id: ${{secrets.APP_ID}} 52 | private_key: ${{secrets.PRIVATE_KEY}} 53 | # github_app_should_limit_repositories 54 | # github_app_should_limit_permissions 55 | 56 | - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 57 | id: token2 58 | with: 59 | app_id: ${{secrets.APP_ID}} 60 | private_key: ${{secrets.PRIVATE_KEY}} 61 | repositories: >- 62 | ["${{github.event.repository.name}}"] 63 | permissions: >- 64 | { 65 | "contents": "write" 66 | } 67 | 68 | - uses: actions/create-github-app-token@46e4a501e119d39574a54e53a06c9a705efc55c9 # v1.6.1 69 | id: token3 70 | with: 71 | app-id: ${{vars.APP_ID}} 72 | private-key: ${{secrets.PRIVATE_KEY}} 73 | owner: ${{github.repository_owner}} 74 | # github_app_should_limit_repositories 75 | 76 | - uses: actions/create-github-app-token@46e4a501e119d39574a54e53a06c9a705efc55c9 # v1.6.1 77 | id: token4 78 | with: 79 | app-id: ${{vars.APP_ID}} 80 | private-key: ${{secrets.PRIVATE_KEY}} 81 | owner: ${{github.repository_owner}} 82 | repositories: "repo1,repo2" 83 | 84 | - uses: actions/create-github-app-token@46e4a501e119d39574a54e53a06c9a705efc55c9 # v1.6.1 85 | id: token5 86 | with: 87 | app-id: ${{vars.APP_ID}} 88 | private-key: ${{secrets.PRIVATE_KEY}} 89 | 90 | container-job: 91 | runs-on: ubuntu-latest 92 | permissions: {} 93 | container: 94 | image: node:latest # deny_job_container_latest_image 95 | --------------------------------------------------------------------------------