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