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