Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support configuration file and a policy job_secrets #29

Merged
merged 2 commits into from
Jan 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ GitHub Actions linter
- Why: To limit the scope of secrets
- Exceptions
- workflow has only one job
- `job_secrets`: Job should not set secrets to environment variables
- How to fix: set secrets to steps
- Why: To limit the scope of secrets
- Exceptions
- job has only one step

### job_permissions

Expand Down Expand Up @@ -112,6 +117,38 @@ jobs:
- run: echo bar
```

### job_secrets

: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 install

- [Download a pre-built binary from GitHub Releases](https://github.com/suzuki-shunsuke/ghalint/releases) and locate an executable binary `ghalint` in `PATH`
Expand All @@ -131,6 +168,23 @@ ERRO[0000] github.token should not be set to workflow's env env_name=GITHUB_TOK
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
```

## Configuration file

Configuration file path: `^\.?ghalint\.ya?ml$`

You can exclude the policy `job_secrets`.

e.g.

```yaml
excludes:
- policy_name: job_secrets
workflow_file_path: .github/workflows/actionlint.yaml
job_name: actionlint
```

* policy_name: Only `job_secrets` is supported

## How does it works?

ghalint reads GitHub Actions Workflows `^\.github/workflows/.*\.ya?ml$` and validates them.
Expand Down
11 changes: 11 additions & 0 deletions pkg/cli/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package cli

type Config struct {
Excludes []*Exclude
}

type Exclude struct {
PolicyName string `yaml:"policy_name"`
WorkflowFilePath string `yaml:"workflow_file_path"`
JobName string `yaml:"job_name"`
}
2 changes: 1 addition & 1 deletion pkg/cli/job_permissions_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ func (policy *JobPermissionsPolicy) Name() string {
return "job_permissions"
}

func (policy *JobPermissionsPolicy) Apply(ctx context.Context, logE *logrus.Entry, wf *Workflow) error {
func (policy *JobPermissionsPolicy) Apply(ctx context.Context, logE *logrus.Entry, cfg *Config, wf *Workflow) error {
failed := false
if wf.Permissions != nil && len(wf.Permissions) == 0 {
return nil
Expand Down
72 changes: 72 additions & 0 deletions pkg/cli/job_permissions_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package cli_test

import (
"context"
"testing"

"github.com/sirupsen/logrus"
"github.com/suzuki-shunsuke/ghalint/pkg/cli"
)

func TestJobPermissionsPolicy_Apply(t *testing.T) {
t.Parallel()
data := []struct {
name string
cfg *cli.Config
wf *cli.Workflow
exp bool
}{
{
name: "workflow permissions is empty",
cfg: &cli.Config{},
wf: &cli.Workflow{
Permissions: map[string]string{},
Jobs: map[string]*cli.Job{
"foo": {},
"bar": {},
},
},
},
{
name: "workflow has only one job",
cfg: &cli.Config{},
wf: &cli.Workflow{
Permissions: map[string]string{
"contents": "read",
},
Jobs: map[string]*cli.Job{
"foo": {},
},
},
},
{
name: "job should have permissions",
cfg: &cli.Config{},
wf: &cli.Workflow{
Jobs: map[string]*cli.Job{
"foo": {},
"bar": {},
},
},
exp: false,
},
}
policy := &cli.JobPermissionsPolicy{}
logE := logrus.NewEntry(logrus.New())
ctx := context.Background()
for _, d := range data {
d := d
t.Run(d.name, func(t *testing.T) {
t.Parallel()
if err := policy.Apply(ctx, logE, d.cfg, d.wf); err != nil {
if d.exp {
t.Fatal(err)
}
return
}
if d.exp {
t.Fatal("error must be returned")
}
})
}
}
61 changes: 61 additions & 0 deletions pkg/cli/job_secrets_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package cli

import (
"context"
"errors"
"regexp"

"github.com/sirupsen/logrus"
)

type JobSecretsPolicy struct {
secretPattern *regexp.Regexp
githubTokenPattern *regexp.Regexp
}

func NewJobSecretsPolicy() *JobSecretsPolicy {
return &JobSecretsPolicy{
secretPattern: regexp.MustCompile(`\${{ *secrets\.[^ ]+ *}}`),
githubTokenPattern: regexp.MustCompile(`\${{ *github\.token+ *}}`),
}
}

func (policy *JobSecretsPolicy) Name() string {
return "job_secrets"
}

func checkExcludes(policyName string, wf *Workflow, jobName string, cfg *Config) bool {
for _, exclude := range cfg.Excludes {
if exclude.PolicyName == policyName && wf.FilePath == exclude.WorkflowFilePath && jobName == exclude.JobName {
return true
}
}
return false
}

func (policy *JobSecretsPolicy) Apply(ctx context.Context, logE *logrus.Entry, cfg *Config, wf *Workflow) error {
failed := false
for jobName, job := range wf.Jobs {
logE := logE.WithField("job_name", jobName)
if checkExcludes(policy.Name(), wf, jobName, cfg) {
continue
}
if len(job.Steps) < 2 { //nolint:gomnd
continue
}
for envName, envValue := range job.Env {
if policy.secretPattern.MatchString(envValue) {
failed = true
logE.WithField("env_name", envName).Error("secret should not be set to job's env")
}
if policy.githubTokenPattern.MatchString(envValue) {
failed = true
logE.WithField("env_name", envName).Error("github.token should not be set to job's env")
}
}
}
if failed {
return errors.New("workflow violates policies")
}
return nil
}
158 changes: 158 additions & 0 deletions pkg/cli/job_secrets_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package cli_test

import (
"context"
"testing"

"github.com/sirupsen/logrus"
"github.com/suzuki-shunsuke/ghalint/pkg/cli"
)

func TestJobSecretsPolicy_Apply(t *testing.T) { //nolint:funlen
t.Parallel()
data := []struct {
name string
cfg *cli.Config
wf *cli.Workflow
exp bool
}{
{
name: "exclude",
cfg: &cli.Config{
Excludes: []*cli.Exclude{
{
PolicyName: "job_secrets",
WorkflowFilePath: ".github/workflows/test.yaml",
JobName: "foo",
},
},
},
wf: &cli.Workflow{
FilePath: ".github/workflows/test.yaml",
Jobs: map[string]*cli.Job{
"foo": {
Env: map[string]string{
"GITHUB_TOKEN": "${{github.token}}",
},
Steps: []interface{}{
map[string]interface{}{
"run": "echo hello",
},
map[string]interface{}{
"run": "echo bar",
},
},
},
},
},
},
{
name: "job has only one step",
cfg: &cli.Config{},
wf: &cli.Workflow{
FilePath: ".github/workflows/test.yaml",
Jobs: map[string]*cli.Job{
"foo": {
Env: map[string]string{
"GITHUB_TOKEN": "${{github.token}}",
},
Steps: []interface{}{
map[string]interface{}{
"run": "echo hello",
},
},
},
},
},
},
{
name: "secret should not be set to job's env",
cfg: &cli.Config{},
wf: &cli.Workflow{
FilePath: ".github/workflows/test.yaml",
Jobs: map[string]*cli.Job{
"foo": {
Env: map[string]string{
"GITHUB_TOKEN": "${{secrets.GITHUB_TOKEN}}",
},
Steps: []interface{}{
map[string]interface{}{
"run": "echo hello",
},
map[string]interface{}{
"run": "echo bar",
},
},
},
},
},
exp: false,
},
{
name: "github token should not be set to job's env",
cfg: &cli.Config{},
wf: &cli.Workflow{
FilePath: ".github/workflows/test.yaml",
Jobs: map[string]*cli.Job{
"foo": {
Env: map[string]string{
"GITHUB_TOKEN": "${{github.token}}",
},
Steps: []interface{}{
map[string]interface{}{
"run": "echo hello",
},
map[string]interface{}{
"run": "echo bar",
},
},
},
},
},
exp: false,
},
{
name: "pass",
cfg: &cli.Config{},
wf: &cli.Workflow{
FilePath: ".github/workflows/test.yaml",
Jobs: map[string]*cli.Job{
"foo": {
Env: map[string]string{
"FOO": "foo",
},
Steps: []interface{}{
map[string]interface{}{
"run": "echo hello",
"env": map[string]string{
"GITHUB_TOKEN": "${{github.token}}",
},
},
map[string]interface{}{
"run": "echo bar",
},
},
},
},
},
},
}
policy := cli.NewJobSecretsPolicy()
logE := logrus.NewEntry(logrus.New())
ctx := context.Background()
for _, d := range data {
d := d
t.Run(d.name, func(t *testing.T) {
t.Parallel()
if err := policy.Apply(ctx, logE, d.cfg, d.wf); err != nil {
if d.exp {
t.Fatal(err)
}
return
}
if d.exp {
t.Fatal("error must be returned")
}
})
}
}
Loading