Skip to content

Commit 2386222

Browse files
committed
feat: Require SHA for /ok-to-test comments
Introduced a new configuration option `require-ok-to-test-sha` that enforces the use of a commit SHA when using the `/ok-to-test` command. This helps to mitigate race conditions where a malicious actor could push a commit after the `/ok-to-test` comment is made but before the CI runs. The regular expression for parsing `/ok-to-test` comments was updated to capture an optional SHA. New tests were added to cover various scenarios, including valid and invalid SHAs, and the absence of a SHA when required. The `ParsePayload` function was modified to handle this new validation logic, and appropriate error messages were created for cases where the SHA is missing or does not match the pull request's HEAD SHA. Co-authored-by: Cursor Signed-off-by: Chmouel Boudjnah <[email protected]>
1 parent 5f2c811 commit 2386222

File tree

10 files changed

+327
-8
lines changed

10 files changed

+327
-8
lines changed

config/302-pac-configmap.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,13 @@ data:
164164
# you may want to disable this if ok-to-test should be done on each iteration
165165
remember-ok-to-test: "false"
166166

167+
# require-ok-to-test-sha enforces that a pull request's commit SHA must be specified
168+
# in an `/ok-to-test` comment. This prevents a race condition where a malicious
169+
# user could push a bad commit after the `/ok-to-test` comment is posted but
170+
# before the CI runs.
171+
# Default: false
172+
require-ok-to-test-sha: "false"
173+
167174
# When enabled, this option prevents duplicate pipeline runs when a commit appears in
168175
# both a push event and a pull request. If a push event comes from a commit that is
169176
# part of an open pull request, the push event will be skipped as it would create

docs/content/docs/guide/gitops_commands.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,41 @@ This will always trigger a new PipelineRun, even if previous runs were successfu
3939

4040
Similar to `/retest`, the `/ok-to-test` command will only trigger new PipelineRuns if no successful PipelineRun already exists for the same commit. This prevents duplicate runs when repository owners repeatedly test the same commit by `/test` and `/retest` command.
4141

42+
### Requiring a SHA with `/ok-to-test`
43+
44+
{{< tech_preview "Requiring a SHA argument to `/ok-to-test`" >}}
45+
{{< support_matrix github_app="true" github_webhook="false" gitea="false" gitlab="false" bitbucket_cloud="false" bitbucket_datacenter="false" >}}
46+
47+
Cluster administrators can enforce SHA validation on `/ok-to-test` by setting
48+
`require-ok-to-test-sha: "true"` in the Pipelines-as-Code ConfigMap. This
49+
feature currently applies only to GitHub, as its `issue_comment` event does not
50+
include the pull request’s HEAD SHA (unlike other providers that do).
51+
52+
Without this SHA, a small timing window exists where an attacker could push a
53+
new commit immediately after an owner comments `/ok-to-test`, causing CI to run
54+
on unintended code. Requiring the reviewer to include the commit ID eliminates
55+
this risk until GitHub includes the SHA in its webhook payload.
56+
57+
When enabled, repository owners and collaborators must append a 7–40 character
58+
Git SHA (in lowercase or uppercase hexadecimal) to the command, for example:
59+
60+
```text
61+
/ok-to-test 1A2B3C4
62+
```
63+
64+
Pipelines-as-Code verifies the provided SHA against the pull request’s current HEAD:
65+
66+
- Short SHAs must match the HEAD commit’s prefix.
67+
- Full SHAs must match exactly.
68+
69+
If the SHA is missing or invalid, the comment is rejected, and the bot replies
70+
with instructions to retry using the correct value. This mechanism protects
71+
GitHub repositories from the time-of-check/time-of-use vulnerability,
72+
a risk that other providers avoid by including the commit SHA directly in the
73+
webhook payload.
74+
75+
### Targeting Specific PipelineRuns
76+
4277
If you have multiple `PipelineRun` and you want to target a specific `PipelineRun`, you can use the `/test` command followed by the specific PipelineRun name to restart it. Example:
4378

4479
```text

pkg/acl/regexp.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import (
44
"regexp"
55
)
66

7-
const OKToTestCommentRegexp = `(^|\n)\/ok-to-test(\r\n|\r|\n|$)`
7+
const OKToTestCommentRegexp = `(^|\n)\/ok-to-test(?:\s+([a-fA-F0-9]{7,40}))?\s*(\r\n|\r|\n|$)`
88

99
// MatchRegexp Match a regexp to a string.
1010
func MatchRegexp(reg, comment string) bool {

pkg/acl/regexp_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@ func TestRegexp(t *testing.T) {
2222
text: "/ok-to-test",
2323
matched: true,
2424
},
25+
{
26+
name: "good/match regexp with short sha",
27+
text: "/ok-to-test 1234567",
28+
matched: true,
29+
},
30+
{
31+
name: "good/match regexp with uppercase sha",
32+
text: "/ok-to-test ABCDEF1",
33+
matched: true,
34+
},
35+
{
36+
name: "good/match regexp with full sha",
37+
text: "/ok-to-test 1234567890123456789012345678901234567890",
38+
matched: true,
39+
},
40+
{
41+
name: "bad/match regexp with invalid sha",
42+
text: "/ok-to-test GGGGGGG",
43+
matched: false,
44+
},
2545
{
2646
name: "good/match regexp newline",
2747
text: "\n/ok-to-test",
@@ -32,6 +52,11 @@ func TestRegexp(t *testing.T) {
3252
text: "\n /ok-to-test",
3353
matched: false,
3454
},
55+
{
56+
name: "good/match regexp trailing spaces",
57+
text: "/ok-to-test \n",
58+
matched: true,
59+
},
3560
{
3661
name: "good/in the middle",
3762
text: "foo bar\n/ok-to-test\nhello moto",

pkg/opscomments/comments.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"go.uber.org/zap"
99

10+
"github.com/openshift-pipelines/pipelines-as-code/pkg/acl"
1011
"github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/v1alpha1"
1112
"github.com/openshift-pipelines/pipelines-as-code/pkg/events"
1213
"github.com/openshift-pipelines/pipelines-as-code/pkg/params/info"
@@ -18,7 +19,7 @@ var (
1819
retestAllRegex = regexp.MustCompile(`(?m)^/retest\s*$`)
1920
testSingleRegex = regexp.MustCompile(`(?m)^/test[ \t]+\S+`)
2021
retestSingleRegex = regexp.MustCompile(`(?m)^/retest[ \t]+\S+`)
21-
oktotestRegex = regexp.MustCompile(`(?m)^/ok-to-test\s*$`)
22+
oktotestRegex = regexp.MustCompile(acl.OKToTestCommentRegexp)
2223
cancelAllRegex = regexp.MustCompile(`(?m)^(/cancel)\s*$`)
2324
cancelSingleRegex = regexp.MustCompile(`(?m)^(/cancel)[ \t]+\S+`)
2425
)
@@ -88,6 +89,15 @@ func IsOkToTestComment(comment string) bool {
8889
return oktotestRegex.MatchString(comment)
8990
}
9091

92+
// GetSHAFromOkToTestComment extracts the optional SHA from an /ok-to-test comment.
93+
func GetSHAFromOkToTestComment(comment string) string {
94+
matches := oktotestRegex.FindStringSubmatch(comment)
95+
if len(matches) > 2 {
96+
return strings.TrimSpace(matches[2])
97+
}
98+
return ""
99+
}
100+
91101
// EventTypeBackwardCompat handle the backward compatibility we need to keep until
92102
// we have done the deprecated notice
93103
//

pkg/opscomments/comments_test.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,9 +306,9 @@ func TestIsOkToTestComment(t *testing.T) {
306306
want: false,
307307
},
308308
{
309-
name: "invalid comment",
310-
comment: "/ok-to-test abc",
311-
want: false,
309+
name: "valid comment with sha",
310+
comment: "/ok-to-test 1234567",
311+
want: true,
312312
},
313313
}
314314

@@ -679,6 +679,41 @@ func TestGetPipelineRunAndBranchNameFromCancelComment(t *testing.T) {
679679
}
680680
}
681681

682+
func TestGetSHAFromOkToTestComment(t *testing.T) {
683+
tests := []struct {
684+
name string
685+
comment string
686+
want string
687+
}{
688+
{
689+
name: "no sha",
690+
comment: "/ok-to-test",
691+
want: "",
692+
},
693+
{
694+
name: "short sha",
695+
comment: "/ok-to-test 1234567",
696+
want: "1234567",
697+
},
698+
{
699+
name: "full sha",
700+
comment: "/ok-to-test 1234567890123456789012345678901234567890",
701+
want: "1234567890123456789012345678901234567890",
702+
},
703+
{
704+
name: "sha with surrounding text",
705+
comment: "lgtm\n/ok-to-test 1234567\napproved",
706+
want: "1234567",
707+
},
708+
}
709+
for _, tt := range tests {
710+
t.Run(tt.name, func(t *testing.T) {
711+
got := GetSHAFromOkToTestComment(tt.comment)
712+
assert.Equal(t, tt.want, got)
713+
})
714+
}
715+
}
716+
682717
func TestAnyOpsKubeLabelInSelector(t *testing.T) {
683718
assert.Assert(t, strings.Contains(AnyOpsKubeLabelInSelector(), RetestSingleCommentEventType.String()))
684719
}

pkg/params/settings/config.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ type Settings struct {
8181
CustomConsolePRTaskLog string `json:"custom-console-url-pr-tasklog"`
8282
CustomConsoleNamespaceURL string `json:"custom-console-url-namespace"`
8383

84-
RememberOKToTest bool `json:"remember-ok-to-test"`
84+
RememberOKToTest bool `json:"remember-ok-to-test"`
85+
RequireOkToTestSHA bool `json:"require-ok-to-test-sha"`
8586
}
8687

8788
func (s *Settings) DeepCopy(out *Settings) {

pkg/params/settings/config_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func TestSyncConfig(t *testing.T) {
7777
"custom-console-url-namespace": "https://custom-console-namespace",
7878
"remember-ok-to-test": "false",
7979
"skip-push-event-for-pr-commits": "true",
80+
"require-ok-to-test-sha": "true",
8081
},
8182
expectedStruct: Settings{
8283
ApplicationName: "pac-pac",
@@ -107,6 +108,7 @@ func TestSyncConfig(t *testing.T) {
107108
CustomConsolePRTaskLog: "https://custom-console-pr-tasklog",
108109
CustomConsoleNamespaceURL: "https://custom-console-namespace",
109110
RememberOKToTest: false,
111+
RequireOkToTestSHA: true,
110112
},
111113
},
112114
{

pkg/provider/github/acl_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,157 @@ func TestOkToTestComment(t *testing.T) {
346346
}
347347
}
348348

349+
func TestOkToTestCommentSHA(t *testing.T) {
350+
tests := []struct {
351+
name string
352+
commentsReply string
353+
commentBody string
354+
runevent info.Event
355+
allowed bool
356+
wantErr bool
357+
requireOkToTestSHA bool
358+
pullRequestReply string
359+
pullRequestListComment string
360+
}{
361+
{
362+
name: "good issue comment event with sha",
363+
commentsReply: `[{"body": "/ok-to-test ABCDEF1", "user": {"login": "owner"}}]`,
364+
commentBody: "/ok-to-test ABCDEF1",
365+
pullRequestReply: `{"head": {"sha": "abcdef1234567890"}}`,
366+
requireOkToTestSHA: true,
367+
runevent: info.Event{
368+
Organization: "owner",
369+
Sender: "nonowner",
370+
EventType: "issue_comment",
371+
Event: &github.IssueCommentEvent{
372+
Issue: &github.Issue{
373+
PullRequestLinks: &github.PullRequestLinks{
374+
HTMLURL: github.Ptr("http://url.com/owner/repo/1"),
375+
},
376+
},
377+
},
378+
},
379+
allowed: true,
380+
wantErr: false,
381+
},
382+
{
383+
name: "bad issue comment event with sha",
384+
commentsReply: `[{"body": "/ok-to-test 1234567", "user": {"login": "owner"}}]`,
385+
commentBody: "/ok-to-test 1234567",
386+
pullRequestReply: `{"head": {"sha": "7654321"}}`,
387+
requireOkToTestSHA: true,
388+
runevent: info.Event{
389+
Organization: "owner",
390+
Sender: "nonowner",
391+
EventType: "issue_comment",
392+
Event: &github.IssueCommentEvent{
393+
Issue: &github.Issue{
394+
PullRequestLinks: &github.PullRequestLinks{
395+
HTMLURL: github.Ptr("http://url.com/owner/repo/1"),
396+
},
397+
},
398+
},
399+
},
400+
allowed: false,
401+
wantErr: true,
402+
},
403+
{
404+
name: "good issue comment event without sha",
405+
commentsReply: `[{"body": "/ok-to-test", "user": {"login": "owner"}}]`,
406+
commentBody: "/ok-to-test",
407+
pullRequestReply: `{"head": {"sha": "1234567890"}}`,
408+
requireOkToTestSHA: false,
409+
runevent: info.Event{
410+
Organization: "owner",
411+
Sender: "nonowner",
412+
EventType: "issue_comment",
413+
Event: &github.IssueCommentEvent{
414+
Issue: &github.Issue{
415+
PullRequestLinks: &github.PullRequestLinks{
416+
HTMLURL: github.Ptr("http://url.com/owner/repo/1"),
417+
},
418+
},
419+
},
420+
},
421+
allowed: true,
422+
wantErr: false,
423+
},
424+
{
425+
name: "bad issue comment event without sha when required",
426+
commentsReply: `[{"body": "/ok-to-test", "user": {"login": "owner"}}]`,
427+
commentBody: "/ok-to-test",
428+
pullRequestReply: `{"head": {"sha": "1234567890"}}`,
429+
requireOkToTestSHA: true,
430+
runevent: info.Event{
431+
Organization: "owner",
432+
Sender: "nonowner",
433+
EventType: "issue_comment",
434+
Event: &github.IssueCommentEvent{
435+
Issue: &github.Issue{
436+
PullRequestLinks: &github.PullRequestLinks{
437+
HTMLURL: github.Ptr("http://url.com/owner/repo/1"),
438+
},
439+
},
440+
},
441+
},
442+
allowed: false,
443+
wantErr: true,
444+
},
445+
}
446+
for _, tt := range tests {
447+
t.Run(tt.name, func(t *testing.T) {
448+
tt.runevent.TriggerTarget = "ok-to-test-comment"
449+
fakeclient, mux, _, teardown := ghtesthelper.SetupGH()
450+
defer teardown()
451+
mux.HandleFunc("/repos/owner/issues/comments/0", func(rw http.ResponseWriter, _ *http.Request) {
452+
fmt.Fprint(rw, tt.commentsReply)
453+
})
454+
mux.HandleFunc("/repos/owner/repo/pulls/1", func(rw http.ResponseWriter, _ *http.Request) {
455+
fmt.Fprint(rw, tt.pullRequestReply)
456+
})
457+
mux.HandleFunc("/repos/owner/repo/issues/1/comments", func(rw http.ResponseWriter, r *http.Request) {
458+
// this will test if pagination works okay
459+
if r.URL.Query().Get("page") == "" || r.URL.Query().Get("page") == "1" {
460+
rw.Header().Add("Link", `<https://api.github.com/owner/repo/issues/1/comments?page=2&per_page=1>; rel="next"`)
461+
fmt.Fprint(rw, `[{"body": "Foo Bar", "user": {"login": "notallowed"}}]`, tt.commentsReply)
462+
} else {
463+
fmt.Fprint(rw, tt.commentsReply)
464+
}
465+
})
466+
mux.HandleFunc("/repos/owner/collaborators", func(rw http.ResponseWriter, _ *http.Request) {
467+
fmt.Fprint(rw, "[]")
468+
})
469+
ctx, _ := rtesting.SetupFakeContext(t)
470+
observer, _ := zapobserver.New(zap.InfoLevel)
471+
logger := zap.New(observer).Sugar()
472+
repo := &v1alpha1.Repository{Spec: v1alpha1.RepositorySpec{
473+
Settings: &v1alpha1.Settings{},
474+
}}
475+
pacopts := &info.PacOpts{
476+
Settings: settings.Settings{
477+
RequireOkToTestSHA: tt.requireOkToTestSHA,
478+
},
479+
}
480+
gprovider := Provider{
481+
ghClient: fakeclient,
482+
repo: repo,
483+
Logger: logger,
484+
PaginedNumber: 1,
485+
Run: &params.Run{},
486+
pacInfo: pacopts,
487+
}
488+
489+
payload := fmt.Sprintf(`{"action": "created", "repository": {"name": "repo", "owner": {"login": "owner"}}, "sender": {"login": %q}, "issue": {"pull_request": {"html_url": "https://github.com/owner/repo/pull/1"}}, "comment": {"body": %q}}`,
490+
tt.runevent.Sender, tt.commentBody)
491+
_, err := gprovider.ParsePayload(ctx, gprovider.Run, &http.Request{Header: http.Header{"X-Github-Event": []string{"issue_comment"}}}, payload)
492+
if (err != nil) != tt.wantErr {
493+
t.Errorf("aclCheck() error = %v, wantErr %v", err, tt.wantErr)
494+
return
495+
}
496+
})
497+
}
498+
}
499+
349500
func TestAclCheckAll(t *testing.T) {
350501
fakeclient, mux, _, teardown := ghtesthelper.SetupGH()
351502
defer teardown()

0 commit comments

Comments
 (0)