Repository: suzuki-shunsuke/ghalint Branch: main Commit: d507057d2a3d Files: 113 Total size: 161.4 KB Directory structure: gitextract_p8kwv86v/ ├── .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 │ ├── go-licenses.yaml │ ├── golangci-lint.yaml │ ├── goreleaser.yaml │ ├── reviewdog.yaml │ ├── syft.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/ │ ├── action/ │ │ └── find.go │ ├── cli/ │ │ ├── app.go │ │ ├── experiment/ │ │ │ ├── command.go │ │ │ └── validateinput/ │ │ │ └── command.go │ │ ├── gflags/ │ │ │ └── gflags.go │ │ ├── run.go │ │ └── run_action.go │ ├── config/ │ │ ├── config.go │ │ └── config_test.go │ ├── controller/ │ │ ├── act/ │ │ │ ├── controller.go │ │ │ └── run.go │ │ ├── controller.go │ │ ├── run.go │ │ └── schema/ │ │ ├── action.go │ │ ├── controller.go │ │ ├── job.go │ │ ├── reusable_workflow.go │ │ ├── run.go │ │ ├── step.go │ │ └── workflow.go │ ├── github/ │ │ ├── github.go │ │ └── keyring.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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository github: - suzuki-shunsuke ================================================ FILE: .github/workflows/actionlint.yaml ================================================ --- name: actionlint on: pull_request jobs: actionlint: runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read pull-requests: write steps: - uses: suzuki-shunsuke/actionlint-action@8297c48141939cdcf80a8341c3cd525b300e36db # v0.1.2 ================================================ FILE: .github/workflows/autofix.yaml ================================================ --- name: autofix.ci on: pull_request permissions: {} jobs: autofix: runs-on: ubuntu-24.04 permissions: {} timeout-minutes: 15 steps: - uses: suzuki-shunsuke/go-autofix-action@ba716cebb7767055bdde0e62bb91d715bde39ab2 # v0.1.12 with: aqua_version: v2.59.0 ================================================ FILE: .github/workflows/check-commit-signing.yaml ================================================ --- name: Check if all commits are signed on: pull_request_target: branches: [main] concurrency: group: ${{ github.workflow }}--${{ github.head_ref }} # github.ref is unavailable in case of pull_request_target cancel-in-progress: true jobs: check-commit-signing: uses: suzuki-shunsuke/check-commit-signing-workflow/.github/workflows/check.yaml@547eee345f56310a656f271ec5eaa900af46b0fb # v0.1.0 permissions: contents: read pull-requests: write ================================================ FILE: .github/workflows/release.yaml ================================================ --- name: Release on: push: tags: [v*] jobs: release: uses: suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@b2ecf54e35aca9e9689e761f5bd6d1ad9542a8cf # v8.0.0 with: aqua_version: v2.59.0 go-version-file: go.mod permissions: contents: write id-token: write actions: read attestations: write ================================================ FILE: .github/workflows/test.yaml ================================================ --- name: test on: pull_request concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: {} jobs: test: uses: ./.github/workflows/workflow_call_test.yaml permissions: pull-requests: write contents: read status-check: runs-on: ubuntu-24.04 if: always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) timeout-minutes: 10 permissions: {} needs: - test steps: - run: exit 1 ================================================ FILE: .github/workflows/workflow_call_test.yaml ================================================ --- name: test (workflow_call) on: workflow_call permissions: {} jobs: test: uses: suzuki-shunsuke/go-test-full-workflow/.github/workflows/test.yaml@e442a17816baa8a940edc1544cc6e739d870e165 # v5.0.2 with: aqua_version: v2.59.0 golangci-lint-timeout: 120s permissions: pull-requests: write contents: read ================================================ FILE: .gitignore ================================================ dist .coverage third_party_licenses ================================================ FILE: .golangci.yml ================================================ version: "2" linters: default: all disable: - depguard - err113 - exhaustruct - godot - ireturn - lll - musttag - nlreturn - tagalign - tagliatelle - varnamelen - wsl - wsl_v5 - noinlineerr exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling paths: - third_party$ - builtin$ - examples$ rules: - path: _test\.go linters: - goconst formatters: enable: - gci - gofmt - gofumpt - goimports exclusions: generated: lax paths: - third_party$ - builtin$ - examples$ ================================================ FILE: .goreleaser.yml ================================================ version: 2 project_name: ghalint archives: - format_overrides: - goos: windows formats: [zip] files: - LICENSE - README.md - third_party_licenses/**/* env: - GO111MODULE=on before: hooks: - go mod tidy sboms: - id: default disable: false builds: - main: ./cmd/ghalint binary: ghalint env: - CGO_ENABLED=0 goos: - windows - darwin - linux goarch: - amd64 - arm64 release: prerelease: "true" # we update release note manually before releasing header: | [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}} homebrew_casks: - # NOTE: make sure the url_template, the token and given repo (github or gitlab) owner and name are from the # same kind. We will probably unify this in the next major version like it is done with scoop. repository: owner: suzuki-shunsuke name: homebrew-ghalint # The project name and current git tag are used in the format string. commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" homepage: https://github.com/suzuki-shunsuke/ghalint description: GitHub Actions linter license: MIT skip_upload: true hooks: post: install: | if OS.mac? system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/ghalint"] end scoops: - description: GitHub Actions linter for security best practices. license: MIT skip_upload: true repository: owner: suzuki-shunsuke name: scoop-bucket ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Please read the following document. - https://github.com/suzuki-shunsuke/oss-contribution-guide ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Shunsuke Suzuki Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # ghalint [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/suzuki-shunsuke/ghalint) [Install](docs/install.md) | [Policies](#policies) | [How to use](#how-to-use) | [Configuration](#configuration) GitHub Actions linter for security best practices. ```console $ ghalint run 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 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 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 ``` ghalint is a command line tool to check GitHub Actions Workflows and action.yaml for security policy compliance. ## :bulb: We've ported ghalint to lintnet module - https://lintnet.github.io/ - https://github.com/lintnet-modules/ghalint lintnet is a general purpose linter powered by Jsonnet. We've ported ghalint to [the lintnet module](https://github.com/lintnet-modules/ghalint), so you can migrate ghalint to lintnet! ## Policies ### 1. Workflow Policies 1. [job_permissions](docs/policies/001.md): All jobs should have `permissions` 1. [deny_read_all_permission](docs/policies/002.md): `read-all` permission should not be used 1. [deny_write_all_permission](docs/policies/003.md): `write-all` permission should not be used 1. [deny_inherit_secrets](docs/policies/004.md): `secrets: inherit` should not be used 1. [workflow_secrets](docs/policies/005.md): Workflow should not set secrets to environment variables 1. [job_secrets](docs/policies/006.md): Job should not set secrets to environment variables 1. [deny_job_container_latest_image](docs/policies/007.md): Job's container image tag should not be `latest` 1. [action_ref_should_be_full_length_commit_sha](docs/policies/008.md): action's ref should be full length commit SHA 1. [github_app_should_limit_repositories](docs/policies/009.md): GitHub Actions issuing GitHub Access tokens from GitHub Apps should limit repositories 1. [github_app_should_limit_permissions](docs/policies/010.md): GitHub Actions issuing GitHub Access tokens from GitHub Apps should limit permissions 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) 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` ### 2. Action Policies 1. [action_ref_should_be_full_length_commit_sha](docs/policies/008.md): action's ref should be full length commit SHA 1. [github_app_should_limit_repositories](docs/policies/009.md): GitHub Actions issuing GitHub Access tokens from GitHub Apps should limit repositories 1. [github_app_should_limit_permissions](docs/policies/010.md): GitHub Actions issuing GitHub Access tokens from GitHub Apps should limit permissions 1. [action_shell_is_required](docs/policies/011.md): `shell` is required if `run` is set 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` ## How to use ### 1. Validate workflows Run the command `ghalint run` on the repository root directory. ```sh ghalint run ``` Then ghalint validates workflow files `^\.github/workflows/.*\.ya?ml$`. ### 2. Validate action.yaml Run the command `ghalint run-action`. ```sh ghalint run-action ``` The alias `act` is available. ```sh ghalint act ``` Then ghalint validates action files `^([^/]+/){0,3}action\.ya?ml$` on the current directory. You can also specify file paths. ```sh ghalint act foo/action.yaml bar/action.yml ``` ## Configuration file Configuration file path: `^(\.|\.github/)?ghalint\.ya?ml$` You can specify the configuration file with the command line option `-config (-c)` or the environment variable `GHALINT_CONFIG`. ```sh ghalint -c foo.yaml run ``` ### JSON Schema - [ghalint.json](json-schema/ghalint.json) - https://raw.githubusercontent.com/suzuki-shunsuke/ghalint/refs/heads/main/json-schema/ghalint.json 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. ```sh ajv --spec=draft2020 -s json-schema/ghalint.json -d ghalint.yaml ``` #### Input Complementation by YAML Language Server [Please see the comment too.](https://github.com/szksh-lab/.github/issues/67#issuecomment-2564960491) Version: `main` ```yaml # yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/ghalint/main/json-schema/ghalint.json ``` Or pinning version: ```yaml # yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/ghalint/v1.2.1/json-schema/ghalint.json ``` ### Disable policies You can disable the following policies. - [deny_inherit_secrets](docs/policies/004.md) - [job_secrets](docs/policies/006.md) - [action_ref_should_be_full_length_commit_sha](docs/policies/008.md) - [github_app_should_limit_repositories](docs/policies/009.md) e.g. ```yaml excludes: - policy_name: deny_inherit_secrets workflow_file_path: .github/workflows/actionlint.yaml job_name: actionlint - policy_name: job_secrets workflow_file_path: .github/workflows/actionlint.yaml job_name: actionlint - policy_name: action_ref_should_be_full_length_commit_sha action_name: slsa-framework/slsa-github-generator - policy_name: github_app_should_limit_repositories workflow_file_path: .github/workflows/test.yaml job_name: test step_id: create_token ``` ## Environment variables - `GHALINT_CONFIG`: Configuration file path - `GHALINT_LOG_LEVEL`: Log level One of `error`, `warn`, `info` (default), `debug` - `GHALINT_LOG_COLOR`: Configure log color. One of `auto` (default), `always`, and `never`. 💡 If you want to enable log color in GitHub Actions, please try `GHALINT_LOG_COLOR=always` ```yaml env: GHALINT_LOG_COLOR: always ``` AS IS image TO BE image ## How does it works? ghalint reads GitHub Actions Workflows `^\.github/workflows/.*\.ya?ml$` and validates them. If there are violatation ghalint outputs error logs and fails. If there is no violation ghalint succeeds. ## Experimental Features > [!WARNING] > These features are experimental, meaning they are unstable and may be changed or removed at minor or patch versions. ### Validate inputs of actions and reusable workflows [#904](https://github.com/suzuki-shunsuke/ghalint/pull/904) ```console $ ghalint exp validate-input ERRO[0000] invalid input key action=suzuki-shunsuke/actionlint-action@c8d3c0dcc9152f1d1c7d4a38cbf4953c3a55953d input_key=actionlint_option job_key=actionlint program=ghalint required_inputs= valid_inputs="sparse-checkout, actionlint_options" version=v1.0.0-local workflow_file_path=.github/workflows/actionlint.yaml ``` `ghalint exp validate-input` command validates inputs of actions and reusable workflows. It fails if required inputs aren't given or unknown inputs are passed. > [!WARNING] > [Actions using `required: true` will not automatically return an error if the input is not specified.](https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#inputs) > This means if `ghalint exp validate-input` fails as required inputs aren't given, the action may work without any problem. > Now `ghalint exp validate-input` can't ignore those errors. > Ideally, actions should be fixed. By default, the following files are validated. ``` .github/workflows/*.yaml .github/workflows/*.yml action.yaml action.yml */action.yaml */action.yml */*/action.yaml */*/action.yml */*/*/action.yaml */*/*/action.yml ``` This command uses a GitHub access token with `contents:read` permission to download actions and reusable workflows. It downloads them into `XDG_DATA_HOME/ghalint`. You can pass a GitHub access token by environment variables `GITHUB_TOKEN` or `GHALINT_GITHUB_TOKEN`. You can also manage it by secret stores such as GNOME Keyring, Windows Credential Manager, and macOS Keychain. ```sh ghalint exp token set [-stdin] ``` ```sh ghalint exp token rm # Remove a token from secret store ``` ## LICENSE [MIT](LICENSE) ================================================ FILE: _typos.toml ================================================ [default.extend-words] ERRO = "ERRO" intoto = "intoto" ================================================ FILE: aqua/aqua-checksums.json ================================================ { "checksums": [ { "id": "github_release/github.com/anchore/syft/v1.44.0/syft_1.44.0_darwin_amd64.tar.gz", "checksum": "C40ECE5407927327F94F35901727DBC604B46857E04F04EC94A310845FB71BDE", "algorithm": "sha256" }, { "id": "github_release/github.com/anchore/syft/v1.44.0/syft_1.44.0_darwin_arm64.tar.gz", "checksum": "24E4D34078AE81DA7C82539616F0CCAC3E226CF4F74A38CE6FB3463619E50A55", "algorithm": "sha256" }, { "id": "github_release/github.com/anchore/syft/v1.44.0/syft_1.44.0_linux_amd64.tar.gz", "checksum": "0E91737AEE2B5BAF1D255B959630194A302335D848FF97BB07921EB6205B5F5A", "algorithm": "sha256" }, { "id": "github_release/github.com/anchore/syft/v1.44.0/syft_1.44.0_linux_arm64.tar.gz", "checksum": "6F6CDCDC695721D91CE756E3B5BC3E3416599C464101F5E32E9C3F33054EE6D9", "algorithm": "sha256" }, { "id": "github_release/github.com/anchore/syft/v1.44.0/syft_1.44.0_windows_amd64.zip", "checksum": "195E786EB84EC145854F20528992E86637C77D1968731DFE6CE850C90E28F47A", "algorithm": "sha256" }, { "id": "github_release/github.com/crate-ci/typos/v1.46.2/typos-v1.46.2-aarch64-apple-darwin.tar.gz", "checksum": "4B15EE9548CD68CF22D6E67AF8A12CEB608EA4DBC34E0346792D09994222D694", "algorithm": "sha256" }, { "id": "github_release/github.com/crate-ci/typos/v1.46.2/typos-v1.46.2-aarch64-unknown-linux-musl.tar.gz", "checksum": "311F2A15E8433C895CD9EE3198530BBFF552F59609EBA739F5BD9CEB2A2C0887", "algorithm": "sha256" }, { "id": "github_release/github.com/crate-ci/typos/v1.46.2/typos-v1.46.2-x86_64-apple-darwin.tar.gz", "checksum": "3652F90D82D38F64E40C1791D2D82209979048EF3ABD715B0EB1488CF483CE1D", "algorithm": "sha256" }, { "id": "github_release/github.com/crate-ci/typos/v1.46.2/typos-v1.46.2-x86_64-pc-windows-msvc.zip", "checksum": "DDC4AE26822E806CE84BC410643D02A3DAC53AAC9AB2A5F389624418C5654A17", "algorithm": "sha256" }, { "id": "github_release/github.com/crate-ci/typos/v1.46.2/typos-v1.46.2-x86_64-unknown-linux-musl.tar.gz", "checksum": "D68C1A9C5ABD8DE11F7749EDFA414087C8BC828E89064714487D23C89F36B06E", "algorithm": "sha256" }, { "id": "github_release/github.com/golangci/golangci-lint/v2.12.2/golangci-lint-2.12.2-darwin-amd64.tar.gz", "checksum": "F6F06D94B6241521C53D15450C5209B028270BF966F842AFB11C030C79F5BC16", "algorithm": "sha256" }, { "id": "github_release/github.com/golangci/golangci-lint/v2.12.2/golangci-lint-2.12.2-darwin-arm64.tar.gz", "checksum": "A9C54498731B3128F79E090BE6110F3E5FFFCCC617B08142ED244D4126C73F29", "algorithm": "sha256" }, { "id": "github_release/github.com/golangci/golangci-lint/v2.12.2/golangci-lint-2.12.2-linux-amd64.tar.gz", "checksum": "8DF580D2670FED8FA984AAC0507099AF8DF275E665215F5C7A2AE3943893A553", "algorithm": "sha256" }, { "id": "github_release/github.com/golangci/golangci-lint/v2.12.2/golangci-lint-2.12.2-linux-arm64.tar.gz", "checksum": "44CD40A8C76C86755375ADFEEA52CFD3533CB43D7BD647771E0AE065E166DF3A", "algorithm": "sha256" }, { "id": "github_release/github.com/golangci/golangci-lint/v2.12.2/golangci-lint-2.12.2-windows-amd64.zip", "checksum": "BD42E3EBC8CB4ECECB86941983BAAF1DC221BBB04D838E94CE63B49CC91E02BB", "algorithm": "sha256" }, { "id": "github_release/github.com/golangci/golangci-lint/v2.12.2/golangci-lint-2.12.2-windows-arm64.zip", "checksum": "947B9A5BF762D465710B376C156F0184ABB2168378B0826AF1899E0EE7183742", "algorithm": "sha256" }, { "id": "github_release/github.com/goreleaser/goreleaser/v2.15.4/goreleaser_Darwin_all.tar.gz", "checksum": "82D730F3366350C90D7E5DF3CF9E8E425FD1C84BF7D7E3E564F92D97C5EA9EA4", "algorithm": "sha256" }, { "id": "github_release/github.com/goreleaser/goreleaser/v2.15.4/goreleaser_Linux_arm64.tar.gz", "checksum": "DE01CA1497571E9B348413CD2E7F74BE49B8D57696AE386F7EEDD06176544A88", "algorithm": "sha256" }, { "id": "github_release/github.com/goreleaser/goreleaser/v2.15.4/goreleaser_Linux_x86_64.tar.gz", "checksum": "AAE00C71A4A6D55E08CCE9273A1516BDCE33C1E07CFFB7E502FA6FEC4377DEDE", "algorithm": "sha256" }, { "id": "github_release/github.com/goreleaser/goreleaser/v2.15.4/goreleaser_Windows_arm64.zip", "checksum": "10227D9DE3EB846F0E58529C22E75DCBD713B67879A7F83912DE7ABE658C5FD7", "algorithm": "sha256" }, { "id": "github_release/github.com/goreleaser/goreleaser/v2.15.4/goreleaser_Windows_x86_64.zip", "checksum": "146695F49717DFD79D64D5D6F4B1D25E2B56D73E723BBF68A8DC13CE5CF69693", "algorithm": "sha256" }, { "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Darwin_arm64.tar.gz", "checksum": "C28DEF83AF6C5AA8728D6D18160546AFD3E5A219117715A2C6C023BD16F14D10", "algorithm": "sha256" }, { "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Darwin_x86_64.tar.gz", "checksum": "9BAADB110C87F22C55688CF4A966ACCE3006C7A4A962732D6C8B45234C454C6E", "algorithm": "sha256" }, { "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Linux_arm64.tar.gz", "checksum": "B6AFF657B39E9267A258E8FA66D616F7221AEC5975D0251DAC76043BAD0FA177", "algorithm": "sha256" }, { "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Linux_x86_64.tar.gz", "checksum": "AD5CE7D5FFA52AAA7EC8710A8FA764181B6CECAAB843CC791E1CCE1680381569", "algorithm": "sha256" }, { "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Windows_arm64.tar.gz", "checksum": "72ABE9907454C5697777CFFF1D0D03DB8F5A9FD6950C609CA397A90D41AB65D7", "algorithm": "sha256" }, { "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Windows_x86_64.tar.gz", "checksum": "97C733E492DEC1FD83B9342C25A384D5AB6EBFA72B6978346E9A436CAD1853F6", "algorithm": "sha256" }, { "id": "github_release/github.com/sigstore/cosign/v3.0.6/cosign-darwin-amd64", "checksum": "4C3E7AF8372D3CA3296E62FA56F23FCBB5721CC6AC1827900D398F110D7CD280", "algorithm": "sha256" }, { "id": "github_release/github.com/sigstore/cosign/v3.0.6/cosign-darwin-arm64", "checksum": "5FADD012AE6381A6A29FF86A7D39AA873878852F1073FC90B15995961ECFB084", "algorithm": "sha256" }, { "id": "github_release/github.com/sigstore/cosign/v3.0.6/cosign-linux-amd64", "checksum": "C956E5DFCAC53D52BCF058360D579472F0C1D2D9B69F55209E256FE7783F4C74", "algorithm": "sha256" }, { "id": "github_release/github.com/sigstore/cosign/v3.0.6/cosign-linux-arm64", "checksum": "BEDAC92E8C3729864E13D4A17048007CFAFA79D5DECA993A43A90FFE018EF2B8", "algorithm": "sha256" }, { "id": "github_release/github.com/sigstore/cosign/v3.0.6/cosign-windows-amd64.exe", "checksum": "9B85A88EBFF2D9DD30FF4984A6F61F2CEDC232DD87D81FA7F2FF3C0ED96C241C", "algorithm": "sha256" }, { "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_darwin_amd64.tar.gz", "checksum": "768B8517666A15D25A6870307231416016FC1165F8A1C1743B6AACDBAC7A5FAC", "algorithm": "sha256" }, { "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_darwin_arm64.tar.gz", "checksum": "FBD7DADDBB65ABD0DE5C6B898F2219588C7D1A71DF6808137D0A628858E7777B", "algorithm": "sha256" }, { "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_linux_amd64.tar.gz", "checksum": "40BC7B5F472211B22C4786D55F6859FA8093F1A373FF40A2DCCD29BD3D11CF96", "algorithm": "sha256" }, { "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_linux_arm64.tar.gz", "checksum": "691EB4CC3929A5E065F7C2F977CEE8306D817CB0F8DE9D5B4B4ED38C027CEC41", "algorithm": "sha256" }, { "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_windows_amd64.zip", "checksum": "4452010897556935E3F94A11AF2B2889563E05073A6DEA72FCF40B83B7F4AE5B", "algorithm": "sha256" }, { "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_windows_arm64.zip", "checksum": "156D02F4E784E237B0661464D6FF76D6C4EFC4E01F858F8A9734364CD41BC98E", "algorithm": "sha256" }, { "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.6/ghalint_1.5.6_darwin_amd64.tar.gz", "checksum": "D2A0E8605333068065DCF4C9B7B7A24891EDA1750AC01FB755DFBA426A390883", "algorithm": "sha256" }, { "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.6/ghalint_1.5.6_darwin_arm64.tar.gz", "checksum": "1262CAC411E27B4653E6B66B7B06580EBCC2026FDD903E12E6CB0E4591639DE6", "algorithm": "sha256" }, { "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.6/ghalint_1.5.6_linux_amd64.tar.gz", "checksum": "98EE0E3330DE7286F470D1E89C03FF7CE70D7A5998BA0F15969C400447BE579C", "algorithm": "sha256" }, { "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.6/ghalint_1.5.6_linux_arm64.tar.gz", "checksum": "203A22C70B40BB161626973AD2A8DD06AEB736699FC8E03DD425DEE8FF3406E6", "algorithm": "sha256" }, { "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.6/ghalint_1.5.6_windows_amd64.zip", "checksum": "109EA9B39C8E263CEF924BD3B4FE5505964204F934CA60D986F4090D01A99BA5", "algorithm": "sha256" }, { "id": "github_release/github.com/suzuki-shunsuke/ghalint/v1.5.6/ghalint_1.5.6_windows_arm64.zip", "checksum": "C1219CAE104EA418A1CB4E7A02526A3FAD384C0788FA3540A86B316BB074D0D8", "algorithm": "sha256" }, { "id": "registries/github_content/github.com/aquaproj/aqua-registry/v4.513.1/registry.yaml", "checksum": "2F2D35FCFD79012DD744CD867EDFEDED4954A9382191C35BB25D776C35DECF3A", "algorithm": "sha256" } ] } ================================================ FILE: aqua/aqua.yaml ================================================ --- # yaml-language-server: $schema=https://raw.githubusercontent.com/aquaproj/aqua/main/json-schema/aqua-yaml.json # aqua - Declarative CLI Version Manager # https://aquaproj.github.io/ checksum: enabled: true require_checksum: true registries: - type: standard ref: v4.513.1 # renovate: depName=aquaproj/aqua-registry import_dir: imports ================================================ FILE: aqua/imports/cmdx.yaml ================================================ packages: - name: suzuki-shunsuke/cmdx@v2.0.2 ================================================ FILE: aqua/imports/cosign.yaml ================================================ packages: - name: sigstore/cosign@v3.0.6 ================================================ FILE: aqua/imports/ghalint.yaml ================================================ packages: - name: suzuki-shunsuke/ghalint@v1.5.6 ================================================ FILE: aqua/imports/go-licenses.yaml ================================================ packages: - name: google/go-licenses@v2.0.1 ================================================ FILE: aqua/imports/golangci-lint.yaml ================================================ packages: - name: golangci/golangci-lint@v2.12.2 ================================================ FILE: aqua/imports/goreleaser.yaml ================================================ packages: - name: goreleaser/goreleaser@v2.15.4 ================================================ FILE: aqua/imports/reviewdog.yaml ================================================ packages: - name: reviewdog/reviewdog@v0.21.0 ================================================ FILE: aqua/imports/syft.yaml ================================================ packages: - name: anchore/syft@v1.44.0 ================================================ FILE: aqua/imports/typos.yaml ================================================ packages: - name: crate-ci/typos@v1.46.2 ================================================ FILE: cmd/gen-jsonschema/main.go ================================================ package main import ( "fmt" "log" "github.com/suzuki-shunsuke/gen-go-jsonschema/jsonschema" "github.com/suzuki-shunsuke/ghalint/pkg/config" ) func main() { if err := core(); err != nil { log.Fatal(err) } } func core() error { if err := jsonschema.Write(&config.Config{}, "json-schema/ghalint.json"); err != nil { return fmt.Errorf("create or update a JSON Schema: %w", err) } return nil } ================================================ FILE: cmd/ghalint/main.go ================================================ package main import ( "github.com/suzuki-shunsuke/ghalint/pkg/cli" "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" ) var version = "" func main() { urfave.Main("ghalint", version, cli.Run) } ================================================ FILE: cmdx.yaml ================================================ --- # cmdx - task runner # https://github.com/suzuki-shunsuke/cmdx tasks: - name: test short: t description: test usage: test script: go test ./... -race -covermode=atomic - name: coverage short: c description: coverage test usage: coverage test script: "bash scripts/coverage.sh {{.target}}" args: - name: target - name: vet short: v description: go vet usage: go vet script: go vet ./... - name: lint short: l description: lint the go code usage: lint the go code script: golangci-lint run - name: install short: i description: go install usage: go install script: | sha="" if git diff --quiet; then sha=$(git rev-parse HEAD) fi go install \ -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 '+')" \ ./cmd/ghalint - name: usage description: Update usage.md usage: Update usage.md script: bash scripts/generate-usage.sh - name: js description: Generate JSON Schema usage: Generate JSON Schema script: "go run ./cmd/gen-jsonschema" ================================================ FILE: docs/codes/001.md ================================================ # parse a workflow file as YAML: EOF ```console $ ghalint run 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 ``` This error occurs if the workflow file has no YAML node. Probably this means the YAML file is empty or all codes are empty lines or commented out. ## How to solve 1. Fix the workflow file 1. Move or rename the workflow file to exclude it from targets of ghalint If this error occurs, probably the YAML file is invalid as a GitHub Actions Workflow. So this isn't a bug of ghalint. Please fix the workflow file. ref. https://github.com/suzuki-shunsuke/ghalint/issues/197#issuecomment-1782032909 image > [Error: .github#L1](https://github.com/suzuki-shunsuke/test-github-action/commit/52b75ce5cf55aeff15394fb0cabdbaaa28fab847#annotation_15218437727) > No event triggers defined in `on` ================================================ FILE: docs/codes/002.md ================================================ # read a configuration file: parse configuration file as YAML: EOF ```console $ ghalint run FATA[0000] ghalint failed config_file=ghalint.yaml error="read a configuration file: parse configuration file as YAML: EOF" ``` This error occurs if the configuration file has no YAML node. Probably this means the YAML file is empty or all codes are empty lines or commented out. ## How to solve Please fix the configuration file. ================================================ FILE: docs/install.md ================================================ # Install ghalint is written in Go. So you only have to install a binary in your `PATH`. There are some ways to install ghalint. 1. [Homebrew](#homebrew) 1. [Scoop](#scoop) 1. [aqua](#aqua) 1. [mise](#mise) 1. [GitHub Releases](#github-releases) 1. [Build an executable binary from source code yourself using Go](#build-an-executable-binary-from-source-code-yourself-using-go) ## Homebrew You can install ghalint using [Homebrew](https://brew.sh/). ```sh brew install ghalint ``` Or ```sh brew install suzuki-shunsuke/ghalint/ghalint ``` ## Scoop You can install ghalint using [Scoop](https://scoop.sh/). ```sh scoop bucket add suzuki-shunsuke https://github.com/suzuki-shunsuke/scoop-bucket scoop install ghalint ``` ## aqua You can install ghalint using [aqua](https://aquaproj.github.io/). ```sh aqua g -i suzuki-shunsuke/ghalint ``` ## mise You can install ghalint using [mise](https://github.com/jdx/mise). ```sh mise use -g ghalint@latest ``` ## Build an executable binary from source code yourself using Go ```sh go install github.com/suzuki-shunsuke/ghalint/cmd/ghalint@latest ``` ## GitHub Releases You can download an asset from [GitHub Releases](https://github.com/suzuki-shunsuke/ghalint/releases). Please unarchive it and install a pre built binary into `$PATH`. ### Verify downloaded assets from GitHub Releases You can verify downloaded assets using some tools. 1. [GitHub CLI](https://cli.github.com/) 1. [slsa-verifier](https://github.com/slsa-framework/slsa-verifier) 1. [Cosign](https://github.com/sigstore/cosign) ### 1. GitHub CLI You can install GitHub CLI by aqua. ```sh aqua g -i cli/cli ``` ```sh version=v1.2.0 asset=ghalint_darwin_arm64.tar.gz gh release download -R suzuki-shunsuke/ghalint "$version" -p "$asset" gh attestation verify "$asset" \ -R suzuki-shunsuke/ghalint \ --signer-workflow suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml ``` ### 2. slsa-verifier You can install slsa-verifier by aqua. ```sh aqua g -i slsa-framework/slsa-verifier ``` ```sh version=v1.2.0 asset=ghalint_darwin_arm64.tar.gz gh release download -R suzuki-shunsuke/ghalint "$version" -p "$asset" -p multiple.intoto.jsonl slsa-verifier verify-artifact "$asset" \ --provenance-path multiple.intoto.jsonl \ --source-uri github.com/suzuki-shunsuke/ghalint \ --source-tag "$version" ``` ### 3. Cosign You can install Cosign by aqua. ```sh aqua g -i sigstore/cosign ``` ```sh version=v1.2.0 checksum_file="ghalint_${version#v}_checksums.txt" asset=ghalint_darwin_arm64.tar.gz gh release download "$version" \ -R suzuki-shunsuke/ghalint \ -p "$asset" \ -p "$checksum_file" \ -p "${checksum_file}.pem" \ -p "${checksum_file}.sig" cosign verify-blob \ --signature "${checksum_file}.sig" \ --certificate "${checksum_file}.pem" \ --certificate-identity-regexp 'https://github\.com/suzuki-shunsuke/go-release-workflow/\.github/workflows/release\.yaml@.*' \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ "$checksum_file" cat "$checksum_file" | sha256sum -c --ignore-missing ``` ================================================ FILE: docs/policies/001.md ================================================ # job_permissions All jobs should have the field [permissions](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idpermissions). ## Examples :x: ```yaml jobs: foo: # The job doesn't have `permissions` runs-on: ubuntu-latest steps: - run: echo hello ``` :o: ```yaml jobs: foo: runs-on: ubuntu-latest permissions: {} # Set permissions steps: - run: echo hello ``` ## Why? For least privilege. ## Exceptions 1. workflow's `permissions` is empty `{}` ```yaml permissions: {} # empty permissions jobs: foo: # The job is missing `permissions`, but it's okay because the workflow's `permissions` is empty runs-on: ubuntu-latest steps: - run: echo hello ``` 2. workflow has only one job and the workflow has `permissions` ```yaml permissions: contents: read jobs: foo: # The job is missing `permissions`, but it's okay because the workflow has permissions and the workflow has only one job. runs-on: ubuntu-latest steps: - run: echo hello ``` ================================================ FILE: docs/policies/002.md ================================================ # deny_read_all_permission [`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. ## Examples :x: ```yaml name: test jobs: foo: runs-on: ubuntu-latest permissions: read-all # Don't use read-all steps: - run: echo foo ``` :o: ```yaml name: test jobs: foo: runs-on: ubuntu-latest permissions: contents: read steps: - run: echo foo ``` ## Why? For least privilege. You should grant only necessary permissions. ================================================ FILE: docs/policies/003.md ================================================ # deny_write_all_permission [`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. ## Examples :x: ```yaml name: test jobs: foo: runs-on: ubuntu-latest permissions: write-all # Don't use write-all steps: - run: echo foo ``` :o: ```yaml name: test jobs: foo: runs-on: ubuntu-latest permissions: contents: write steps: - run: echo foo ``` ## Why? For least privilege. You should grant only necessary permissions. ================================================ FILE: docs/policies/004.md ================================================ # deny_inherit_secrets [`secrets: inherit`](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit) should not be used ## Examples :x: ```yaml jobs: release: uses: suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@v0.4.4 secrets: inherit # `inherit` should not be used ``` :o: ```yaml jobs: release: uses: suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@v0.4.4 secrets: # Only required secrets should be passed gh_app_id: ${{ secrets.APP_ID }} gh_app_private_key: ${{ secrets.APP_PRIVATE_KEY }} ``` ## Why? Secrets should be exposed to only required jobs. ## How to ignore the violation 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). e.g. ghalint.yaml ```yaml excludes: - policy_name: deny_inherit_secrets workflow_file_path: .github/workflows/actionlint.yaml job_name: actionlint ``` `policy_name`, `workflow_file_path`, and `job_name` are required. ================================================ FILE: docs/policies/005.md ================================================ # workflow_secrets Workflows should not set secrets to environment variables. ## Examples :x: ```yaml name: test env: GITHUB_TOKEN: ${{github.token}} # The secret should not be set to workflow's environment variables DATADOG_API_KEY: ${{secrets.DATADOG_API_KEY}} # The secret should not be set to workflow's environment variables jobs: foo: runs-on: ubuntu-latest permissions: {} steps: - run: echo foo bar: runs-on: ubuntu-latest permissions: {} steps: - run: echo bar ``` :o: ```yaml name: test jobs: foo: runs-on: ubuntu-latest permissions: {} env: GITHUB_TOKEN: ${{github.token}} steps: - run: echo foo bar: runs-on: ubuntu-latest permissions: {} env: DATADOG_API_KEY: ${{secrets.DATADOG_API_KEY}} steps: - run: echo bar ``` ## How to fix Set secrets to jobs or steps. ## Why? Secrets should be exposed to only necessary jobs or steps. ## Exceptions Workflow has only one job. ================================================ FILE: docs/policies/006.md ================================================ # job_secrets Job should not set secrets to environment variables. ## Examples :x: ```yaml jobs: foo: runs-on: ubuntu-latest permissions: issues: write env: GITHUB_TOKEN: ${{github.token}} # secret is set in job steps: - run: echo foo - run: gh label create bug ``` :o: ```yaml jobs: foo: runs-on: ubuntu-latest permissions: issues: write steps: - run: echo foo - run: gh label create bug env: GITHUB_TOKEN: ${{github.token}} # secret is set in step ``` ## How to fix Set secrets to steps. ## Why? Secrets should be exposed to only necessary steps. ## Exceptions Job has only one step. ## How to ignore the violation 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). e.g. ghalint.yaml ```yaml excludes: - policy_name: job_secrets workflow_file_path: .github/workflows/actionlint.yaml job_name: actionlint ``` `policy_name`, `workflow_file_path`, and `job_name` are required. ================================================ FILE: docs/policies/007.md ================================================ # deny_job_container_latest_image Job's container image tag should not be `latest`. ## Examples :x: ```yaml jobs: container-test-job: runs-on: ubuntu-latest container: image: node:latest # latest tags should not be used ``` ⭕ ```yaml jobs: container-test-job: runs-on: ubuntu-latest container: image: node:10 # Ideally, hash is best ``` ## Why? Image tags should be pinned with tag or hash. ================================================ FILE: docs/policies/008.md ================================================ # action_ref_should_be_full_length_commit_sha action's ref should be full length commit SHA ## Examples :x: ``` actions/checkout@v3 ``` ⭕ ``` actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 ``` ## Why? https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions > Pinning an action to a full length commit SHA is currently the only way to use an action as an immutable release. > 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 ## Exclude Some actions and reusable workflows don't support pinning version. You can exclude those actions and reusable workflows. ghalint.yaml ```yaml excludes: # slsa-framework/slsa-github-generator doesn't support pinning version # > Invalid ref: 68bad40844440577b33778c9f29077a3388838e9. Expected ref of the form refs/tags/vX.Y.Z # https://github.com/slsa-framework/slsa-github-generator/issues/722 - policy_name: action_ref_should_be_full_length_commit_sha action_name: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml ``` [#650](https://github.com/suzuki-shunsuke/ghalint/pull/650) As of v1.1.0, `action_name` supports a glob pattern. https://pkg.go.dev/path#Match ```yaml excludes: - policy_name: action_ref_should_be_full_length_commit_sha action_name: suzuki-shunsuke/tfaction/* # glob pattern ``` `policy_name` and `action_name` are mandatory. ## pinact https://github.com/suzuki-shunsuke/pinact [pinact](https://github.com/suzuki-shunsuke/pinact) is useful to convert tags to full length commit SHA. ================================================ FILE: docs/policies/009.md ================================================ # github_app_should_limit_repositories GitHub Actions issuing GitHub Access tokens from GitHub Apps should limit repositories. This policy supports the following actions. 1. https://github.com/tibdex/github-app-token 1. https://github.com/actions/create-github-app-token ## Examples ### tibdex/github-app-token https://github.com/tibdex/github-app-token :x: ```yaml - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 with: app_id: ${{secrets.APP_ID}} private_key: ${{secrets.PRIVATE_KEY}} ``` ⭕ ```yaml - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 with: app_id: ${{secrets.APP_ID}} private_key: ${{secrets.PRIVATE_KEY}} repositories: >- ["${{github.event.repository.name}}"] ``` ### actions/create-github-app-token https://github.com/actions/create-github-app-token :x: ```yaml - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 with: app-id: ${{vars.APP_ID}} private-key: ${{secrets.PRIVATE_KEY}} owner: ${{github.repository_owner}} permission-issues: write ``` ⭕ ```yaml - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 with: app-id: ${{vars.APP_ID}} private-key: ${{secrets.PRIVATE_KEY}} owner: ${{github.repository_owner}} repositories: "repo1,repo2" permission-issues: write ``` Or > If owner and repositories are empty, access will be scoped to only the current repository. ```yaml - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 with: app-id: ${{vars.APP_ID}} private-key: ${{secrets.PRIVATE_KEY}} permission-issues: write ``` ## Why? The scope of access tokens should be limited. ## How to ignore the violation 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). e.g. ghalint.yaml ```yaml excludes: - policy_name: github_app_should_limit_repositories workflow_file_path: .github/workflows/actionlint.yaml job_name: actionlint step_id: create_token ``` - workflow: `policy_name`, `workflow_file_path`, `job_name`, `step_id` are required. - action: `policy_name`, `action_file_path`, `step_id` are required. ================================================ FILE: docs/policies/010.md ================================================ # github_app_should_limit_permissions GitHub Actions issuing GitHub Access tokens from GitHub Apps should limit permissions. This policy supports the following actions. 1. https://github.com/tibdex/github-app-token 1. https://github.com/actions/create-github-app-token > [!NOTE] > This policy has supported [actions/create-github-app-token](https://github.com/actions/create-github-app-token) since ghalint v1.3.0. > [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). > If you use old create-github-app-token, please update it to v1.12.0 or later. ## Examples ### tibdex/github-app-token https://github.com/tibdex/github-app-token :x: ```yaml - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 with: app_id: ${{secrets.APP_ID}} private_key: ${{secrets.PRIVATE_KEY}} repositories: >- ["${{github.event.repository.name}}"] ``` ⭕ ```yaml - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 with: app_id: ${{secrets.APP_ID}} private_key: ${{secrets.PRIVATE_KEY}} repositories: >- ["${{github.event.repository.name}}"] permissions: >- { "contents": "read" } ``` ### actions/create-github-app-token :x: ```yaml - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 with: app-id: ${{vars.APP_ID}} private-key: ${{secrets.PRIVATE_KEY}} ``` ⭕ ```yaml - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 with: app-id: ${{vars.APP_ID}} private-key: ${{secrets.PRIVATE_KEY}} permission-issues: write ``` ## Why? The scope of access tokens should be limited. ================================================ FILE: docs/policies/011.md ================================================ # action_shell_is_required `shell` is required if `run` is set ## Examples :x: ```yaml - run: echo hello ``` ⭕ ```yaml - run: echo hello shell: bash ``` ## Why? > Required if run is set. https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsshell ================================================ FILE: docs/policies/012.md ================================================ # job_timeout_minutes_is_required All jobs should set [timeout-minutes](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes). ## Examples :x: ```yaml jobs: foo: # The job doesn't have `timeout-minutes` runs-on: ubuntu-latest steps: - run: echo hello ``` :o: ```yaml jobs: foo: runs-on: ubuntu-latest timeout-minutes: 30 steps: - run: echo hello ``` ## :bulb: Set `timeout-minutes` by `ghatm` https://github.com/suzuki-shunsuke/ghatm It's so bothersome to fix a lot of workflow files by hand. [ghatm](https://github.com/suzuki-shunsuke/ghatm) is a command line tool to fix them automatically. ## Why? https://exercism.org/docs/building/github/gha-best-practices#h-set-timeouts-for-workflows > 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. > > The ideal timeout depends on the individual workflow but 30 minutes is typically more than enough for the workflows used in Exercism repos. > > This has the following advantages: > > PRs won't be pending CI for half the day, issues can be caught early or workflow runs can be restarted. > The number of overall parallel builds is limited, hanging jobs will not cause issues for other PRs if they are cancelled early. ## Exceptions 1. All steps set `timeout-minutes` ```yaml jobs: foo: # The job is missing `timeout-minutes`, but it's okay because all steps set timeout-minutes runs-on: ubuntu-latest steps: - run: echo hello timeout-minutes: 5 - run: echo bar timeout-minutes: 5 ``` 2. A job uses a reusable workflow When a reusable workflow is called with `uses`, `timeout-minutes` is not available. ```yaml jobs: foo: uses: suzuki-shunsuke/renovate-config-validator-workflow/.github/workflows/validate.yaml@v0.2.3 ``` ================================================ FILE: docs/policies/013.md ================================================ # checkout_persist_credentials_should_be_false [actions/checkout](https://github.com/actions/checkout)'s input `persist-credentials` should be `false`. ## Examples :x: ```yaml jobs: foo: runs-on: ubuntu-latest steps: # persist-credentials is not set - uses: actions/checkout@v4 bar: runs-on: ubuntu-latest steps: # persist-credentials is true - uses: actions/checkout@v4 with: persist-credentials: "true" ``` :o: ```yaml jobs: foo: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: persist-credentials: "false" ``` ## Why? https://github.com/actions/checkout/issues/485 Persisting token allows every step after `actions/checkout` to access token. This is a security risk. ## :bulb: Fix using suzuki-shunsuke/disable-checkout-persist-credentials Adding `persist-credentials: false` by hand is bothersome. You can do this automatically using suzuki-shunsuke/disable-checkout-persist-credentials. https://github.com/suzuki-shunsuke/disable-checkout-persist-credentials ## How to ignore the violation If you need to persist token in a specific job, please configure it with [the configuration file](../../README.md#configuration-file). e.g. ghalint.yaml ```yaml excludes: - policy_name: checkout_persist_credentials_should_be_false workflow_file_path: .github/workflows/actionlint.yaml job_name: actionlint ``` - workflow: `policy_name`, `workflow_file_path`, `job_name` are required - action: `policy_name` and `action_file_path` are required ================================================ FILE: docs/usage.md ================================================ # Usage ```console $ ghalint --help NAME: ghalint - GitHub Actions linter USAGE: ghalint [global options] [command [command options]] VERSION: 1.5.6 COMMANDS: run lint GitHub Actions Workflows run-action, act lint actions experiment, exp experimental commands version Show version help, h Shows a list of commands or help for one command completion Output shell completion script for bash, zsh, fish, or Powershell GLOBAL OPTIONS: --log-color string log color [$GHALINT_LOG_COLOR] --log-level string log level [$GHALINT_LOG_LEVEL] --config string, -c string configuration file path [$GHALINT_CONFIG] --help, -h show help --version, -v print the version ``` ## ghalint run ```console $ ghalint run --help NAME: ghalint run - lint GitHub Actions Workflows USAGE: ghalint run OPTIONS: --help, -h show help ``` ## ghalint run-action ```console $ ghalint run-action --help NAME: ghalint run-action - lint actions USAGE: ghalint run-action [arguments...] OPTIONS: --help, -h show help ``` ## ghalint experiment ```console $ ghalint experiment --help NAME: ghalint experiment - experimental commands USAGE: ghalint experiment [command [command options]] DESCRIPTION: experimental commands. These commands are not stable and may change in the future without major updates. COMMANDS: validate-input validate action inputs OPTIONS: --help, -h show help ``` ### experiment validate-input ```console $ experiment validate-input --help NAME: ghalint experiment validate-input - validate action inputs USAGE: ghalint experiment validate-input DESCRIPTION: validate action inputs OPTIONS: --help, -h show help ``` ## ghalint version ```console $ ghalint version --help NAME: ghalint version - Show version USAGE: ghalint version OPTIONS: --json, -j Output version in JSON format --help, -h show help ``` ## ghalint completion ```console $ ghalint completion --help NAME: ghalint completion - Output shell completion script for bash, zsh, fish, or Powershell USAGE: ghalint completion DESCRIPTION: Output shell completion script for bash, zsh, fish, or Powershell. Source the output to enable completion. # .bashrc source <(ghalint completion bash) # .zshrc source <(ghalint completion zsh) # fish ghalint completion fish > ~/.config/fish/completions/ghalint.fish # Powershell Output the script to path/to/autocomplete/ghalint.ps1 an run it. OPTIONS: --help, -h show help ``` ================================================ FILE: go.mod ================================================ module github.com/suzuki-shunsuke/ghalint go 1.26.3 require ( github.com/adrg/xdg v0.5.3 github.com/google/go-github/v86 v86.0.0 github.com/spf13/afero v1.15.0 github.com/suzuki-shunsuke/gen-go-jsonschema v0.1.0 github.com/suzuki-shunsuke/slog-error v0.2.2 github.com/suzuki-shunsuke/slog-util v0.3.2 github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.3 github.com/urfave/cli/v3 v3.9.0 golang.org/x/oauth2 v0.36.0 gopkg.in/yaml.v3 v3.0.1 ) require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/lmittmann/tint v1.1.3 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/zalando/go-keyring v0.2.6 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.28.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) ================================================ FILE: go.sum ================================================ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v86 v86.0.0 h1:S/6aANJhwRm8EQmGKVML3j41yq0h2BsTP8FnDkO7kcA= github.com/google/go-github/v86 v86.0.0/go.mod h1:zKv1l4SwDXNFMGByi2FWkq71KwSXqj/eQRZuqtmcot8= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/suzuki-shunsuke/gen-go-jsonschema v0.1.0 h1:g7askc+nskCkKRWTVOdsAT8nMhwiaVT6Dmlnh6uvITM= github.com/suzuki-shunsuke/gen-go-jsonschema v0.1.0/go.mod h1:yFO7h5wwFejxi6jbtazqmk7b/JSBxHcit8DGwb1bhg0= github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0 h1:oVXrrYNGBq4POyITQNWKzwsYz7B2nUcqtDbeX4BfeEc= github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0/go.mod h1:kDFtLeftDiIUUHXGI3xq5eJ+uAOi50FPrxPENTHktJ0= github.com/suzuki-shunsuke/slog-error v0.2.2 h1:z8rymlIlZcMA+ERnnhVigQ0Q+X0pxKqBfDzSIyGh6vU= github.com/suzuki-shunsuke/slog-error v0.2.2/go.mod h1:w45QyO2G0uiEuo9hhrcLqqRl3hmYon9jGgq9CrCxxOY= github.com/suzuki-shunsuke/slog-util v0.3.2 h1:P4sc/swT8rwmmKDfMrh9GR+AzYJhJdW3BSxZXYBURuY= github.com/suzuki-shunsuke/slog-util v0.3.2/go.mod h1:fHyN2kPkinXSgo6GMR0QBj0gd/CpSer0j8bc5C4Pqks= github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.3 h1:28ZzFUyh118PFMBeHuKYPkIwaxHo+/mJYmljlr9DBRU= github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.3/go.mod h1:pfMAEENW39YADk1hW/bfHfO4rMu8GKgO4Psh6YY9nyM= github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c= github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: json-schema/ghalint.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/suzuki-shunsuke/ghalint/pkg/config/config", "$ref": "#/$defs/Config", "$defs": { "Config": { "properties": { "excludes": { "items": { "$ref": "#/$defs/Exclude" }, "type": "array" } }, "additionalProperties": false, "type": "object" }, "Exclude": { "properties": { "policy_name": { "type": "string" }, "workflow_file_path": { "type": "string" }, "action_file_path": { "type": "string" }, "job_name": { "type": "string" }, "action_name": { "type": "string" }, "step_id": { "type": "string" } }, "additionalProperties": false, "type": "object", "required": [ "policy_name" ] } } } ================================================ FILE: pkg/action/find.go ================================================ package action import ( "fmt" "github.com/spf13/afero" ) func Find(fs afero.Fs) ([]string, error) { patterns := []string{ "action.yaml", "action.yml", "*/action.yaml", "*/action.yml", "*/*/action.yaml", "*/*/action.yml", "*/*/*/action.yaml", "*/*/*/action.yml", } files := []string{} for _, pattern := range patterns { matches, err := afero.Glob(fs, pattern) if err != nil { return nil, fmt.Errorf("check if the action file exists: %w", err) } files = append(files, matches...) } return files, nil } ================================================ FILE: pkg/cli/app.go ================================================ package cli import ( "context" "github.com/spf13/afero" "github.com/suzuki-shunsuke/ghalint/pkg/cli/experiment" "github.com/suzuki-shunsuke/ghalint/pkg/cli/experiment/validateinput" "github.com/suzuki-shunsuke/ghalint/pkg/cli/gflags" "github.com/suzuki-shunsuke/slog-util/slogutil" "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" "github.com/urfave/cli/v3" ) type RunArgs struct { *gflags.GlobalFlags } type RunActionArgs struct { *gflags.GlobalFlags Files []string } func Run(ctx context.Context, logger *slogutil.Logger, env *urfave.Env) error { //nolint:funlen fs := afero.NewOsFs() runner := &Runner{ fs: fs, } gf := &gflags.GlobalFlags{} runArgs := &RunArgs{ GlobalFlags: gf, } runActionArgs := &RunActionArgs{ GlobalFlags: gf, } validateInputArgs := &validateinput.Args{ GlobalFlags: gf, } return urfave.Command(env, &cli.Command{ //nolint:wrapcheck Name: "ghalint", Usage: "GitHub Actions linter", Flags: []cli.Flag{ &cli.StringFlag{ Name: "log-color", Usage: "log color", Sources: cli.EnvVars( "GHALINT_LOG_COLOR", ), Destination: &gf.LogColor, }, &cli.StringFlag{ Name: "log-level", Usage: "log level", Sources: cli.EnvVars( "GHALINT_LOG_LEVEL", ), Destination: &gf.LogLevel, }, &cli.StringFlag{ Name: "config", Aliases: []string{"c"}, Usage: "configuration file path", Sources: cli.EnvVars( "GHALINT_CONFIG", ), Destination: &gf.Config, }, }, Commands: []*cli.Command{ { Name: "run", Usage: "lint GitHub Actions Workflows", Action: func(ctx context.Context, _ *cli.Command) error { return runner.Run(ctx, logger, runArgs) }, }, { Name: "run-action", Aliases: []string{ "act", }, Usage: "lint actions", Action: func(ctx context.Context, _ *cli.Command) error { return runner.RunAction(ctx, logger, runActionArgs) }, Arguments: []cli.Argument{ &cli.StringArgs{ Name: "files", Destination: &runActionArgs.Files, Max: -1, }, }, }, experiment.New(logger, fs, validateInputArgs), }, }).Run(ctx, env.Args) } type Runner struct { fs afero.Fs } ================================================ FILE: pkg/cli/experiment/command.go ================================================ package experiment import ( "github.com/spf13/afero" "github.com/suzuki-shunsuke/ghalint/pkg/cli/experiment/validateinput" "github.com/suzuki-shunsuke/slog-util/slogutil" "github.com/urfave/cli/v3" ) func New(logger *slogutil.Logger, fs afero.Fs, validateInputArgs *validateinput.Args) *cli.Command { return &cli.Command{ Name: "experiment", Aliases: []string{"exp"}, Usage: "experimental commands", Description: "experimental commands. These commands are not stable and may change in the future without major updates.", Commands: []*cli.Command{ validateinput.New(logger, fs, validateInputArgs), }, } } ================================================ FILE: pkg/cli/experiment/validateinput/command.go ================================================ package validateinput import ( "context" "fmt" "os" "path/filepath" "github.com/adrg/xdg" "github.com/spf13/afero" "github.com/suzuki-shunsuke/ghalint/pkg/cli/gflags" "github.com/suzuki-shunsuke/ghalint/pkg/controller/schema" "github.com/suzuki-shunsuke/ghalint/pkg/github" "github.com/suzuki-shunsuke/slog-util/slogutil" "github.com/urfave/cli/v3" ) type Args struct { *gflags.GlobalFlags } func New(logger *slogutil.Logger, fs afero.Fs, args *Args) *cli.Command { runner := &Runner{ fs: fs, } return &cli.Command{ Name: "validate-input", Usage: "validate action inputs", Description: "validate action inputs", Action: func(ctx context.Context, _ *cli.Command) error { return runner.Action(ctx, logger, args) }, } } type Runner struct { fs afero.Fs } func (r *Runner) Action(ctx context.Context, logger *slogutil.Logger, args *Args) error { if err := logger.SetLevel(args.LogLevel); err != nil { return fmt.Errorf("set log level: %w", err) } if err := logger.SetColor(args.LogColor); err != nil { return fmt.Errorf("set log color: %w", err) } rootDir, err := GetRootDir() if err != nil { return fmt.Errorf("get the root directory: %w", err) } gh := github.New(ctx, logger.Logger) ctrl := schema.New(r.fs, logger.Logger, gh.Repositories, rootDir) return ctrl.Run(ctx) //nolint:wrapcheck } func GetRootDir() (string, error) { // ${GHALINT_ROOT_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/ghalint} rootDir := os.Getenv("GHALINT_ROOT_DIR") if rootDir != "" { return rootDir, nil } xdgDataHome := xdg.DataHome if xdgDataHome == "" { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("get the current user home directory: %w", err) } xdgDataHome = filepath.Join(home, ".local", "share") } return filepath.Join(xdgDataHome, "ghalint"), nil } ================================================ FILE: pkg/cli/gflags/gflags.go ================================================ package gflags type GlobalFlags struct { LogColor string LogLevel string Config string } ================================================ FILE: pkg/cli/run.go ================================================ package cli import ( "context" "fmt" "github.com/suzuki-shunsuke/ghalint/pkg/controller" "github.com/suzuki-shunsuke/slog-util/slogutil" ) func (r *Runner) Run(ctx context.Context, logger *slogutil.Logger, args *RunArgs) error { if err := logger.SetLevel(args.LogLevel); err != nil { return fmt.Errorf("set log level: %w", err) } if err := logger.SetColor(args.LogColor); err != nil { return fmt.Errorf("set log color: %w", err) } ctrl := controller.New(r.fs) return ctrl.Run(ctx, logger.Logger, args.Config) //nolint:wrapcheck } ================================================ FILE: pkg/cli/run_action.go ================================================ package cli import ( "context" "fmt" "github.com/suzuki-shunsuke/ghalint/pkg/controller/act" "github.com/suzuki-shunsuke/slog-util/slogutil" ) func (r *Runner) RunAction(ctx context.Context, logger *slogutil.Logger, args *RunActionArgs) error { if err := logger.SetColor(args.LogColor); err != nil { return fmt.Errorf("set log color: %w", err) } if err := logger.SetLevel(args.LogLevel); err != nil { return fmt.Errorf("set log level: %w", err) } ctrl := act.New(r.fs) return ctrl.Run(ctx, logger.Logger, args.Config, args.Files...) //nolint:wrapcheck } ================================================ FILE: pkg/config/config.go ================================================ package config import ( "errors" "fmt" "io" "path" "path/filepath" "github.com/spf13/afero" "github.com/suzuki-shunsuke/slog-error/slogerr" "gopkg.in/yaml.v3" ) type Config struct { Excludes []*Exclude `json:"excludes,omitempty"` } type Exclude struct { PolicyName string `json:"policy_name" yaml:"policy_name"` WorkflowFilePath string `json:"workflow_file_path,omitempty" yaml:"workflow_file_path"` ActionFilePath string `json:"action_file_path,omitempty" yaml:"action_file_path"` JobName string `json:"job_name,omitempty" yaml:"job_name"` ActionName string `json:"action_name,omitempty" yaml:"action_name"` StepID string `json:"step_id,omitempty" yaml:"step_id"` } func (e *Exclude) FilePath() string { if e.WorkflowFilePath != "" { return e.WorkflowFilePath } return e.ActionFilePath } func Find(fs afero.Fs) string { filePaths := []string{ "ghalint.yaml", ".ghalint.yaml", ".github/ghalint.yaml", "ghalint.yml", ".ghalint.yml", ".github/ghalint.yml", } for _, filePath := range filePaths { if _, err := fs.Stat(filePath); err == nil { return filePath } } return "" } func Read(fs afero.Fs, cfg *Config, filePath string) error { f, err := fs.Open(filePath) if err != nil { return fmt.Errorf("open a configuration file: %w", err) } defer f.Close() if err := yaml.NewDecoder(f).Decode(cfg); err != nil { err := fmt.Errorf("parse configuration file as YAML: %w", err) if errors.Is(err, io.EOF) { return slogerr.With(err, "reference", "https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/codes/002.md") //nolint:wrapcheck } return err } return nil } func Validate(cfg *Config) error { for _, exclude := range cfg.Excludes { if err := validate(exclude); err != nil { return err } } return nil } func ConvertPath(cfg *Config) { for _, exclude := range cfg.Excludes { convertPath(exclude) } } func convertPath(exclude *Exclude) { exclude.WorkflowFilePath = filepath.FromSlash(exclude.WorkflowFilePath) exclude.ActionFilePath = filepath.FromSlash(exclude.ActionFilePath) } func validate(exclude *Exclude) error { //nolint:cyclop if exclude.PolicyName == "" { return errors.New(`policy_name is required`) } switch exclude.PolicyName { case "action_ref_should_be_full_length_commit_sha": if exclude.ActionName == "" { return errors.New(`action_name is required to exclude action_ref_should_be_full_length_commit_sha`) } if _, err := path.Match(exclude.ActionName, ""); err != nil { return fmt.Errorf("action_name must be a glob pattern: %w", slogerr.With(err, "pattern_reference", "https://pkg.go.dev/path#Match")) } case "job_secrets": if exclude.WorkflowFilePath == "" { return errors.New(`workflow_file_path is required to exclude job_secrets`) } if exclude.JobName == "" { return errors.New(`job_name is required to exclude job_secrets`) } case "deny_inherit_secrets": if exclude.WorkflowFilePath == "" { return errors.New(`workflow_file_path is required to exclude deny_inherit_secrets`) } if exclude.JobName == "" { return errors.New(`job_name is required to exclude deny_inherit_secrets`) } case "github_app_should_limit_repositories": if exclude.WorkflowFilePath == "" && exclude.ActionFilePath == "" { return errors.New(`workflow_file_path or action_file_path is required to exclude github_app_should_limit_repositories`) } if exclude.WorkflowFilePath != "" && exclude.JobName == "" { return errors.New(`job_name is required to exclude github_app_should_limit_repositories`) } if exclude.StepID == "" { return errors.New(`step_id is required to exclude github_app_should_limit_repositories`) } case "checkout_persist_credentials_should_be_false": if exclude.WorkflowFilePath == "" && exclude.ActionFilePath == "" { return errors.New(`workflow_file_path or action_file_path is required to exclude checkout_persist_credentials_should_be_false`) } if exclude.WorkflowFilePath != "" && exclude.JobName == "" { return errors.New(`job_name is required to exclude checkout_persist_credentials_should_be_false`) } default: return slogerr.With(errors.New(`the policy can't be excluded`), "policy_name", exclude.PolicyName) //nolint:wrapcheck } return nil } ================================================ FILE: pkg/config/config_test.go ================================================ package config_test import ( "testing" "github.com/suzuki-shunsuke/ghalint/pkg/config" ) func TestValidate(t *testing.T) { //nolint:funlen t.Parallel() data := []struct { name string cfg *config.Config isErr bool }{ { name: "policy_name is required", cfg: &config.Config{ Excludes: []*config.Exclude{ {}, }, }, isErr: true, }, { name: "action_name is required", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "action_ref_should_be_full_length_commit_sha", }, }, }, isErr: true, }, { name: "workflow_file_path is required", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "job_secrets", }, }, }, isErr: true, }, { name: "job_name is required", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "job_secrets", WorkflowFilePath: ".github/workflows/foo.yaml", }, }, }, isErr: true, }, { name: "disallowed policy", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "deny_read_all_permission", WorkflowFilePath: ".github/workflows/foo.yaml", JobName: "foo", }, }, }, isErr: true, }, } for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() if err := config.Validate(d.cfg); err != nil { if d.isErr { return } t.Fatal(err) } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/controller/act/controller.go ================================================ package act import ( "github.com/spf13/afero" ) type Controller struct { fs afero.Fs } func New(fs afero.Fs) *Controller { return &Controller{ fs: fs, } } ================================================ FILE: pkg/controller/act/run.go ================================================ package act import ( "context" "fmt" "log/slog" "github.com/suzuki-shunsuke/ghalint/pkg/action" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/controller" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "github.com/suzuki-shunsuke/slog-error/slogerr" "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" ) func (c *Controller) Run(_ context.Context, logger *slog.Logger, cfgFilePath string, args ...string) error { cfg := &config.Config{} if err := c.readConfig(cfg, cfgFilePath); err != nil { return err } filePaths, err := c.listFiles(args...) if err != nil { return fmt.Errorf("find action files: %w", err) } stepPolicies := []controller.StepPolicy{ &policy.GitHubAppShouldLimitRepositoriesPolicy{}, &policy.GitHubAppShouldLimitPermissionsPolicy{}, &policy.ActionShellIsRequiredPolicy{}, policy.NewActionRefShouldBeSHAPolicy(), &policy.CheckoutPersistCredentialShouldBeFalsePolicy{}, } failed := false for _, filePath := range filePaths { logger := logger.With("action_file_path", filePath) if c.validateAction(logger, cfg, stepPolicies, filePath) { failed = true } } if failed { return urfave.ErrSilent } return nil } func (c *Controller) listFiles(args ...string) ([]string, error) { if len(args) != 0 { return args, nil } return action.Find(c.fs) //nolint:wrapcheck } func (c *Controller) validateAction(logger *slog.Logger, cfg *config.Config, stepPolicies []controller.StepPolicy, filePath string) bool { action := &workflow.Action{} if err := workflow.ReadAction(c.fs, filePath, action); err != nil { slogerr.WithError(logger, err).Error("read an action file") return true } stepCtx := &policy.StepContext{ FilePath: filePath, Action: action, } return c.applyStepPolicies(logger, cfg, stepCtx, action, stepPolicies) } type Policy interface { Name() string ID() string } func withPolicyReference(logger *slog.Logger, p Policy) *slog.Logger { return logger.With( "policy_name", p.Name(), "reference", fmt.Sprintf("https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/policies/%s.md", p.ID()), ) } func (c *Controller) applyStepPolicies(logger *slog.Logger, cfg *config.Config, stepCtx *policy.StepContext, action *workflow.Action, stepPolicies []controller.StepPolicy) bool { failed := false for _, stepPolicy := range stepPolicies { logger := withPolicyReference(logger, stepPolicy) if c.applyStepPolicy(logger, cfg, stepCtx, action, stepPolicy) { failed = true } } return failed } func (c *Controller) applyStepPolicy(logger *slog.Logger, cfg *config.Config, stepCtx *policy.StepContext, action *workflow.Action, stepPolicy controller.StepPolicy) bool { failed := false for _, step := range action.Runs.Steps { logger := logger if step.ID != "" { logger = logger.With("step_id", step.ID) } if step.Name != "" { logger = logger.With("step_name", step.Name) } if err := stepPolicy.ApplyStep(logger, cfg, stepCtx, step); err != nil { if err.Error() != "" { slogerr.WithError(logger, err).Error("the step violates policies") } failed = true } } return failed } func (c *Controller) readConfig(cfg *config.Config, cfgFilePath string) error { if cfgFilePath == "" { if c := config.Find(c.fs); c != "" { cfgFilePath = c } } if cfgFilePath != "" { if err := config.Read(c.fs, cfg, cfgFilePath); err != nil { return fmt.Errorf("read a configuration file: %w", slogerr.With(err, "config_file", cfgFilePath, )) } if err := config.Validate(cfg); err != nil { return fmt.Errorf("validate a configuration file: %w", slogerr.With(err, "config_file", cfgFilePath, )) } config.ConvertPath(cfg) } return nil } ================================================ FILE: pkg/controller/controller.go ================================================ package controller import ( "log/slog" "github.com/spf13/afero" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) type Controller struct { fs afero.Fs } func New(fs afero.Fs) *Controller { return &Controller{ fs: fs, } } type WorkflowPolicy interface { Name() string ID() string ApplyWorkflow(logger *slog.Logger, cfg *config.Config, wfCtx *policy.WorkflowContext, wf *workflow.Workflow) error } type JobPolicy interface { Name() string ID() string ApplyJob(logger *slog.Logger, cfg *config.Config, jobCtx *policy.JobContext, job *workflow.Job) error } type StepPolicy interface { Name() string ID() string ApplyStep(logger *slog.Logger, cfg *config.Config, stepCtx *policy.StepContext, step *workflow.Step) error } ================================================ FILE: pkg/controller/run.go ================================================ package controller import ( "context" "fmt" "log/slog" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "github.com/suzuki-shunsuke/slog-error/slogerr" "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" ) func (c *Controller) Run(_ context.Context, logger *slog.Logger, cfgFilePath string) error { cfg := &config.Config{} if err := c.readConfig(cfg, cfgFilePath); err != nil { return err } filePaths, err := workflow.List(c.fs) if err != nil { return fmt.Errorf("find workflow files: %w", err) } wfPolicies := []WorkflowPolicy{ policy.NewWorkflowSecretsPolicy(), } jobPolicies := []JobPolicy{ &policy.JobPermissionsPolicy{}, &policy.JobTimeoutMinutesIsRequiredPolicy{}, policy.NewJobSecretsPolicy(), &policy.DenyInheritSecretsPolicy{}, &policy.DenyJobContainerLatestImagePolicy{}, policy.NewActionRefShouldBeSHAPolicy(), &policy.DenyReadAllPermissionPolicy{}, &policy.DenyWriteAllPermissionPolicy{}, } stepPolicies := []StepPolicy{ &policy.GitHubAppShouldLimitRepositoriesPolicy{}, &policy.GitHubAppShouldLimitPermissionsPolicy{}, policy.NewActionRefShouldBeSHAPolicy(), &policy.CheckoutPersistCredentialShouldBeFalsePolicy{}, } failed := false for _, filePath := range filePaths { logger := logger.With("workflow_file_path", filePath) if c.validateWorkflow(logger, cfg, wfPolicies, jobPolicies, stepPolicies, filePath) { failed = true } } if failed { return urfave.ErrSilent } return nil } func (c *Controller) validateWorkflow(logger *slog.Logger, cfg *config.Config, wfPolicies []WorkflowPolicy, jobPolicies []JobPolicy, stepPolicies []StepPolicy, filePath string) bool { wf := &workflow.Workflow{ FilePath: filePath, } if err := workflow.Read(c.fs, filePath, wf); err != nil { slogerr.WithError(logger, err).Error("read a workflow file") return true } wfCtx := &policy.WorkflowContext{ FilePath: filePath, Workflow: wf, } failed := false for _, wfPolicy := range wfPolicies { logger := withPolicyReference(logger, wfPolicy) if err := wfPolicy.ApplyWorkflow(logger, cfg, wfCtx, wf); err != nil { if err.Error() != "" { slogerr.WithError(logger, err).Error("the workflow violates policies") } failed = true continue } } if c.applyJobPolicies(logger, cfg, wfCtx, jobPolicies) { failed = true } if c.applyStepPolicies(logger, cfg, wfCtx, wf.Jobs, stepPolicies) { failed = true } return failed } type Policy interface { Name() string ID() string } func withPolicyReference(logger *slog.Logger, p Policy) *slog.Logger { return logger.With( "policy_name", p.Name(), "reference", fmt.Sprintf("https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/policies/%s.md", p.ID()), ) } func (c *Controller) applyJobPolicies(logger *slog.Logger, cfg *config.Config, wfCtx *policy.WorkflowContext, jobPolicies []JobPolicy) bool { failed := false for _, jobPolicy := range jobPolicies { logger := withPolicyReference(logger, jobPolicy) if c.applyJobPolicy(logger, cfg, wfCtx, jobPolicy) { failed = true } } return failed } func (c *Controller) applyJobPolicy(logger *slog.Logger, cfg *config.Config, wfCtx *policy.WorkflowContext, jobPolicy JobPolicy) bool { failed := false for jobName, job := range wfCtx.Workflow.Jobs { jobCtx := &policy.JobContext{ Workflow: wfCtx, Name: jobName, } logger := logger.With("job_name", jobName) if err := jobPolicy.ApplyJob(logger, cfg, jobCtx, job); err != nil { failed = true if err.Error() != "" { slogerr.WithError(logger, err).Error("the job violates policies") } } } return failed } func (c *Controller) applyStepPolicies(logger *slog.Logger, cfg *config.Config, wfCtx *policy.WorkflowContext, jobs map[string]*workflow.Job, stepPolicies []StepPolicy) bool { failed := false for _, stepPolicy := range stepPolicies { logger := withPolicyReference(logger, stepPolicy) if c.applyStepPolicy(logger, cfg, wfCtx, jobs, stepPolicy) { failed = true } } return failed } func (c *Controller) applyStepPolicy(logger *slog.Logger, cfg *config.Config, wfCtx *policy.WorkflowContext, jobs map[string]*workflow.Job, stepPolicy StepPolicy) bool { failed := false for jobName, job := range jobs { stepCtx := &policy.StepContext{ FilePath: wfCtx.FilePath, Job: &policy.JobContext{ Name: jobName, Workflow: wfCtx, Job: job, }, } logger := logger.With("job_name", jobName) for _, step := range job.Steps { logger := logger if step.ID != "" { logger = logger.With("step_id", step.ID) } if step.Name != "" { logger = logger.With("step_name", step.Name) } if err := stepPolicy.ApplyStep(logger, cfg, stepCtx, step); err != nil { if err.Error() != "" { slogerr.WithError(logger, err).Error("the step violates policies") } failed = true } } } return failed } func (c *Controller) readConfig(cfg *config.Config, cfgFilePath string) error { if cfgFilePath == "" { if c := config.Find(c.fs); c != "" { cfgFilePath = c } } if cfgFilePath != "" { if err := config.Read(c.fs, cfg, cfgFilePath); err != nil { return fmt.Errorf("read a configuration file: %w", slogerr.With(err, "config_file", cfgFilePath, )) } if err := config.Validate(cfg); err != nil { return fmt.Errorf("validate a configuration file: %w", slogerr.With(err, "config_file", cfgFilePath, )) } config.ConvertPath(cfg) } return nil } ================================================ FILE: pkg/controller/schema/action.go ================================================ package schema import ( "context" "errors" "fmt" "log/slog" "github.com/spf13/afero" "github.com/suzuki-shunsuke/ghalint/pkg/action" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "github.com/suzuki-shunsuke/slog-error/slogerr" ) func (c *Controller) runActions(ctx context.Context) error { filePaths, err := action.Find(c.fs) if err != nil { return fmt.Errorf("find action files: %w", err) } failed := false for _, filePath := range filePaths { logger := c.logger.With("action_file_path", filePath) vw := &validateAction{ action: filePath, logger: logger, fs: c.fs, gh: c.gh, rootDir: c.rootDir, } if err := vw.validate(ctx); err != nil { slogerr.WithError(logger, err).Error("validate action") failed = true } } if failed { return errors.New("some action files are invalid") } return nil } type validateAction struct { action string logger *slog.Logger fs afero.Fs gh GitHub rootDir string } func (v *validateAction) validate(ctx context.Context) error { act := &workflow.Action{} if err := workflow.ReadAction(v.fs, v.action, act); err != nil { return fmt.Errorf("read an action file: %w", err) } failed := false for _, step := range act.Runs.Steps { vs := &validateStep{ step: step, logger: v.logger, fs: v.fs, gh: v.gh, rootDir: v.rootDir, } if err := vs.validate(ctx); err != nil { slogerr.WithError(v.logger, err).Error("validate a step") failed = true } } if failed { return errors.New("some steps are invalid") } return nil } ================================================ FILE: pkg/controller/schema/controller.go ================================================ package schema import ( "context" "log/slog" "github.com/spf13/afero" "github.com/suzuki-shunsuke/ghalint/pkg/github" ) type Controller struct { fs afero.Fs logger *slog.Logger gh GitHub rootDir string } func New(fs afero.Fs, logger *slog.Logger, gh GitHub, rootDir string) *Controller { return &Controller{ fs: fs, logger: logger, gh: gh, rootDir: rootDir, } } type GitHub interface { GetCommitSHA1(ctx context.Context, owner, repo, ref, lastSHA string) (string, *github.Response, error) GetContents(ctx context.Context, owner, repo, path string, opts *github.RepositoryContentGetOptions) (fileContent *github.RepositoryContent, directoryContent []*github.RepositoryContent, resp *github.Response, err error) } ================================================ FILE: pkg/controller/schema/job.go ================================================ package schema import ( "context" "errors" "fmt" "log/slog" "github.com/spf13/afero" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "github.com/suzuki-shunsuke/slog-error/slogerr" "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" ) type validateJob struct { job *workflow.Job logger *slog.Logger fs afero.Fs gh GitHub rootDir string } func (v *validateJob) validate(ctx context.Context) error { // Get actions if v.job.Uses != "" { v.logger = v.logger.With("reusable_workflow", v.job.Uses) if err := v.validateReusableWorkflow(ctx); err != nil { return fmt.Errorf("validate a reusable workflow: %w", err) } return nil } failed := false for _, step := range v.job.Steps { vs := &validateStep{ step: step, fs: v.fs, logger: v.logger, gh: v.gh, rootDir: v.rootDir, } if err := vs.validate(ctx); err != nil { failed = true if !errors.Is(err, urfave.ErrSilent) { slogerr.WithError(v.logger, err).Error("validate a step") } } } if failed { return urfave.ErrSilent } return nil } ================================================ FILE: pkg/controller/schema/reusable_workflow.go ================================================ package schema import ( "context" "errors" "fmt" "io" "maps" "path/filepath" "slices" "strings" "github.com/spf13/afero" "github.com/suzuki-shunsuke/ghalint/pkg/github" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "github.com/suzuki-shunsuke/slog-error/slogerr" "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" "gopkg.in/yaml.v3" ) func (v *validateJob) validateReusableWorkflow(ctx context.Context) error { // read workflow wf := &ReusableWorkflow{} if err := v.read(ctx, wf); err != nil { return fmt.Errorf("read a reusable workflow: %w", err) } if err := v.validateWorkflow(wf); err != nil { return fmt.Errorf("validate a reusable workflow: %w", err) } return nil } /* on: workflow_call: inputs: aqua_policy_config: required: false type: string */ type ReusableWorkflow struct { On *On } type On struct { WorkflowCall *WorkflowCall `yaml:"workflow_call"` } func (o *On) UnmarshalYAML(unmarshal func(any) error) error { //nolint:cyclop var onAny any if err := unmarshal(&onAny); err != nil { return fmt.Errorf("unmarshal a workflow to any: %w", err) } if s, ok := onAny.(string); ok { if s != "workflow_call" { return nil } o.WorkflowCall = &WorkflowCall{} return nil } onMap, ok := onAny.(map[string]any) if !ok { return errors.New("failed to convert workflow on into map") } workflowCallAny, ok := onMap["workflow_call"] if !ok { return nil } o.WorkflowCall = &WorkflowCall{} workflowCallMap, ok := workflowCallAny.(map[string]any) if !ok { return nil } inputsAny, ok := workflowCallMap["inputs"] if !ok { return nil } inputsMap, ok := inputsAny.(map[string]any) if !ok { return nil } o.WorkflowCall.Inputs = map[string]*workflow.Input{} for inputKey, v := range inputsMap { o.WorkflowCall.Inputs[inputKey] = &workflow.Input{} inputValueMap, ok := v.(map[string]any) if !ok { continue } requiredAny, ok := inputValueMap["required"] if !ok { continue } required, ok := requiredAny.(bool) if !ok { continue } o.WorkflowCall.Inputs[inputKey] = &workflow.Input{ Required: required, } } return nil } type WorkflowCall struct { Inputs map[string]*workflow.Input } func (v *validateJob) validateWorkflow(wf *ReusableWorkflow) error { if wf.On == nil { return errors.New("the reusable workflow is invalid. on is not set") } if wf.On.WorkflowCall == nil { return errors.New("the reusable workflow is invalid. workflow_call is not set") } inputs := wf.On.WorkflowCall.Inputs requiredKeys := map[string]struct{}{} for key, input := range inputs { if input.Required { requiredKeys[key] = struct{}{} } } v.logger = v.logger.With( "valid_inputs", strings.Join(slices.Collect(maps.Keys(inputs)), ", "), "required_inputs", strings.Join(slices.Collect(maps.Keys(requiredKeys)), ", "), ) failed := false // Check if the input is valid for key := range v.job.With { if _, ok := inputs[key]; !ok { v.logger.Error("invalid input key", "input_key", key) failed = true } } // Check if required keys are set for key := range requiredKeys { if _, ok := v.job.With[key]; !ok { v.logger.Error("required key is not set", "input_key", key) failed = true } } if failed { return urfave.ErrSilent } return nil } func readReusableWorkflow(fs afero.Fs, p string, wf *ReusableWorkflow) error { f, err := fs.Open(p) if err != nil { return fmt.Errorf("open a workflow file: %w", err) } defer f.Close() if err := yaml.NewDecoder(f).Decode(wf); err != nil { err := fmt.Errorf("parse a workflow file as YAML: %w", err) if errors.Is(err, io.EOF) { return slogerr.With(err, //nolint:wrapcheck "reference", "https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/codes/001.md", ) } return err } return nil } func (v *validateJob) read(ctx context.Context, wf *ReusableWorkflow) error { //nolint:cyclop if strings.HasPrefix(v.job.Uses, "./") { // local workflow if err := readReusableWorkflow(v.fs, v.job.Uses, wf); err != nil { return fmt.Errorf("read a local workflow file: %w", err) } return nil } // /[/]@ fullPath, ref, ok := strings.Cut(v.job.Uses, "@") if !ok { return fmt.Errorf("invalid job.uses format: %s", v.job.Uses) } elems := strings.Split(fullPath, "/") owner := elems[0] repo := elems[1] path := strings.Join(elems[2:], "/") sha := ref if !fullCommitSHAPattern.MatchString(ref) { // Get SHA of actions s, _, err := v.gh.GetCommitSHA1(ctx, owner, repo, ref, "") if err != nil { return fmt.Errorf("get commit SHA1: %w", err) } sha = s } // Download actions and store them in $GHALINT_ROOT_DIR/actions // Check if the action file exists cachePath := filepath.Join(v.rootDir, "actions", owner, repo, sha, path) if f, err := afero.Exists(v.fs, cachePath); err != nil { return fmt.Errorf("check if the workflow file exists: %w", err) } else if f { if err := readReusableWorkflow(v.fs, cachePath, wf); err != nil { return fmt.Errorf("read a cached workflow file: %w", err) } return nil } // Download a wofklow file content, _, _, err := v.gh.GetContents(ctx, owner, repo, path, &github.RepositoryContentGetOptions{ Ref: sha, }) if err != nil { return fmt.Errorf("download workflow file: %w", err) } // write workflow to the cache dir if err := v.fs.MkdirAll(filepath.Dir(cachePath), dirPermission); err != nil { return fmt.Errorf("create workflow directory: %w", err) } c, err := content.GetContent() if err != nil { return fmt.Errorf("get content: %w", err) } b := []byte(c) if err := afero.WriteFile(v.fs, cachePath, b, filePermission); err != nil { return fmt.Errorf("write workflow file: %w", err) } if err := yaml.Unmarshal(b, wf); err != nil { return fmt.Errorf("unmarshal workflow file: %w", err) } return nil } ================================================ FILE: pkg/controller/schema/run.go ================================================ package schema import ( "context" "errors" "fmt" "github.com/suzuki-shunsuke/slog-error/slogerr" "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" ) func (c *Controller) Run(ctx context.Context) error { // Find action.yaml and workflow files failed := false if err := c.runWorkflow(ctx); err != nil { failed = true if !errors.Is(err, urfave.ErrSilent) { slogerr.WithError(c.logger, err).Error("validate workflows") } } if err := c.runActions(ctx); err != nil { if !errors.Is(err, urfave.ErrSilent) { return fmt.Errorf("validate actions: %w", err) } return urfave.ErrSilent } if failed { return urfave.ErrSilent } return nil } ================================================ FILE: pkg/controller/schema/step.go ================================================ package schema import ( "context" "errors" "fmt" "log/slog" "path/filepath" "regexp" "strings" "github.com/spf13/afero" "github.com/suzuki-shunsuke/ghalint/pkg/github" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "github.com/suzuki-shunsuke/slog-error/slogerr" "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" "gopkg.in/yaml.v3" ) type validateStep struct { step *workflow.Step logger *slog.Logger fs afero.Fs gh GitHub rootDir string } var fullCommitSHAPattern = regexp.MustCompile(`\b[0-9a-f]{40}\b`) func (v *validateStep) readAction(ctx context.Context, action *workflow.Action) error { //nolint:cyclop if strings.HasPrefix(v.step.Uses, "./") { // local action if err := v.readLocalAction(action); err != nil { return fmt.Errorf("read a local action file: %w", err) } return nil } // /[/]@ fullPath, ref, ok := strings.Cut(v.step.Uses, "@") if !ok { return fmt.Errorf("invalid action format: %s", v.step.Uses) } elems := strings.Split(fullPath, "/") owner := elems[0] repo := elems[1] path := strings.Join(elems[2:], "/") sha := ref if !fullCommitSHAPattern.MatchString(ref) { // Get SHA of actions s, _, err := v.gh.GetCommitSHA1(ctx, owner, repo, ref, "") if err != nil { return fmt.Errorf("get commit SHA1: %w", err) } sha = s } // Download actions and store them in $GHALINT_ROOT_DIR/actions // Check if the action file exists cachePath := filepath.Join(v.rootDir, "actions", owner, repo, sha, path, "action.yaml") if f, err := afero.Exists(v.fs, cachePath); err != nil { return fmt.Errorf("check if the action file exists: %w", err) } else if f { if err := workflow.ReadAction(v.fs, cachePath, action); err != nil { return fmt.Errorf("read a cached action file: %w", err) } return nil } // Download action.yaml or action.yml content, err := v.download(ctx, &downloadInput{ Owner: owner, Repo: repo, Path: path, Ref: sha, }) if err != nil { return fmt.Errorf("download action file: %w", err) } // write action.yaml to $GHALINT_ROOT_DIR/actions/// if err := v.fs.MkdirAll(filepath.Dir(cachePath), dirPermission); err != nil { return fmt.Errorf("create action directory: %w", err) } if err := afero.WriteFile(v.fs, cachePath, []byte(content), filePermission); err != nil { return fmt.Errorf("write action file: %w", err) } if err := yaml.Unmarshal([]byte(content), action); err != nil { return fmt.Errorf("unmarshal action file: %w", err) } return nil } const ( filePermission = 0o644 dirPermission = 0o755 ) type downloadInput struct { Owner string Repo string Path string Ref string } func (v *validateStep) download(ctx context.Context, input *downloadInput) (string, error) { for _, file := range []string{"action.yaml", "action.yml"} { content, _, _, err := v.gh.GetContents(ctx, input.Owner, input.Repo, filepath.Join(input.Path, file), &github.RepositoryContentGetOptions{ Ref: input.Ref, }) if err != nil { slogerr.WithError(v.logger, err).Debug("get action file") continue } s, err := content.GetContent() if err != nil { return "", fmt.Errorf("get content: %w", err) } return s, nil } return "", errors.New("action file can't be downloaded") } func (v *validateStep) validate(ctx context.Context) error { // Validate inputs if v.step.Uses == "" { return nil } v.logger = v.logger.With("action", v.step.Uses) action := &workflow.Action{} if err := v.readAction(ctx, action); err != nil { return fmt.Errorf("read action: %w", err) } validKeys := map[string]struct{}{} requiredKeys := map[string]struct{}{} validKeysArray := make([]string, 0, len(action.Inputs)) requiredKeysArray := []string{} for key, input := range action.Inputs { validKeysArray = append(validKeysArray, key) validKeys[key] = struct{}{} if input.Required { requiredKeys[key] = struct{}{} requiredKeysArray = append(requiredKeysArray, key) } } validKeysS := strings.Join(validKeysArray, ", ") requiredKeysS := strings.Join(requiredKeysArray, ", ") v.logger = v.logger.With( "valid_inputs", validKeysS, "required_inputs", requiredKeysS, ) failed := false // Check if the input is valid for key := range v.step.With { if _, ok := action.Inputs[key]; !ok { v.logger.Error("invalid input key", "input_key", key) failed = true } } // Check if required keys are set for key := range requiredKeys { if _, ok := v.step.With[key]; !ok { v.logger.Error("required key is not set", "input_key", key) failed = true } } if failed { return urfave.ErrSilent } return nil } func (v *validateStep) readLocalAction(action *workflow.Action) error { found := false for _, file := range []string{"action.yaml", "action.yml"} { p := filepath.Join(v.step.Uses, file) if f, err := afero.Exists(v.fs, p); err != nil { return fmt.Errorf("check if the action file exists: %w", err) } else if !f { continue } found = true if err := workflow.ReadAction(v.fs, p, action); err != nil { return fmt.Errorf("read a local action file: %w", err) } } if !found { return errors.New("local action file not found") } return nil } ================================================ FILE: pkg/controller/schema/workflow.go ================================================ package schema import ( "context" "errors" "fmt" "log/slog" "github.com/spf13/afero" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "github.com/suzuki-shunsuke/slog-error/slogerr" "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" ) func (c *Controller) runWorkflow(ctx context.Context) error { filePaths, err := workflow.List(c.fs) if err != nil { return fmt.Errorf("find workflow files: %w", err) } failed := false for _, filePath := range filePaths { logger := c.logger.With("workflow_file_path", filePath) vw := &validateWorkflow{ workflow: filePath, logger: logger, fs: c.fs, gh: c.gh, rootDir: c.rootDir, } if err := vw.validate(ctx); err != nil { failed = true if !errors.Is(err, urfave.ErrSilent) { slogerr.WithError(logger, err).Error("validate workflow") } } } if failed { return urfave.ErrSilent } return nil } type validateWorkflow struct { workflow string logger *slog.Logger fs afero.Fs gh GitHub rootDir string } func (v *validateWorkflow) validate(ctx context.Context) error { wf := &workflow.Workflow{ FilePath: v.workflow, } if err := workflow.Read(v.fs, v.workflow, wf); err != nil { return fmt.Errorf("read a workflow file: %w", err) } failed := false for name, job := range wf.Jobs { vj := &validateJob{ job: job, logger: v.logger.With("job_key", name), fs: v.fs, gh: v.gh, rootDir: v.rootDir, } if err := vj.validate(ctx); err != nil { failed = true if !errors.Is(err, urfave.ErrSilent) { slogerr.WithError(v.logger, err).Error("validate job") } } } if failed { return urfave.ErrSilent } return nil } ================================================ FILE: pkg/github/github.go ================================================ package github import ( "context" "log/slog" "net/http" "os" "github.com/google/go-github/v86/github" "github.com/suzuki-shunsuke/urfave-cli-v3-util/keyring/ghtoken" "golang.org/x/oauth2" ) type ( ListOptions = github.ListOptions Reference = github.Reference Response = github.Response RepositoryTag = github.RepositoryTag RepositoryRelease = github.RepositoryRelease Client = github.Client GitObject = github.GitObject Commit = github.Commit RepositoryContentGetOptions = github.RepositoryContentGetOptions RepositoryContent = github.RepositoryContent ) func New(ctx context.Context, logger *slog.Logger) *Client { return github.NewClient(getHTTPClientForGitHub(ctx, logger, getGitHubToken())) } func getGitHubToken() string { return os.Getenv("GITHUB_TOKEN") } func checkKeyringEnabled() bool { return os.Getenv("GHALINT_KEYRING_ENABLED") == "true" } func getHTTPClientForGitHub(ctx context.Context, logger *slog.Logger, token string) *http.Client { if token == "" { if checkKeyringEnabled() { return oauth2.NewClient(ctx, ghtoken.NewTokenSource(logger, KeyService)) } return http.DefaultClient } return oauth2.NewClient(ctx, oauth2.StaticTokenSource( &oauth2.Token{AccessToken: token}, )) } ================================================ FILE: pkg/github/keyring.go ================================================ package github const ( KeyService = "suzuki-shunsuke/ghalint" ) ================================================ FILE: pkg/policy/action_ref_should_be_full_length_commit_sha_policy.go ================================================ package policy import ( "errors" "log/slog" "path" "regexp" "strings" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "github.com/suzuki-shunsuke/slog-error/slogerr" ) type ActionRefShouldBeSHAPolicy struct { sha1Pattern *regexp.Regexp sha256Pattern *regexp.Regexp } func NewActionRefShouldBeSHAPolicy() *ActionRefShouldBeSHAPolicy { return &ActionRefShouldBeSHAPolicy{ sha1Pattern: regexp.MustCompile(`\b[0-9a-f]{40}\b`), sha256Pattern: regexp.MustCompile(`\b[0-9a-f]{64}\b`), } } func (p *ActionRefShouldBeSHAPolicy) Name() string { return "action_ref_should_be_full_length_commit_sha" } func (p *ActionRefShouldBeSHAPolicy) ID() string { return "008" } func (p *ActionRefShouldBeSHAPolicy) ApplyJob(_ *slog.Logger, cfg *config.Config, _ *JobContext, job *workflow.Job) error { return p.apply(cfg, job.Uses) } func (p *ActionRefShouldBeSHAPolicy) ApplyStep(_ *slog.Logger, cfg *config.Config, _ *StepContext, step *workflow.Step) error { return p.apply(cfg, step.Uses) } func (p *ActionRefShouldBeSHAPolicy) apply(cfg *config.Config, uses string) error { action := p.checkUses(uses) if action == "" || p.excluded(action, cfg.Excludes) { return nil } return slogerr.With(errors.New("action ref should be full length SHA"), //nolint:wrapcheck "action", action, ) } func (p *ActionRefShouldBeSHAPolicy) checkUses(uses string) string { if uses == "" { return "" } if ref, ok := strings.CutPrefix(uses, "docker://"); ok { repoAndTag, digest, hasDigest := strings.Cut(ref, "@sha256:") if hasDigest && p.sha256Pattern.MatchString(digest) { return "" } repo := repoAndTag lastColon := strings.LastIndex(repoAndTag, ":") lastSlash := strings.LastIndex(repoAndTag, "/") if lastColon != -1 && lastColon > lastSlash { repo = repoAndTag[:lastColon] } return "docker://" + repo } action, tag, ok := strings.Cut(uses, "@") if !ok { return "" } if p.sha1Pattern.MatchString(tag) { return "" } return action } func (p *ActionRefShouldBeSHAPolicy) excluded(action string, excludes []*config.Exclude) bool { for _, exclude := range excludes { if exclude.PolicyName != p.Name() { continue } if f, _ := path.Match(exclude.ActionName, action); f { return true } } return false } ================================================ FILE: pkg/policy/action_ref_should_be_full_length_commit_sha_policy_test.go ================================================ package policy_test import ( "log/slog" "testing" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) func TestActionRefShouldBeSHAPolicy_ApplyJob(t *testing.T) { //nolint:funlen t.Parallel() data := []struct { name string cfg *config.Config job *workflow.Job isErr bool }{ { name: "exclude", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "action_ref_should_be_full_length_commit_sha", ActionName: "slsa-framework/slsa-github-generator", }, { PolicyName: "action_ref_should_be_full_length_commit_sha", ActionName: "suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml", }, }, }, job: &workflow.Job{ Steps: []*workflow.Step{ { Uses: "slsa-framework/slsa-github-generator@v1.5.0", }, }, }, }, { name: "job error", isErr: true, cfg: &config.Config{}, job: &workflow.Job{ Uses: "suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@v0.4.4", }, }, { name: "docker image with digest", cfg: &config.Config{}, job: &workflow.Job{ Uses: "docker://rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9", }, }, { name: "docker image with digest (no tag)", cfg: &config.Config{}, job: &workflow.Job{ Uses: "docker://rhysd/actionlint@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9", }, }, { name: "docker image with port and digest", cfg: &config.Config{}, job: &workflow.Job{ Uses: "docker://registry.example.com:5000/myimage:1.0.0@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9", }, }, { name: "docker image with tag", isErr: true, cfg: &config.Config{}, job: &workflow.Job{ Uses: "docker://rhysd/actionlint:latest", }, }, { name: "docker image with port and tag", isErr: true, cfg: &config.Config{}, job: &workflow.Job{ Uses: "docker://registry.example.com:5000/myimage:latest", }, }, { name: "exclude docker image with tag", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "action_ref_should_be_full_length_commit_sha", ActionName: "docker://rhysd/actionlint", }, }, }, job: &workflow.Job{ Uses: "docker://rhysd/actionlint:latest", }, }, { name: "exclude docker image with port and tag", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "action_ref_should_be_full_length_commit_sha", ActionName: "docker://registry.example.com:5000/myimage", }, }, }, job: &workflow.Job{ Uses: "docker://registry.example.com:5000/myimage:latest", }, }, } p := policy.NewActionRefShouldBeSHAPolicy() logger := slog.New(slog.DiscardHandler) for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() if err := p.ApplyJob(logger, d.cfg, nil, d.job); err != nil { if d.isErr { return } t.Fatal(err) } if d.isErr { t.Fatal("error must be returned") } }) } } func TestActionRefShouldBeSHAPolicy_ApplyStep(t *testing.T) { //nolint:funlen t.Parallel() data := []struct { name string cfg *config.Config step *workflow.Step isErr bool }{ { name: "exclude", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "action_ref_should_be_full_length_commit_sha", ActionName: "slsa-framework/slsa-github-generator", }, { PolicyName: "action_ref_should_be_full_length_commit_sha", ActionName: "suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml", }, }, }, step: &workflow.Step{ Uses: "slsa-framework/slsa-github-generator@v1.5.0", }, }, { name: "exclude with glob pattern", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "action_ref_should_be_full_length_commit_sha", ActionName: "slsa-framework/*", }, }, }, step: &workflow.Step{ Uses: "slsa-framework/slsa-github-generator@v1.5.0", }, }, { name: "step error", isErr: true, cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "action_ref_should_be_full_length_commit_sha", ActionName: "actions/checkout", }, }, }, step: &workflow.Step{ Uses: "slsa-framework/slsa-github-generator@v1.5.0", ID: "generate", Name: "Generate SLSA Provenance", }, }, { name: "docker image with digest", cfg: &config.Config{}, step: &workflow.Step{ Uses: "docker://rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9", }, }, { name: "docker image with digest (no tag)", cfg: &config.Config{}, step: &workflow.Step{ Uses: "docker://rhysd/actionlint@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9", }, }, { name: "docker image with port and digest", cfg: &config.Config{}, step: &workflow.Step{ Uses: "docker://registry.example.com:5000/myimage:1.0.0@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9", }, }, { name: "docker image with tag", isErr: true, cfg: &config.Config{}, step: &workflow.Step{ Uses: "docker://rhysd/actionlint:latest", }, }, { name: "docker image with port and tag", isErr: true, cfg: &config.Config{}, step: &workflow.Step{ Uses: "docker://registry.example.com:5000/myimage:latest", }, }, { name: "exclude docker image with tag", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "action_ref_should_be_full_length_commit_sha", ActionName: "docker://rhysd/actionlint", }, }, }, step: &workflow.Step{ Uses: "docker://rhysd/actionlint:latest", }, }, { name: "exclude docker image with port and tag", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "action_ref_should_be_full_length_commit_sha", ActionName: "docker://registry.example.com:5000/myimage", }, }, }, step: &workflow.Step{ Uses: "docker://registry.example.com:5000/myimage:latest", }, }, } p := policy.NewActionRefShouldBeSHAPolicy() logger := slog.New(slog.DiscardHandler) for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() if err := p.ApplyStep(logger, d.cfg, nil, d.step); err != nil { if d.isErr { return } t.Fatal(err) } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/policy/action_shell_is_required.go ================================================ package policy import ( "errors" "log/slog" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) type ActionShellIsRequiredPolicy struct{} func (p *ActionShellIsRequiredPolicy) Name() string { return "action_shell_is_required" } func (p *ActionShellIsRequiredPolicy) ID() string { return "011" } func (p *ActionShellIsRequiredPolicy) ApplyStep(_ *slog.Logger, _ *config.Config, _ *StepContext, step *workflow.Step) error { if step.Run != "" && step.Shell == "" { return errors.New("shell is required if run is set") } return nil } ================================================ FILE: pkg/policy/action_shell_is_required_test.go ================================================ package policy_test import ( "log/slog" "testing" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) func TestActionShellIsRequiredPolicy_ApplyStep(t *testing.T) { t.Parallel() data := []struct { name string step *workflow.Step isErr bool }{ { name: "pass", step: &workflow.Step{ Run: "echo hello", Shell: "bash", }, }, { name: "step error", isErr: true, step: &workflow.Step{ Run: "echo hello", }, }, } p := &policy.ActionShellIsRequiredPolicy{} logger := slog.New(slog.DiscardHandler) for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() if err := p.ApplyStep(logger, nil, nil, d.step); err != nil { if d.isErr { return } t.Fatal(err) } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/policy/checkout_persist_credentials_should_be_false.go ================================================ package policy import ( "errors" "log/slog" "strings" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) type CheckoutPersistCredentialShouldBeFalsePolicy struct{} func (p *CheckoutPersistCredentialShouldBeFalsePolicy) Name() string { return "checkout_persist_credentials_should_be_false" } func (p *CheckoutPersistCredentialShouldBeFalsePolicy) ID() string { return "013" } func (p *CheckoutPersistCredentialShouldBeFalsePolicy) ApplyStep(_ *slog.Logger, cfg *config.Config, stepCtx *StepContext, step *workflow.Step) error { if p.excluded(stepCtx, cfg.Excludes) { return nil } if !strings.HasPrefix(step.Uses, "actions/checkout@") { return nil } f, ok := step.With["persist-credentials"] if !ok { return errors.New("persist-credentials should be false") } if f != "false" { return errors.New("persist-credentials should be false") } return nil } func (p *CheckoutPersistCredentialShouldBeFalsePolicy) excluded(stepCtx *StepContext, excludes []*config.Exclude) bool { for _, exclude := range excludes { if exclude.PolicyName != p.Name() { continue } if stepCtx.Action != nil { if exclude.ActionFilePath != stepCtx.FilePath { continue } return true } if exclude.JobName != stepCtx.Job.Name { continue } if exclude.WorkflowFilePath != stepCtx.Job.Workflow.FilePath { continue } return true } return false } ================================================ FILE: pkg/policy/checkout_persist_credentials_should_be_false_test.go ================================================ package policy_test import ( "log/slog" "testing" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) func TestCheckoutPersistCredentialShouldBeFalsePolicy_ApplyStep(t *testing.T) { //nolint:funlen t.Parallel() data := []struct { name string cfg *config.Config step *workflow.Step stepCtx *policy.StepContext isErr bool }{ { name: "exclude", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "checkout_persist_credentials_should_be_false", WorkflowFilePath: ".github/workflows/test.yml", JobName: "test", }, }, }, stepCtx: &policy.StepContext{ Job: &policy.JobContext{ Name: "test", Workflow: &policy.WorkflowContext{ FilePath: ".github/workflows/test.yml", }, }, }, step: &workflow.Step{ Uses: "actions/checkout@v4", }, }, { name: "persist-credentials is not set", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "checkout_persist_credentials_should_be_false", JobName: "test-2", WorkflowFilePath: ".github/workflows/test.yml", }, }, }, stepCtx: &policy.StepContext{ Job: &policy.JobContext{ Name: "test", Workflow: &policy.WorkflowContext{ FilePath: ".github/workflows/test.yml", }, }, }, step: &workflow.Step{ Uses: "actions/checkout@v4", }, isErr: true, }, { name: "persist-credentials is true", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "checkout_persist_credentials_should_be_false", JobName: "test-2", }, }, }, stepCtx: &policy.StepContext{ Job: &policy.JobContext{ Name: "test", Workflow: &policy.WorkflowContext{ FilePath: ".github/workflows/test.yml", }, }, }, step: &workflow.Step{ Uses: "actions/checkout@v4", With: map[string]string{ "persist-credentials": "true", }, }, isErr: true, }, { name: "persist-credentials is false", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "checkout_persist_credentials_should_be_false", JobName: "test-2", }, }, }, stepCtx: &policy.StepContext{ Job: &policy.JobContext{ Name: "test", Workflow: &policy.WorkflowContext{ FilePath: ".github/workflows/test.yml", }, }, }, step: &workflow.Step{ Uses: "actions/checkout@v4", With: map[string]string{ "persist-credentials": "false", }, }, }, { name: "not checkout", cfg: &config.Config{ Excludes: []*config.Exclude{}, }, stepCtx: &policy.StepContext{ Job: &policy.JobContext{ Name: "test", Workflow: &policy.WorkflowContext{ FilePath: ".github/workflows/test.yml", }, }, }, step: &workflow.Step{ Uses: "actions/cache@v4", }, }, } p := &policy.CheckoutPersistCredentialShouldBeFalsePolicy{} logger := slog.New(slog.DiscardHandler) for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() if err := p.ApplyStep(logger, d.cfg, d.stepCtx, d.step); err != nil { if d.isErr { return } t.Fatal(err) } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/policy/context.go ================================================ package policy import "github.com/suzuki-shunsuke/ghalint/pkg/workflow" type WorkflowContext struct { FilePath string Workflow *workflow.Workflow } type JobContext struct { Name string Workflow *WorkflowContext Job *workflow.Job } type StepContext struct { FilePath string Action *workflow.Action Job *JobContext } ================================================ FILE: pkg/policy/deny_inherit_secrets.go ================================================ package policy import ( "errors" "log/slog" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) type DenyInheritSecretsPolicy struct{} func (p *DenyInheritSecretsPolicy) Name() string { return "deny_inherit_secrets" } func (p *DenyInheritSecretsPolicy) ID() string { return "004" } func (p *DenyInheritSecretsPolicy) ApplyJob(_ *slog.Logger, cfg *config.Config, jobCtx *JobContext, job *workflow.Job) error { if checkExcludes(p.Name(), jobCtx, cfg) { return nil } if job.Secrets.Inherit() { return errors.New("`secrets: inherit` should not be used. Only required secrets should be passed explicitly") } return nil } ================================================ FILE: pkg/policy/deny_inherit_secrets_test.go ================================================ //nolint:funlen package policy_test import ( "log/slog" "testing" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "gopkg.in/yaml.v3" ) func TestDenyInheritSecretsPolicy_ApplyJob(t *testing.T) { t.Parallel() data := []struct { name string job string cfg *config.Config jobCtx *policy.JobContext isErr bool }{ { name: "exclude", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "deny_inherit_secrets", WorkflowFilePath: ".github/workflows/test.yaml", JobName: "foo", }, }, }, jobCtx: &policy.JobContext{ Workflow: &policy.WorkflowContext{ FilePath: ".github/workflows/test.yaml", }, Name: "foo", }, job: `secrets: inherit`, }, { name: "not exclude", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "deny_inherit_secrets", WorkflowFilePath: ".github/workflows/test.yaml", JobName: "bar", }, }, }, jobCtx: &policy.JobContext{ Workflow: &policy.WorkflowContext{ FilePath: ".github/workflows/test.yaml", }, Name: "foo", }, job: `secrets: inherit`, isErr: true, }, { name: "error", job: `secrets: inherit`, cfg: &config.Config{}, jobCtx: &policy.JobContext{ Workflow: &policy.WorkflowContext{ FilePath: ".github/workflows/test.yaml", }, Name: "foo", }, isErr: true, }, { name: "pass", cfg: &config.Config{}, jobCtx: &policy.JobContext{ Workflow: &policy.WorkflowContext{ FilePath: ".github/workflows/test.yaml", }, Name: "foo", }, job: `secrets: foo: ${{secrets.API_KEY}}`, }, } p := &policy.DenyInheritSecretsPolicy{} logger := slog.New(slog.DiscardHandler) for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() job := &workflow.Job{} if err := yaml.Unmarshal([]byte(d.job), job); err != nil { t.Fatal(err) } if err := p.ApplyJob(logger, d.cfg, d.jobCtx, job); err != nil { if d.isErr { return } t.Fatal(err) } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/policy/deny_job_container_latest_image.go ================================================ package policy import ( "errors" "log/slog" "strings" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) type DenyJobContainerLatestImagePolicy struct{} func (p *DenyJobContainerLatestImagePolicy) Name() string { return "deny_job_container_latest_image" } func (p *DenyJobContainerLatestImagePolicy) ID() string { return "007" } func (p *DenyJobContainerLatestImagePolicy) ApplyJob(logger *slog.Logger, _ *config.Config, _ *JobContext, job *workflow.Job) error { if job.Container == nil { return nil } if job.Container.Image == "" { return errors.New("job container should have image") } if strings.Contains(job.Container.Image, "${{") { logger.Debug("job container image contains `${{`; skipping latest image check") return nil } _, tag, ok := strings.Cut(job.Container.Image, ":") if !ok { return errors.New("job container image should be :") } if tag == "latest" { return errors.New("job container image tag should not be `latest`") } return nil } ================================================ FILE: pkg/policy/deny_job_container_latest_image_test.go ================================================ package policy_test import ( "log/slog" "testing" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) func TestDenyJobContainerLatestImagePolicy_ApplyJob(t *testing.T) { //nolint:funlen t.Parallel() data := []struct { name string job *workflow.Job isErr bool }{ { name: "pass", job: &workflow.Job{ Container: &workflow.Container{ Image: "node:18", }, }, }, { name: "job container should have image", job: &workflow.Job{ Container: &workflow.Container{}, }, isErr: true, }, { name: "job container image should have tag", job: &workflow.Job{ Container: &workflow.Container{ Image: "node", }, }, isErr: true, }, { name: "latest", job: &workflow.Job{ Container: &workflow.Container{ Image: "node:latest", }, }, isErr: true, }, { name: "Use variables", job: &workflow.Job{ Container: &workflow.Container{ Image: "mirror.gcr.io/${{needs.list.outputs.image}}", }, }, isErr: false, }, } p := &policy.DenyJobContainerLatestImagePolicy{} logger := slog.New(slog.DiscardHandler) for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() if err := p.ApplyJob(logger, nil, nil, d.job); err != nil { if !d.isErr { t.Fatal(err) } return } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/policy/deny_read_all_policy.go ================================================ package policy import ( "errors" "log/slog" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) type DenyReadAllPermissionPolicy struct{} func (p *DenyReadAllPermissionPolicy) Name() string { return "deny_read_all_permission" } func (p *DenyReadAllPermissionPolicy) ID() string { return "002" } func (p *DenyReadAllPermissionPolicy) ApplyJob(_ *slog.Logger, _ *config.Config, jobCtx *JobContext, job *workflow.Job) error { wfReadAll := jobCtx.Workflow.Workflow.Permissions.ReadAll() if job.Permissions.ReadAll() { return errors.New("don't use read-all permission") } if job.Permissions.IsNil() && wfReadAll { return errors.New("don't use read-all permission") } return nil } ================================================ FILE: pkg/policy/deny_read_all_policy_test.go ================================================ package policy_test //nolint:dupl import ( "log/slog" "testing" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) func TestDenyReadAllPermissionPolicy_ApplyJob(t *testing.T) { t.Parallel() data := []struct { name string jobCtx *policy.JobContext job *workflow.Job isErr bool }{ { name: "don't use read-all", job: &workflow.Job{ Permissions: workflow.NewPermissions(true, false, nil), }, isErr: true, }, { name: "job permissions is null and workflow permissions is read-all", jobCtx: &policy.JobContext{ Workflow: &policy.WorkflowContext{ Workflow: &workflow.Workflow{ Permissions: workflow.NewPermissions(true, false, nil), }, }, }, job: &workflow.Job{}, isErr: true, }, { name: "pass", job: &workflow.Job{ Permissions: workflow.NewPermissions(false, false, map[string]string{ "contents": "read", }), }, }, } p := &policy.DenyReadAllPermissionPolicy{} logger := slog.New(slog.DiscardHandler) for _, d := range data { if d.jobCtx == nil { d.jobCtx = &policy.JobContext{ Workflow: &policy.WorkflowContext{ Workflow: &workflow.Workflow{}, }, } } t.Run(d.name, func(t *testing.T) { t.Parallel() if err := p.ApplyJob(logger, nil, d.jobCtx, d.job); err != nil { if !d.isErr { t.Fatal(err) } return } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/policy/deny_write_all_policy.go ================================================ package policy import ( "errors" "log/slog" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) type DenyWriteAllPermissionPolicy struct{} func (p *DenyWriteAllPermissionPolicy) Name() string { return "deny_write_all_permission" } func (p *DenyWriteAllPermissionPolicy) ID() string { return "003" } func (p *DenyWriteAllPermissionPolicy) ApplyJob(_ *slog.Logger, _ *config.Config, jobCtx *JobContext, job *workflow.Job) error { wfWriteAll := jobCtx.Workflow.Workflow.Permissions.WriteAll() if job.Permissions.WriteAll() { return errors.New("don't use write-all permission") } if job.Permissions.IsNil() && wfWriteAll { return errors.New("don't use write-all permission") } return nil } ================================================ FILE: pkg/policy/deny_write_all_policy_test.go ================================================ package policy_test //nolint:dupl import ( "log/slog" "testing" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) func TestDenyWriteAllPermissionPolicy_ApplyJob(t *testing.T) { t.Parallel() data := []struct { name string jobCtx *policy.JobContext job *workflow.Job isErr bool }{ { name: "don't use write-all", job: &workflow.Job{ Permissions: workflow.NewPermissions(false, true, nil), }, isErr: true, }, { name: "job permissions is null and workflow permissions is write-all", jobCtx: &policy.JobContext{ Workflow: &policy.WorkflowContext{ Workflow: &workflow.Workflow{ Permissions: workflow.NewPermissions(false, true, nil), }, }, }, job: &workflow.Job{}, isErr: true, }, { name: "pass", job: &workflow.Job{ Permissions: workflow.NewPermissions(false, false, map[string]string{ "contents": "write", }), }, }, } p := &policy.DenyWriteAllPermissionPolicy{} logger := slog.New(slog.DiscardHandler) for _, d := range data { if d.jobCtx == nil { d.jobCtx = &policy.JobContext{ Workflow: &policy.WorkflowContext{ Workflow: &workflow.Workflow{}, }, } } t.Run(d.name, func(t *testing.T) { t.Parallel() if err := p.ApplyJob(logger, nil, d.jobCtx, d.job); err != nil { if !d.isErr { t.Fatal(err) } return } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/policy/error.go ================================================ package policy import "errors" var ( errPermissionHyphenIsRequired = errors.New("an input `permission-*` is required") errPermissionsIsRequired = errors.New("the input `permissions` is required") errRepositoriesIsRequired = errors.New("the input `repositories` is required") errEmpty = errors.New("") ) ================================================ FILE: pkg/policy/github_app_should_limit_permissions.go ================================================ package policy import ( "log/slog" "strings" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "github.com/suzuki-shunsuke/slog-error/slogerr" ) type GitHubAppShouldLimitPermissionsPolicy struct{} func (p *GitHubAppShouldLimitPermissionsPolicy) Name() string { return "github_app_should_limit_permissions" } func (p *GitHubAppShouldLimitPermissionsPolicy) ID() string { return "010" } func (p *GitHubAppShouldLimitPermissionsPolicy) ApplyStep(_ *slog.Logger, _ *config.Config, _ *StepContext, step *workflow.Step) (ge error) { //nolint:cyclop action := p.checkUses(step.Uses) if action == "" { return nil } defer func() { if ge != nil { ge = slogerr.With(ge, "action", action, ) } }() switch action { case "tibdex/github-app-token": if step.With == nil { return errPermissionsIsRequired } if _, ok := step.With["permissions"]; !ok { return errPermissionsIsRequired } case "actions/create-github-app-token": if step.With == nil { return errPermissionsIsRequired } err := errPermissionHyphenIsRequired for k := range step.With { if strings.HasPrefix(k, "permission-") { err = nil break } } if err != nil { return err } } return nil } func (p *GitHubAppShouldLimitPermissionsPolicy) checkUses(uses string) string { if uses == "" { return "" } action, _, _ := strings.Cut(uses, "@") return action } ================================================ FILE: pkg/policy/github_app_should_limit_permissions_test.go ================================================ package policy_test import ( "log/slog" "testing" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) func TestGitHubAppShouldLimitPermissionsPolicy_ApplyStep(t *testing.T) { //nolint:funlen t.Parallel() data := []struct { name string cfg *config.Config stepCtx *policy.StepContext step *workflow.Step isErr bool }{ { name: "tibdex/github-app-token fail", isErr: true, cfg: &config.Config{}, step: &workflow.Step{ Uses: "tibdex/github-app-token@v2", ID: "token", With: map[string]string{ "app_id": "xxx", "private_key": "xxx", }, }, }, { name: "tibdex/github-app-token success", cfg: &config.Config{}, step: &workflow.Step{ Uses: "tibdex/github-app-token@v2", ID: "token", With: map[string]string{ "app_id": "xxx", "private_key": "xxx", "permissions": "{}", }, }, }, { name: "actions/create-github-app-token fail", isErr: true, cfg: &config.Config{}, step: &workflow.Step{ Uses: "actions/create-github-app-token@v1.12.0", ID: "token", With: map[string]string{ "app-id": "xxx", "private-key": "xxx", }, }, }, { name: "actions/create-github-app-token succeed", cfg: &config.Config{}, step: &workflow.Step{ Uses: "actions/create-github-app-token@v1.12.0", ID: "token", With: map[string]string{ "app-id": "xxx", "private-key": "xxx", "permission-issues": "write", }, }, }, } p := &policy.GitHubAppShouldLimitPermissionsPolicy{} logger := slog.New(slog.DiscardHandler) for _, d := range data { if d.stepCtx == nil { d.stepCtx = &policy.StepContext{ FilePath: ".github/workflows/test.yaml", Job: &policy.JobContext{ Name: "test", Workflow: &policy.WorkflowContext{ FilePath: ".github/workflows/test.yaml", }, }, } } t.Run(d.name, func(t *testing.T) { t.Parallel() if err := p.ApplyStep(logger, d.cfg, d.stepCtx, d.step); err != nil { if d.isErr { return } t.Fatal(err) } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/policy/github_app_should_limit_repositories.go ================================================ package policy import ( "log/slog" "strings" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "github.com/suzuki-shunsuke/slog-error/slogerr" ) type GitHubAppShouldLimitRepositoriesPolicy struct{} func (p *GitHubAppShouldLimitRepositoriesPolicy) Name() string { return "github_app_should_limit_repositories" } func (p *GitHubAppShouldLimitRepositoriesPolicy) ID() string { return "009" } func (p *GitHubAppShouldLimitRepositoriesPolicy) ApplyStep(logger *slog.Logger, cfg *config.Config, stepCtx *StepContext, step *workflow.Step) (ge error) { //nolint:cyclop action := p.checkUses(step.Uses) if action == "" { return nil } defer func() { if ge != nil { ge = slogerr.With(ge, "action", action, ) } }() if p.excluded(cfg, stepCtx, step) { logger.Debug("this step is ignored") return nil } if action == "tibdex/github-app-token" { if step.With == nil { return errRepositoriesIsRequired } if _, ok := step.With["repositories"]; !ok { return errRepositoriesIsRequired } return nil } if action == "actions/create-github-app-token" { if step.With == nil { return errRepositoriesIsRequired } if _, ok := step.With["repositories"]; ok { return nil } if _, ok := step.With["owner"]; ok { return errRepositoriesIsRequired } return nil } return nil } func (p *GitHubAppShouldLimitRepositoriesPolicy) checkUses(uses string) string { if uses == "" { return "" } action, _, _ := strings.Cut(uses, "@") return action } func (p *GitHubAppShouldLimitRepositoriesPolicy) excluded(cfg *config.Config, stepCtx *StepContext, step *workflow.Step) bool { for _, exclude := range cfg.Excludes { if exclude.PolicyName != p.Name() { continue } if exclude.FilePath() != stepCtx.FilePath { continue } if stepCtx.Job != nil && exclude.JobName != stepCtx.Job.Name { continue } if exclude.StepID != step.ID { continue } return true } return false } ================================================ FILE: pkg/policy/github_app_should_limit_repositories_test.go ================================================ package policy_test import ( "log/slog" "testing" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) func TestGitHubAppShouldLimitRepositoriesPolicy_ApplyStep(t *testing.T) { //nolint:funlen t.Parallel() data := []struct { name string cfg *config.Config stepCtx *policy.StepContext step *workflow.Step isErr bool }{ { name: "tibdex/github-app-token fail", isErr: true, cfg: &config.Config{}, step: &workflow.Step{ Uses: "tibdex/github-app-token@v2", ID: "token", With: map[string]string{ "app_id": "xxx", "private_key": "xxx", }, }, }, { name: "tibdex/github-app-token success", cfg: &config.Config{}, step: &workflow.Step{ Uses: "tibdex/github-app-token@v2", ID: "token", With: map[string]string{ "app_id": "xxx", "private_key": "xxx", "repositories": "{}", }, }, }, { name: "actions/create-github-app-token fail", isErr: true, cfg: &config.Config{}, step: &workflow.Step{ Uses: "actions/create-github-app-token@v2", ID: "token", With: map[string]string{ "app-id": "xxx", "private-key": "xxx", "owner": "xxx", }, }, }, { name: "actions/create-github-app-token success", cfg: &config.Config{}, step: &workflow.Step{ Uses: "actions/create-github-app-token@v2", ID: "token", With: map[string]string{ "app-id": "xxx", "private-key": "xxx", "owner": "xxx", "repositories": "foo,bar", }, }, }, { name: "actions/create-github-app-token success no owner", cfg: &config.Config{}, step: &workflow.Step{ Uses: "actions/create-github-app-token@v2", ID: "token", With: map[string]string{ "app-id": "xxx", "private-key": "xxx", }, }, }, { name: "exclude", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "github_app_should_limit_repositories", WorkflowFilePath: ".github/workflows/test.yaml", JobName: "test", StepID: "token", }, }, }, stepCtx: &policy.StepContext{ FilePath: ".github/workflows/test.yaml", Job: &policy.JobContext{ Name: "test", Workflow: &policy.WorkflowContext{ FilePath: ".github/workflows/test.yaml", }, }, }, step: &workflow.Step{ Uses: "tibdex/github-app-token@v2", ID: "token", With: map[string]string{ "app_id": "xxx", "private_key": "xxx", }, }, }, { name: "exclude action", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "github_app_should_limit_repositories", ActionFilePath: "foo/action.yaml", StepID: "token", }, }, }, stepCtx: &policy.StepContext{ FilePath: "foo/action.yaml", }, step: &workflow.Step{ Uses: "tibdex/github-app-token@v2", ID: "token", With: map[string]string{ "app_id": "xxx", "private_key": "xxx", }, }, }, } p := &policy.GitHubAppShouldLimitRepositoriesPolicy{} logger := slog.New(slog.DiscardHandler) for _, d := range data { if d.stepCtx == nil { d.stepCtx = &policy.StepContext{ FilePath: ".github/workflows/test.yaml", Job: &policy.JobContext{ Name: "test", Workflow: &policy.WorkflowContext{ FilePath: ".github/workflows/test.yaml", }, }, } } t.Run(d.name, func(t *testing.T) { t.Parallel() if err := p.ApplyStep(logger, d.cfg, d.stepCtx, d.step); err != nil { if d.isErr { return } t.Fatal(err) } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/policy/job_permissions_policy.go ================================================ package policy import ( "errors" "log/slog" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) type JobPermissionsPolicy struct{} func (p *JobPermissionsPolicy) Name() string { return "job_permissions" } func (p *JobPermissionsPolicy) ID() string { return "001" } func (p *JobPermissionsPolicy) ApplyJob(_ *slog.Logger, _ *config.Config, jobCtx *JobContext, job *workflow.Job) error { wf := jobCtx.Workflow.Workflow wfPermissions := wf.Permissions.Permissions() if wfPermissions != nil && len(wfPermissions) == 0 { // workflow's permissions is `{}` return nil } if len(wf.Jobs) < 2 && wfPermissions != nil { // workflow permissions is set and there is only one job return nil } if job.Permissions.IsNil() { return errors.New("job should have permissions") } return nil } ================================================ FILE: pkg/policy/job_permissions_policy_test.go ================================================ package policy_test import ( "log/slog" "testing" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) func TestJobPermissionsPolicy_ApplyJob(t *testing.T) { //nolint:funlen t.Parallel() data := []struct { name string jobCtx *policy.JobContext job *workflow.Job isErr bool }{ { name: "workflow permissions is empty", job: &workflow.Job{}, jobCtx: &policy.JobContext{ Workflow: &policy.WorkflowContext{ Workflow: &workflow.Workflow{ Permissions: workflow.NewPermissions(false, false, map[string]string{}), Jobs: map[string]*workflow.Job{ "foo": {}, }, }, }, }, }, { name: "workflow has only one job", jobCtx: &policy.JobContext{ Workflow: &policy.WorkflowContext{ Workflow: &workflow.Workflow{ Permissions: workflow.NewPermissions(false, false, map[string]string{ "contents": "read", }), Jobs: map[string]*workflow.Job{ "foo": {}, }, }, }, }, job: &workflow.Job{}, }, { name: "job should have permissions", jobCtx: &policy.JobContext{ Workflow: &policy.WorkflowContext{ Workflow: &workflow.Workflow{ Permissions: &workflow.Permissions{}, Jobs: map[string]*workflow.Job{ "foo": {}, "bar": {}, }, }, }, }, job: &workflow.Job{}, isErr: true, }, } p := &policy.JobPermissionsPolicy{} logger := slog.New(slog.DiscardHandler) for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() if err := p.ApplyJob(logger, nil, d.jobCtx, d.job); err != nil { if !d.isErr { t.Fatal(err) } return } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/policy/job_secrets_policy.go ================================================ package policy import ( "errors" "log/slog" "regexp" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "github.com/suzuki-shunsuke/slog-error/slogerr" ) type JobSecretsPolicy struct { secretPattern *regexp.Regexp githubTokenPattern *regexp.Regexp } func NewJobSecretsPolicy() *JobSecretsPolicy { return &JobSecretsPolicy{ secretPattern: regexp.MustCompile(`\${{ *secrets\.[^ ]+ *}}`), githubTokenPattern: regexp.MustCompile(`\${{ *github\.token+ *}}`), } } func (p *JobSecretsPolicy) Name() string { return "job_secrets" } func (p *JobSecretsPolicy) ID() string { return "006" } func checkExcludes(policyName string, jobCtx *JobContext, cfg *config.Config) bool { for _, exclude := range cfg.Excludes { if exclude.PolicyName == policyName && jobCtx.Workflow.FilePath == exclude.WorkflowFilePath && jobCtx.Name == exclude.JobName { return true } } return false } func (p *JobSecretsPolicy) ApplyJob(_ *slog.Logger, cfg *config.Config, jobCtx *JobContext, job *workflow.Job) error { if checkExcludes(p.Name(), jobCtx, cfg) { return nil } if len(job.Steps) < 2 { //nolint:mnd return nil } for envName, envValue := range job.Env { if p.secretPattern.MatchString(envValue) { return slogerr.With(errors.New("secret should not be set to job's env"), //nolint:wrapcheck "env_name", envName, ) } if p.githubTokenPattern.MatchString(envValue) { return slogerr.With(errors.New("github.token should not be set to job's env"), //nolint:wrapcheck "env_name", envName, ) } } return nil } ================================================ FILE: pkg/policy/job_secrets_policy_test.go ================================================ package policy_test import ( "log/slog" "testing" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) func TestJobSecretsPolicy_ApplyJob(t *testing.T) { //nolint:funlen t.Parallel() data := []struct { name string cfg *config.Config jobCtx *policy.JobContext job *workflow.Job isErr bool }{ { name: "exclude", cfg: &config.Config{ Excludes: []*config.Exclude{ { PolicyName: "job_secrets", WorkflowFilePath: ".github/workflows/test.yaml", JobName: "foo", }, }, }, jobCtx: &policy.JobContext{ Workflow: &policy.WorkflowContext{ FilePath: ".github/workflows/test.yaml", }, Name: "foo", }, job: &workflow.Job{ Env: map[string]string{ //nolint:gosec "GITHUB_TOKEN": "${{github.token}}", }, Steps: []*workflow.Step{ {}, {}, }, }, }, { name: "job has only one step", cfg: &config.Config{}, jobCtx: &policy.JobContext{}, job: &workflow.Job{ Env: map[string]string{ //nolint:gosec "GITHUB_TOKEN": "${{github.token}}", }, Steps: []*workflow.Step{ {}, }, }, }, { name: "secret should not be set to job's env", cfg: &config.Config{}, jobCtx: &policy.JobContext{}, job: &workflow.Job{ Env: map[string]string{ //nolint:gosec "GITHUB_TOKEN": "${{secrets.GITHUB_TOKEN}}", }, Steps: []*workflow.Step{ {}, {}, }, }, isErr: true, }, { name: "github token should not be set to job's env", cfg: &config.Config{}, jobCtx: &policy.JobContext{}, job: &workflow.Job{ Env: map[string]string{ //nolint:gosec "GITHUB_TOKEN": "${{github.token}}", }, Steps: []*workflow.Step{ {}, {}, }, }, isErr: true, }, { name: "pass", cfg: &config.Config{}, jobCtx: &policy.JobContext{}, job: &workflow.Job{ Env: map[string]string{ "FOO": "foo", }, Steps: []*workflow.Step{ {}, {}, }, }, }, } p := policy.NewJobSecretsPolicy() logger := slog.New(slog.DiscardHandler) for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() if err := p.ApplyJob(logger, d.cfg, d.jobCtx, d.job); err != nil { if !d.isErr { t.Fatal(err) } return } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/policy/job_timeout_minutes_is_required.go ================================================ package policy import ( "errors" "log/slog" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) type JobTimeoutMinutesIsRequiredPolicy struct{} func (p *JobTimeoutMinutesIsRequiredPolicy) Name() string { return "job_timeout_minutes_is_required" } func (p *JobTimeoutMinutesIsRequiredPolicy) ID() string { return "012" } func (p *JobTimeoutMinutesIsRequiredPolicy) ApplyJob(_ *slog.Logger, _ *config.Config, _ *JobContext, job *workflow.Job) error { if job.TimeoutMinutes != nil { return nil } if job.Uses != "" { // when a reusable workflow is called with "uses", "timeout-minutes" is not available. return nil } for _, step := range job.Steps { if step.TimeoutMinutes == nil { return errors.New("job's timeout-minutes is required") } } return nil } ================================================ FILE: pkg/policy/job_timeout_minutes_is_required_test.go ================================================ package policy_test import ( "log/slog" "testing" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) func TestJobTimeoutMinutesIsRequiredPolicy_ApplyJob(t *testing.T) { //nolint:funlen t.Parallel() data := []struct { name string job *workflow.Job isErr bool }{ { name: "normal", job: &workflow.Job{ TimeoutMinutes: 30, Steps: []*workflow.Step{ { Run: "echo hello", }, }, }, }, { name: "expression is used", job: &workflow.Job{ TimeoutMinutes: "${{ matrix.timeout-minutes }}", Steps: []*workflow.Step{ { Run: "echo hello", }, }, }, }, { name: "workflow using reusable workflow", job: &workflow.Job{ Uses: "suzuki-shunsuke/renovate-config-validator-workflow/.github/workflows/validate.yaml@v0.2.3", }, }, { name: "job should have timeout-minutes", job: &workflow.Job{ Steps: []*workflow.Step{ { Run: "echo hello", }, }, }, isErr: true, }, { name: "all steps have timeout-minutes", job: &workflow.Job{ Steps: []*workflow.Step{ { Run: "echo hello", TimeoutMinutes: 60, }, { Run: "echo hello", TimeoutMinutes: 60, }, }, }, }, { name: "expression is used in step's timeout-minutes", job: &workflow.Job{ Steps: []*workflow.Step{ { Run: "echo hello", TimeoutMinutes: "${{ matrix.timeout-minutes }}", }, { Run: "echo hello", TimeoutMinutes: 60, }, }, }, }, } p := &policy.JobTimeoutMinutesIsRequiredPolicy{} logger := slog.New(slog.DiscardHandler) for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() if err := p.ApplyJob(logger, nil, nil, d.job); err != nil { if !d.isErr { t.Fatal(err) } return } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/policy/workflow_secrets_policy.go ================================================ package policy import ( "log/slog" "regexp" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) type WorkflowSecretsPolicy struct { secretPattern *regexp.Regexp githubTokenPattern *regexp.Regexp } func NewWorkflowSecretsPolicy() *WorkflowSecretsPolicy { return &WorkflowSecretsPolicy{ secretPattern: regexp.MustCompile(`\${{ *secrets\.[^ ]+ *}}`), githubTokenPattern: regexp.MustCompile(`\${{ *github\.token+ *}}`), } } func (p *WorkflowSecretsPolicy) Name() string { return "workflow_secrets" } func (p *WorkflowSecretsPolicy) ID() string { return "005" } func (p *WorkflowSecretsPolicy) ApplyWorkflow(logger *slog.Logger, _ *config.Config, _ *WorkflowContext, wf *workflow.Workflow) error { if len(wf.Jobs) < 2 { //nolint:mnd return nil } failed := false for envName, envValue := range wf.Env { if p.secretPattern.MatchString(envValue) { failed = true logger.Error("secret should not be set to workflow's env", "env_name", envName) } if p.githubTokenPattern.MatchString(envValue) { failed = true logger.Error("github.token should not be set to workflow's env", "env_name", envName) } } if failed { return errEmpty } return nil } ================================================ FILE: pkg/policy/workflow_secrets_policy_test.go ================================================ package policy_test import ( "log/slog" "testing" "github.com/suzuki-shunsuke/ghalint/pkg/config" "github.com/suzuki-shunsuke/ghalint/pkg/policy" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" ) func TestWorkflowSecretsPolicy_ApplyWorkflow(t *testing.T) { //nolint:funlen t.Parallel() data := []struct { name string cfg *config.Config wf *workflow.Workflow isErr bool }{ { name: "workflow has only one job", cfg: &config.Config{}, wf: &workflow.Workflow{ FilePath: ".github/workflows/test.yaml", Env: map[string]string{ //nolint:gosec "GITHUB_TOKEN": "${{github.token}}", }, Jobs: map[string]*workflow.Job{ "foo": {}, }, }, }, { name: "secret should not be set to workflow's env", cfg: &config.Config{}, wf: &workflow.Workflow{ FilePath: ".github/workflows/test.yaml", Env: map[string]string{ //nolint:gosec "GITHUB_TOKEN": "${{secrets.GITHUB_TOKEN}}", }, Jobs: map[string]*workflow.Job{ "foo": {}, "bar": {}, }, }, isErr: true, }, { name: "github token should not be set to workflow's env", cfg: &config.Config{}, wf: &workflow.Workflow{ FilePath: ".github/workflows/test.yaml", Env: map[string]string{ //nolint:gosec "GITHUB_TOKEN": "${{github.token}}", }, Jobs: map[string]*workflow.Job{ "foo": {}, "bar": {}, }, }, isErr: true, }, { name: "pass", cfg: &config.Config{}, wf: &workflow.Workflow{ FilePath: ".github/workflows/test.yaml", Env: map[string]string{ "FOO": "foo", }, Jobs: map[string]*workflow.Job{ "foo": {}, "bar": {}, }, }, }, } p := policy.NewWorkflowSecretsPolicy() logger := slog.New(slog.DiscardHandler) for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() if err := p.ApplyWorkflow(logger, d.cfg, nil, d.wf); err != nil { if !d.isErr { t.Fatal(err) } return } if d.isErr { t.Fatal("error must be returned") } }) } } ================================================ FILE: pkg/workflow/container.go ================================================ package workflow import ( "errors" ) type Container struct { Image string } func (c *Container) UnmarshalYAML(unmarshal func(any) error) error { var val any if err := unmarshal(&val); err != nil { return err } return convContainer(val, c) } func convContainer(src any, c *Container) error { //nolint:cyclop switch p := src.(type) { case string: c.Image = p return nil case map[any]any: for k, v := range p { key, ok := k.(string) if !ok { continue } if key != "image" { continue } image, ok := v.(string) if !ok { return errors.New("image must be a string") } c.Image = image return nil } return nil case map[string]any: for k, v := range p { if k != "image" { continue } image, ok := v.(string) if !ok { return errors.New("image must be a string") } c.Image = image return nil } return nil default: return errors.New("container must be a map or string") } } ================================================ FILE: pkg/workflow/container_test.go ================================================ package workflow_test import ( "testing" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "gopkg.in/yaml.v3" ) func TestContainer_UnmarshalYAML(t *testing.T) { t.Parallel() data := []struct { name string yaml string image string }{ { name: "normal", yaml: "image: node:18", image: "node:18", }, { name: "string", yaml: "node:18", image: "node:18", }, } for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() c := &workflow.Container{} if err := yaml.Unmarshal([]byte(d.yaml), c); err != nil { t.Fatal(err) } if d.image != c.Image { t.Fatalf("got %v, wanted %v", c.Image, d.image) } }) } } ================================================ FILE: pkg/workflow/job_secrets.go ================================================ package workflow import ( "errors" "github.com/suzuki-shunsuke/slog-error/slogerr" ) type JobSecrets struct { m map[string]string inherit bool } func (js *JobSecrets) Secrets() map[string]string { return js.m } func (js *JobSecrets) Inherit() bool { return js != nil && js.inherit } func (js *JobSecrets) UnmarshalYAML(unmarshal func(any) error) error { var val any if err := unmarshal(&val); err != nil { return err } return convJobSecrets(val, js) } func convJobSecrets(src any, dest *JobSecrets) error { //nolint:cyclop switch p := src.(type) { case string: switch p { case "inherit": dest.inherit = true return nil default: return slogerr.With(errors.New("job secrets must be a map or `inherit`"), "secrets", p) //nolint:wrapcheck } case map[any]any: m := make(map[string]string, len(p)) for k, v := range p { ks, ok := k.(string) if !ok { return errors.New("secrets key must be string") } vs, ok := v.(string) if !ok { return errors.New("secrets value must be string") } m[ks] = vs } dest.m = m return nil case map[string]any: m := make(map[string]string, len(p)) for k, v := range p { vs, ok := v.(string) if !ok { return errors.New("secrets value must be string") } m[k] = vs } dest.m = m return nil default: return errors.New("secrets must be map[string]string or 'inherit'") } } ================================================ FILE: pkg/workflow/job_secrets_test.go ================================================ package workflow_test import ( "testing" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "gopkg.in/yaml.v3" ) func TestJobSecrets_UnmarshalYAML(t *testing.T) { t.Parallel() data := []struct { name string yaml string inherit bool }{ { name: "not inherit", yaml: `token: ${{github.token}}`, }, { name: "inherit", yaml: `inherit`, inherit: true, }, } for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() js := &workflow.JobSecrets{} if err := yaml.Unmarshal([]byte(d.yaml), js); err != nil { t.Fatal(err) } inherit := js.Inherit() if d.inherit != inherit { t.Fatalf("got %v, wanted %v", inherit, d.inherit) } }) } } ================================================ FILE: pkg/workflow/list_workflows.go ================================================ package workflow import ( "fmt" "github.com/spf13/afero" ) func List(fs afero.Fs) ([]string, error) { files, err := afero.Glob(fs, ".github/workflows/*.yml") if err != nil { return nil, fmt.Errorf("find .github/workflows/*.yml: %w", err) } files2, err := afero.Glob(fs, ".github/workflows/*.yaml") if err != nil { return nil, fmt.Errorf("find .github/workflows/*.yaml: %w", err) } return append(files, files2...), nil } ================================================ FILE: pkg/workflow/permissions.go ================================================ package workflow import ( "errors" "github.com/suzuki-shunsuke/slog-error/slogerr" ) type Permissions struct { m map[string]string readAll bool writeAll bool } func NewPermissions(readAll, writeAll bool, m map[string]string) *Permissions { return &Permissions{ m: m, readAll: readAll, writeAll: writeAll, } } func (ps *Permissions) Permissions() map[string]string { if ps == nil { return nil } return ps.m } func (ps *Permissions) ReadAll() bool { if ps == nil { return false } return ps.readAll } func (ps *Permissions) WriteAll() bool { if ps == nil { return false } return ps.writeAll } func (ps *Permissions) IsNil() bool { if ps == nil { return true } return ps.m == nil && !ps.readAll && !ps.writeAll } func (ps *Permissions) UnmarshalYAML(unmarshal func(any) error) error { var val any if err := unmarshal(&val); err != nil { return err } return convPermissions(val, ps) } func convPermissions(src any, dest *Permissions) error { //nolint:cyclop switch p := src.(type) { case string: switch p { case "read-all": dest.readAll = true return nil case "write-all": dest.writeAll = true return nil default: return slogerr.With(errors.New("unknown permissions"), "permission", p) //nolint:wrapcheck } case map[any]any: m := make(map[string]string, len(p)) for k, v := range p { ks, ok := k.(string) if !ok { return errors.New("permissions key must be string") } vs, ok := v.(string) if !ok { return errors.New("permissions value must be string") } m[ks] = vs } dest.m = m return nil case map[string]any: m := make(map[string]string, len(p)) for k, v := range p { vs, ok := v.(string) if !ok { return errors.New("permissions value must be string") } m[k] = vs } dest.m = m return nil default: return errors.New("permissions must be map[string]string or 'read-all' or 'write-all'") } } ================================================ FILE: pkg/workflow/permissions_test.go ================================================ package workflow_test import ( "testing" "github.com/suzuki-shunsuke/ghalint/pkg/workflow" "gopkg.in/yaml.v3" ) func TestPermissions_UnmarshalYAML(t *testing.T) { t.Parallel() data := []struct { name string yaml string readAll bool writeAll bool }{ { name: "not read-all and write-all", yaml: `contents: read`, }, { name: "read-all", yaml: `read-all`, readAll: true, }, { name: "write-all", yaml: `write-all`, writeAll: true, }, } for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() p := &workflow.Permissions{} if err := yaml.Unmarshal([]byte(d.yaml), p); err != nil { t.Fatal(err) } readAll := p.ReadAll() writeAll := p.WriteAll() if d.readAll != readAll { t.Fatalf("readAll got %v, wanted %v", readAll, d.readAll) } if d.writeAll != writeAll { t.Fatalf("writeAll got %v, wanted %v", writeAll, d.writeAll) } }) } } ================================================ FILE: pkg/workflow/read_action.go ================================================ package workflow import ( "errors" "fmt" "io" "github.com/spf13/afero" "github.com/suzuki-shunsuke/slog-error/slogerr" "gopkg.in/yaml.v3" ) func ReadAction(fs afero.Fs, p string, action *Action) error { f, err := fs.Open(p) if err != nil { return fmt.Errorf("open an action file: %w", err) } defer f.Close() if err := yaml.NewDecoder(f).Decode(action); err != nil { err := fmt.Errorf("parse an action file as YAML: %w", err) if errors.Is(err, io.EOF) { return slogerr.With(err, "reference", "https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/codes/001.md") //nolint:wrapcheck } return err } return nil } ================================================ FILE: pkg/workflow/read_workflow.go ================================================ package workflow import ( "errors" "fmt" "io" "github.com/spf13/afero" "github.com/suzuki-shunsuke/slog-error/slogerr" "gopkg.in/yaml.v3" ) func Read(fs afero.Fs, p string, wf *Workflow) error { f, err := fs.Open(p) if err != nil { return fmt.Errorf("open a workflow file: %w", err) } defer f.Close() if err := yaml.NewDecoder(f).Decode(wf); err != nil { err := fmt.Errorf("parse a workflow file as YAML: %w", err) if errors.Is(err, io.EOF) { return slogerr.With(err, "reference", "https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/codes/001.md") //nolint:wrapcheck } return err } return nil } ================================================ FILE: pkg/workflow/workflow.go ================================================ package workflow import ( "fmt" "strconv" "gopkg.in/yaml.v3" ) type Workflow struct { FilePath string `yaml:"-"` Jobs map[string]*Job Env map[string]string Permissions *Permissions } type Job struct { Permissions *Permissions Env map[string]string Steps []*Step Secrets *JobSecrets Container *Container Uses string TimeoutMinutes any `yaml:"timeout-minutes"` With map[string]any } type Step struct { Uses string ID string Name string Run string Shell string With With TimeoutMinutes any `yaml:"timeout-minutes"` } type With map[string]string func (w With) UnmarshalYAML(b []byte) error { a := map[string]any{} if err := yaml.Unmarshal(b, &a); err != nil { return err //nolint:wrapcheck } for k, v := range a { switch c := v.(type) { case string: w[k] = c case int: w[k] = strconv.Itoa(c) case float64: w[k] = fmt.Sprint(c) case bool: w[k] = strconv.FormatBool(c) default: return fmt.Errorf("unsupported type: %T", c) } } return nil } type Action struct { Runs *Runs Inputs map[string]*Input } type Runs struct { Image string Steps []*Step } type Input struct { Required bool Type string } ================================================ FILE: renovate.json5 ================================================ { extends: [ "github>suzuki-shunsuke/renovate-config#4.0.0", "github>suzuki-shunsuke/renovate-config:nolimit#4.0.0", "github>suzuki-shunsuke/renovate-config:go-directive#4.0.0", "github>aquaproj/aqua-renovate-config#2.12.1", "github>aquaproj/aqua-renovate-config:file#2.12.1(aqua/imports/.*\\.ya?ml)", ], } ================================================ FILE: scripts/coverage.sh ================================================ #!/usr/bin/env bash set -eu set -o pipefail cd "$(dirname "$0")/.." if [ $# -eq 0 ]; then target="$(go list ./... | fzf)" profile=.coverage/$target/coverage.txt mkdir -p .coverage/"$target" elif [ $# -eq 1 ]; then target=$1 mkdir -p .coverage/"$target" profile=.coverage/$target/coverage.txt target=./$target else echo "too many arguments are given: $*" >&2 exit 1 fi go test "$target" -coverprofile="$profile" -covermode=atomic go tool cover -html="$profile" ================================================ FILE: scripts/generate-usage.sh ================================================ #!/usr/bin/env bash set -eu cd "$(dirname "$0")/.." help=$(ghalint help-all) echo -n "# Usage $help" > docs/usage.md ================================================ FILE: test-action.yaml ================================================ name: test description: test inputs: github_token: description: "" required: false default: ${{ github.token }} runs: using: composite steps: # checkout_persist_credentials_should_be_false - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 # action_ref_should_be_full_length_commit_sha - uses: tibdex/github-app-token@v2.1.0 id: token1 with: app_id: ${{secrets.APP_ID}} private_key: ${{secrets.PRIVATE_KEY}} # github_app_should_limit_repositories # github_app_should_limit_permissions - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 id: token2 with: app_id: ${{secrets.APP_ID}} private_key: ${{secrets.PRIVATE_KEY}} repositories: >- ["${{github.event.repository.name}}"] permissions: >- { "contents": "write" } - uses: actions/create-github-app-token@46e4a501e119d39574a54e53a06c9a705efc55c9 # v1.6.1 id: token3 with: app-id: ${{vars.APP_ID}} private-key: ${{secrets.PRIVATE_KEY}} owner: ${{github.repository_owner}} # github_app_should_limit_repositories - uses: actions/create-github-app-token@46e4a501e119d39574a54e53a06c9a705efc55c9 # v1.6.1 id: token4 with: app-id: ${{vars.APP_ID}} private-key: ${{secrets.PRIVATE_KEY}} owner: ${{github.repository_owner}} repositories: "repo1,repo2" - uses: actions/create-github-app-token@46e4a501e119d39574a54e53a06c9a705efc55c9 # v1.6.1 id: token5 with: app-id: ${{vars.APP_ID}} private-key: ${{secrets.PRIVATE_KEY}} - run: echo hello # action_shell_is_required ================================================ FILE: test-workflow.yaml ================================================ name: test on: pull_request env: # Workflow should not set secrets to environment variables FOO: bar GITHUB_TOKEN: ${{github.token}} API_KEY: ${{secrets.API_KEY}} jobs: release: # action_ref_should_be_full_length_commit_sha uses: suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@v0.5.0 # deny_inherit_secrets secrets: inherit permissions: {} foo: # job_permissions runs-on: ubuntu-latest env: # job_secrets FOO: bar GITHUB_TOKEN: ${{github.token}} API_KEY: ${{secrets.API_KEY}} steps: # checkout_persist_credentials_should_be_false - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - run: echo hello - run: echo hello read-all: runs-on: ubuntu-latest # deny_read_all_permission permissions: read-all env: # If the job has only one job, it's okay to set secrets to job's environment variables FOO: bar GITHUB_TOKEN: ${{github.token}} API_KEY: ${{secrets.API_KEY}} steps: - run: echo hello write-all: runs-on: ubuntu-latest # deny_write_all_permission permissions: write-all steps: # action_ref_should_be_full_length_commit_sha - uses: tibdex/github-app-token@v2.1.0 id: token1 with: app_id: ${{secrets.APP_ID}} private_key: ${{secrets.PRIVATE_KEY}} # github_app_should_limit_repositories # github_app_should_limit_permissions - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 id: token2 with: app_id: ${{secrets.APP_ID}} private_key: ${{secrets.PRIVATE_KEY}} repositories: >- ["${{github.event.repository.name}}"] permissions: >- { "contents": "write" } - uses: actions/create-github-app-token@46e4a501e119d39574a54e53a06c9a705efc55c9 # v1.6.1 id: token3 with: app-id: ${{vars.APP_ID}} private-key: ${{secrets.PRIVATE_KEY}} owner: ${{github.repository_owner}} # github_app_should_limit_repositories - uses: actions/create-github-app-token@46e4a501e119d39574a54e53a06c9a705efc55c9 # v1.6.1 id: token4 with: app-id: ${{vars.APP_ID}} private-key: ${{secrets.PRIVATE_KEY}} owner: ${{github.repository_owner}} repositories: "repo1,repo2" - uses: actions/create-github-app-token@46e4a501e119d39574a54e53a06c9a705efc55c9 # v1.6.1 id: token5 with: app-id: ${{vars.APP_ID}} private-key: ${{secrets.PRIVATE_KEY}} container-job: runs-on: ubuntu-latest permissions: {} container: image: node:latest # deny_job_container_latest_image