Skip to content

Commit 134cde2

Browse files
feat: support verifying if the commit hash of a semver comment is equivalent to a commit SHA of action version (#439)
* feat: support verifying if the commit hash of a semver comment is equivalent to a commit SHA of action version * fix: improve the error log and add document * docs: update document
1 parent 26a2d0e commit 134cde2

7 files changed

Lines changed: 164 additions & 32 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ index 84bd67a..5d92e44 100644
4040
permissions:
4141
```
4242

43+
[pinact also supports verifying version annotations](docs/codes/001.md).
44+
4345
## Motivation
4446

4547
It is a good manner to pin GitHub Actions versions by commit hash.
@@ -135,6 +137,10 @@ $ pinact init '.github/pinact.yaml'
135137

136138
About the configuration, please see [Configuration](#Configuration).
137139

140+
## Verify version annotations
141+
142+
Please see [the document](docs/codes/001.md).
143+
138144
## GitHub Actions
139145

140146
https://github.com/suzuki-shunsuke/pinact-action

docs/codes/001.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Verify version annotations
2+
3+
pinact >= v0.1.3
4+
5+
Please see the following code.
6+
7+
```yaml
8+
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v3.5.1
9+
```
10+
11+
You would assume the version of the action is v3.5.1 because the version annotation is "v3.5.1".
12+
But the actual version is v2.7.0 because "ee0669bd1cc54295c223e0bb666b733df41de1c5" is the commit hash of v2.7.0.
13+
Please check releases.
14+
15+
- https://github.com/actions/checkout/releases/tag/v3.5.1
16+
- https://github.com/actions/checkout/releases/tag/v2.7.0
17+
18+
This indicates version annotations aren't necessarily correct.
19+
Especially, attackers can specify a full commit SHA including a malicious code while setting a safe tag to the version annotation.
20+
If a pull request includes changes of GitHub Actions, you should verify version annotations.
21+
22+
pinact v0.1.3 or newer can verify version annotations using `pinact run`'s `--verify` option.
23+
This verification works only if the version annotation is semver and the version is full commit hash like the above example.
24+
This option gets a full commit hash from a version annotation by GitHub API and compares it with the version.
25+
26+
e.g.
27+
28+
```console
29+
$ pinact run --verify testdata/bar.yaml
30+
ERRO[0000] parse a line action=actions/checkout action_version=ee0669bd1cc54295c223e0bb666b733df41de1c5 commit_hash_of_version_annotation=83b7061638ee4956cf7545a6f7efe594e5ad0247 error="verify the version annotation: action_version must be equal to commit_hash_of_version_annotation" help_docs="https://github.com/suzuki-shunsuke/pinact/blob/main/docs/codes/001.md" pinact_version= program=pinact version_annotation=v3.5.1 workflow_file=testdata/bar.yaml
31+
```
32+
33+
Note that `--verify` option calls GitHub API to verify version annotations, which may cause API rate limiting.

pkg/cli/run.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ e.g.
2424
$ pinact run .github/actions/foo/action.yaml .github/actions/bar/action.yaml
2525
`,
2626
Action: r.runAction,
27+
Flags: []cli.Flag{
28+
&cli.BoolFlag{
29+
Name: "verify",
30+
Aliases: []string{"v"},
31+
Usage: "verify if pairs of commit SHA and version are correct",
32+
},
33+
},
2734
}
2835
}
2936

@@ -38,6 +45,7 @@ func (r *Runner) runAction(c *cli.Context) error {
3845
WorkflowFilePaths: c.Args().Slice(),
3946
ConfigFilePath: c.String("config"),
4047
PWD: pwd,
48+
IsVerify: c.Bool("verify"),
4149
}
4250
return ctrl.Run(c.Context, r.LogE, param) //nolint:wrapcheck
4351
}

pkg/controller/run/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
type Config struct {
1111
Files []*File
1212
IgnoreActions []*IgnoreAction `yaml:"ignore_actions"`
13+
IsVerify bool `yaml:"-"`
1314
}
1415

1516
type File struct {

pkg/controller/run/parse_line.go

Lines changed: 98 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package run
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"regexp"
78
"strings"
@@ -11,7 +12,12 @@ import (
1112
"github.com/suzuki-shunsuke/pinact/pkg/github"
1213
)
1314

14-
var usesPattern = regexp.MustCompile(`^ +(?:- )?uses: +(.*)@([^ ]+)(?: +# +(?:tag=)?(v\d+[^ ]*))?`)
15+
var (
16+
usesPattern = regexp.MustCompile(`^ +(?:- )?uses: +(.*)@([^ ]+)(?: +# +(?:tag=)?(v?\d+[^ ]*))?`)
17+
fullCommitSHAPattern = regexp.MustCompile(`\b[0-9a-f]{40}\b`)
18+
semverPattern = regexp.MustCompile(`^v?\d+\.\d+\.\d+[^ ]*$`)
19+
shortTagPattern = regexp.MustCompile(`^v\d+$`)
20+
)
1521

1622
type Action struct {
1723
Name string
@@ -21,6 +27,32 @@ type Action struct {
2127
RepoName string
2228
}
2329

30+
type VersionType int
31+
32+
const (
33+
Semver VersionType = iota
34+
Shortsemver
35+
FullCommitSHA
36+
Empty
37+
Other
38+
)
39+
40+
func getVersionType(v string) VersionType {
41+
if v == "" {
42+
return Empty
43+
}
44+
if fullCommitSHAPattern.MatchString(v) {
45+
return FullCommitSHA
46+
}
47+
if semverPattern.MatchString(v) {
48+
return Semver
49+
}
50+
if shortTagPattern.MatchString(v) {
51+
return Shortsemver
52+
}
53+
return Other
54+
}
55+
2456
func (c *Controller) parseLine(ctx context.Context, logE *logrus.Entry, line string, cfg *Config) (string, error) { //nolint:cyclop,funlen
2557
matches := usesPattern.FindStringSubmatch(line)
2658
if matches == nil {
@@ -29,9 +61,9 @@ func (c *Controller) parseLine(ctx context.Context, logE *logrus.Entry, line str
2961
return line, nil
3062
}
3163
action := &Action{
32-
Name: matches[1],
64+
Name: matches[1], // local action is excluded by the regular expression because local action doesn't have version @
3365
Version: matches[2], // full commit hash, main, v3, v3.0.0
34-
Tag: matches[3], // empty, v1, v3.0.0, hoge
66+
Tag: matches[3], // empty, v1, v3.0.0
3567
}
3668

3769
for _, ignoreAction := range cfg.IgnoreActions {
@@ -44,11 +76,19 @@ func (c *Controller) parseLine(ctx context.Context, logE *logrus.Entry, line str
4476
}
4577
}
4678

47-
if f := c.parseAction(action); !f {
79+
if f := c.parseActionName(action); !f {
4880
logE.WithField("line", line).Debug("ignore line")
4981
return line, nil
5082
}
51-
if action.Tag == "" { //nolint:nestif
83+
84+
switch getVersionType(action.Tag) {
85+
case Empty:
86+
typ := getVersionType(action.Version)
87+
switch typ {
88+
case Shortsemver, Semver:
89+
default:
90+
return line, nil
91+
}
5292
// @xxx
5393
// Get commit hash from tag
5494
// https://docs.github.com/en/rest/git/refs?apiVersion=2022-11-28#get-a-reference
@@ -59,7 +99,7 @@ func (c *Controller) parseLine(ctx context.Context, logE *logrus.Entry, line str
5999
return line, nil
60100
}
61101
longVersion := action.Version
62-
if shortTagPattern.MatchString(action.Version) {
102+
if typ == Shortsemver {
63103
v, err := c.getLongVersionFromSHA(ctx, action, sha)
64104
if err != nil {
65105
return "", err
@@ -70,23 +110,39 @@ func (c *Controller) parseLine(ctx context.Context, logE *logrus.Entry, line str
70110
}
71111
// @yyy # longVersion
72112
return c.patchLine(line, action, sha, longVersion), nil
73-
}
74-
// @xxx # v3
75-
// list releases
76-
// extract releases by commit hash
77-
if !shortTagPattern.MatchString(action.Tag) {
78-
logE.WithField("action_version", action.Version).Debug("ignore the line because the tag is not short")
113+
case Semver:
114+
// verify commit hash
115+
if !cfg.IsVerify {
116+
return line, nil
117+
}
118+
// @xxx # v3.0.0
119+
// @<full commit hash> # v3.0.0
120+
if FullCommitSHA != getVersionType(action.Version) {
121+
return line, nil
122+
}
123+
if err := c.verify(ctx, action); err != nil {
124+
return "", fmt.Errorf("verify the version annotation: %w", err)
125+
}
79126
return line, nil
80-
}
81-
longVersion, err := c.getLongVersionFromSHA(ctx, action, action.Version)
82-
if err != nil {
83-
return "", err
84-
}
85-
if longVersion == "" {
86-
logE.Debug("failed to get a long tag")
127+
case Shortsemver:
128+
// @xxx # v3
129+
// @<full commit hash> # v3
130+
if FullCommitSHA != getVersionType(action.Version) {
131+
return line, nil
132+
}
133+
// replace Shortsemer to Semver
134+
longVersion, err := c.getLongVersionFromSHA(ctx, action, action.Version)
135+
if err != nil {
136+
return "", err
137+
}
138+
if longVersion == "" {
139+
logE.Debug("failed to get a long tag")
140+
return line, nil
141+
}
142+
return c.patchLine(line, action, action.Version, longVersion), nil
143+
default:
87144
return line, nil
88145
}
89-
return c.patchLine(line, action, action.Version, longVersion), nil
90146
}
91147

92148
func (c *Controller) patchLine(line string, action *Action, version, tag string) string {
@@ -105,7 +161,7 @@ func (c *Controller) getLongVersionFromSHA(ctx context.Context, action *Action,
105161
}
106162
// Get long tag from commit hash
107163
for range 10 {
108-
tags, _, err := c.repositoriesService.ListTags(ctx, action.RepoOwner, action.RepoName, opts)
164+
tags, resp, err := c.repositoriesService.ListTags(ctx, action.RepoOwner, action.RepoName, opts)
109165
if err != nil {
110166
return "", fmt.Errorf("list tags: %w", err)
111167
}
@@ -127,30 +183,40 @@ func (c *Controller) getLongVersionFromSHA(ctx context.Context, action *Action,
127183
return tagName, nil
128184
}
129185
}
130-
if len(tags) < opts.PerPage {
186+
if resp.NextPage == 0 {
131187
return "", nil
132188
}
133-
opts.Page++
189+
opts.Page = resp.NextPage
134190
}
135191
return "", nil
136192
}
137193

138-
var shortTagPattern = regexp.MustCompile(`^v\d+$`)
139-
140-
// parseAction returns true if the action is a target.
194+
// parseActionName returns true if the action is a target.
141195
// Otherwise, it returns false.
142-
func (c *Controller) parseAction(action *Action) bool {
196+
func (c *Controller) parseActionName(action *Action) bool {
143197
a := strings.Split(action.Name, "/")
144198
if len(a) == 1 {
145199
// If it fails to extract the repository owner and name, ignore the action.
146200
return false
147201
}
148202
action.RepoOwner = a[0]
149203
action.RepoName = a[1]
150-
if action.Tag != "" && !shortTagPattern.MatchString(action.Tag) {
151-
// Ignore if the tag is not a short tag.
152-
// e.g. uses: actions/checkout@xxx # v2.0.0
153-
return false
154-
}
155204
return true
156205
}
206+
207+
func (c *Controller) verify(ctx context.Context, action *Action) error {
208+
sha, _, err := c.repositoriesService.GetCommitSHA1(ctx, action.RepoOwner, action.RepoName, action.Tag, "")
209+
if err != nil {
210+
return fmt.Errorf("get a commit hash: %w", err)
211+
}
212+
if action.Version == sha {
213+
return nil
214+
}
215+
return logerr.WithFields(errors.New("action_version must be equal to commit_hash_of_version_annotation"), logrus.Fields{ //nolint:wrapcheck
216+
"action": action.Name,
217+
"action_version": action.Version,
218+
"version_annotation": action.Tag,
219+
"commit_hash_of_version_annotation": sha,
220+
"help_docs": "https://github.com/suzuki-shunsuke/pinact/blob/main/docs/codes/001.md",
221+
})
222+
}

pkg/controller/run/run.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,20 @@ type ParamRun struct {
1515
WorkflowFilePaths []string
1616
ConfigFilePath string
1717
PWD string
18+
IsVerify bool
1819
}
1920

2021
func (c *Controller) Run(ctx context.Context, logE *logrus.Entry, param *ParamRun) error {
2122
cfg := &Config{}
2223
if err := c.readConfig(param.ConfigFilePath, cfg); err != nil {
2324
return err
2425
}
26+
cfg.IsVerify = param.IsVerify
2527
workflowFilePaths, err := c.searchFiles(logE, param.WorkflowFilePaths, cfg, param.PWD)
2628
if err != nil {
2729
return fmt.Errorf("search target files: %w", err)
2830
}
31+
2932
for _, workflowFilePath := range workflowFilePaths {
3033
logE := logE.WithField("workflow_file", workflowFilePath)
3134
if err := c.runWorkflow(ctx, logE, workflowFilePath, cfg); err != nil {

testdata/bar.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
name: bar
3+
on: workflow_call
4+
jobs:
5+
integration-test:
6+
runs-on: ubuntu-latest
7+
permissions: {}
8+
steps:
9+
# The version annotation is "v3.5.1", so you would think the version of the action is v3.5.1.
10+
# But the actual version is v2.7.0 because "ee0669bd1cc54295c223e0bb666b733df41de1c5" is the commit hash of v2.7.0.
11+
# https://github.com/actions/checkout/releases/tag/v3.5.1
12+
# https://github.com/actions/checkout/releases/tag/v2.7.0
13+
# This means version annotations aren't necessarily correct.
14+
# pinact run's --verify option verifies version annoations.
15+
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v3.5.1

0 commit comments

Comments
 (0)