Skip to content

Commit a5ec0fb

Browse files
committed
Add an option to force run workflows from the repo default branch
When using OIDC to obtain cloud credentials in workflows, using the digger workflow from the PR branch might be a security issue. Malicious actors could modify the workflow or add any other workflow to a PR to obtain cloud credentials without any way for code owner approvals preventing this. By being able to force the use of the digger workflow from the repository's main branch, the cloud roles can be configured to only trust runs on that branch, which can in turn be secured using typical PR approvals. That way, malicious workflows could only end up there after having passed a review. It is not an option to implement this option inthe digger CLI / digger.yml. This could then be modified in a malicious PR.
1 parent dac93a9 commit a5ec0fb

File tree

3 files changed

+200
-2
lines changed

3 files changed

+200
-2
lines changed

backend/ci_backends/github_actions.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"log/slog"
88

9+
"github.com/diggerhq/digger/backend/config"
910
"github.com/diggerhq/digger/backend/utils"
1011
orchestrator_scheduler "github.com/diggerhq/digger/libs/scheduler"
1112
"github.com/diggerhq/digger/libs/spec"
@@ -26,14 +27,46 @@ func (g GithubActionCi) TriggerWorkflow(spec spec.Spec, runName string, vcsToken
2627
RunName: runName,
2728
}
2829

30+
ref, err := g.resolveWorkflowRef(context.Background(), spec)
31+
if err != nil {
32+
return err
33+
}
34+
2935
_, err = client.Actions.CreateWorkflowDispatchEventByFileName(context.Background(), spec.VCS.RepoOwner, spec.VCS.RepoName, spec.VCS.WorkflowFile, github.CreateWorkflowDispatchEventRequest{
30-
Ref: spec.Job.Branch,
36+
Ref: ref,
3137
Inputs: inputs.ToMap(),
3238
})
3339

3440
return err
3541
}
3642

43+
// resolveWorkflowRef returns the git ref that should be used when triggering
44+
// the workflow. When the `force_trigger_from_default_branch` flag is enabled
45+
// we query GitHub for the repository's default branch; otherwise, we use the
46+
// branch present in the job spec.
47+
func (g GithubActionCi) resolveWorkflowRef(ctx context.Context, spec spec.Spec) (string, error) {
48+
client := g.Client
49+
ref := spec.Job.Branch
50+
51+
if config.DiggerConfig.GetBool("force_trigger_from_default_branch") {
52+
repo, _, rErr := client.Repositories.Get(ctx, spec.VCS.RepoOwner, spec.VCS.RepoName)
53+
if rErr != nil {
54+
slog.Error("Failed to fetch repository info to determine default branch", "owner", spec.VCS.RepoOwner, "repo", spec.VCS.RepoName, "error", rErr)
55+
return "", fmt.Errorf("failed to fetch repo info to get default branch: %v", rErr)
56+
}
57+
if repo.DefaultBranch != nil && *repo.DefaultBranch != "" {
58+
ref = *repo.DefaultBranch
59+
slog.Info("Forcing workflow ref to repository default branch", "repo", spec.VCS.RepoFullname, "defaultBranch", ref)
60+
} else {
61+
// If GitHub doesn't return a default branch, fall back to 'main'.
62+
ref = "main"
63+
slog.Info("Repository default branch unknown — falling back to 'main'", "repo", spec.VCS.RepoFullname)
64+
}
65+
}
66+
67+
return ref, nil
68+
}
69+
3770
func (g GithubActionCi) GetWorkflowUrl(spec spec.Spec) (string, error) {
3871
if spec.JobId == "" {
3972
slog.Error("Cannot get workflow URL: JobId is empty")
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package ci_backends
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"net/url"
10+
"testing"
11+
12+
"github.com/diggerhq/digger/backend/config"
13+
"github.com/diggerhq/digger/libs/spec"
14+
"github.com/google/go-github/v61/github"
15+
)
16+
17+
// Helper to create a github.Client that talks to an httptest server
18+
func newTestGithubClient(ts *httptest.Server) *github.Client {
19+
client := github.NewClient(ts.Client())
20+
base, _ := url.Parse(ts.URL + "/")
21+
client.BaseURL = base
22+
client.UploadURL, _ = url.Parse(ts.URL + "/")
23+
return client
24+
}
25+
26+
func TestTriggerWorkflow_UsesJobBranchWhenNotForced(t *testing.T) {
27+
// server returns error if repo default branch is requested (shouldn't be called)
28+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29+
if r.Method == http.MethodPost {
30+
// Expect the ref to be the job branch
31+
bodyBytes, _ := io.ReadAll(r.Body)
32+
defer r.Body.Close()
33+
var payload map[string]interface{}
34+
_ = json.Unmarshal(bodyBytes, &payload)
35+
if payload["ref"] != "feature/abc" {
36+
t.Fatalf("expected ref 'feature/abc', got %v", payload["ref"])
37+
}
38+
w.WriteHeader(http.StatusCreated)
39+
return
40+
}
41+
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
42+
}))
43+
defer ts.Close()
44+
45+
// Ensure flag is false
46+
config.DiggerConfig.Set("force_trigger_from_default_branch", false)
47+
48+
client := newTestGithubClient(ts)
49+
ga := GithubActionCi{Client: client}
50+
51+
s := spec.Spec{}
52+
s.VCS.RepoOwner = "owner"
53+
s.VCS.RepoName = "repo"
54+
s.VCS.WorkflowFile = "workflow.yml"
55+
s.Job.Branch = "feature/abc"
56+
57+
if err := ga.TriggerWorkflow(s, "run", "token"); err != nil {
58+
t.Fatalf("TriggerWorkflow failed: %v", err)
59+
}
60+
}
61+
62+
func TestTriggerWorkflow_UsesRepoDefaultBranchWhenForced(t *testing.T) {
63+
// Server returns repo info and accept the dispatch
64+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65+
switch r.Method {
66+
case http.MethodGet:
67+
// repos/{owner}/{repo}
68+
resp := map[string]string{"default_branch": "main"}
69+
_ = json.NewEncoder(w).Encode(resp)
70+
return
71+
case http.MethodPost:
72+
// Check dispatched ref == main
73+
bodyBytes, _ := io.ReadAll(r.Body)
74+
defer r.Body.Close()
75+
var payload map[string]interface{}
76+
_ = json.Unmarshal(bodyBytes, &payload)
77+
if payload["ref"] != "main" {
78+
t.Fatalf("expected ref 'main' when forced, got %v", payload["ref"])
79+
}
80+
w.WriteHeader(http.StatusCreated)
81+
return
82+
default:
83+
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
84+
}
85+
}))
86+
defer ts.Close()
87+
88+
// Enable the flag
89+
config.DiggerConfig.Set("force_trigger_from_default_branch", true)
90+
91+
client := newTestGithubClient(ts)
92+
ga := GithubActionCi{Client: client}
93+
94+
s := spec.Spec{}
95+
s.VCS.RepoOwner = "owner"
96+
s.VCS.RepoName = "repo"
97+
s.VCS.WorkflowFile = "workflow.yml"
98+
s.Job.Branch = "feature/abc"
99+
100+
if err := ga.TriggerWorkflow(s, "run", "token"); err != nil {
101+
t.Fatalf("TriggerWorkflow failed: %v", err)
102+
}
103+
}
104+
105+
func TestResolveWorkflowRef_NotForcedReturnsJobBranch(t *testing.T) {
106+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107+
t.Fatalf("no requests expected when flag is disabled, got %s %s", r.Method, r.URL.Path)
108+
}))
109+
defer ts.Close()
110+
111+
config.DiggerConfig.Set("force_trigger_from_default_branch", false)
112+
113+
client := newTestGithubClient(ts)
114+
ga := GithubActionCi{Client: client}
115+
116+
s := spec.Spec{}
117+
s.Job.Branch = "feature/xyz"
118+
119+
ref, err := ga.resolveWorkflowRef(context.Background(), s)
120+
if err != nil {
121+
t.Fatalf("unexpected error: %v", err)
122+
}
123+
if ref != "feature/xyz" {
124+
t.Fatalf("expected feature/xyz branch, got %v", ref)
125+
}
126+
}
127+
128+
func TestResolveWorkflowRef_ForcedWithNoDefaultBranchFallsBackToMain(t *testing.T) {
129+
// server returns repo info without default_branch
130+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131+
if r.Method == http.MethodGet {
132+
// repos/{owner}/{repo} -> respond with empty object
133+
w.WriteHeader(http.StatusOK)
134+
_, _ = io.WriteString(w, `{}`)
135+
return
136+
}
137+
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
138+
}))
139+
defer ts.Close()
140+
141+
config.DiggerConfig.Set("force_trigger_from_default_branch", true)
142+
143+
client := newTestGithubClient(ts)
144+
ga := GithubActionCi{Client: client}
145+
146+
s := spec.Spec{}
147+
s.VCS.RepoOwner = "owner"
148+
s.VCS.RepoName = "repo"
149+
s.Job.Branch = "feature/xyz"
150+
151+
ref, err := ga.resolveWorkflowRef(context.Background(), s)
152+
if err != nil {
153+
t.Fatalf("unexpected error: %v", err)
154+
}
155+
if ref != "main" {
156+
t.Fatalf("expected fallback main, got %v", ref)
157+
}
158+
}

backend/config/config.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package config
22

33
import (
4-
"github.com/spf13/cast"
54
"os"
65
"strings"
76
"time"
87

8+
"github.com/spf13/cast"
9+
910
"github.com/spf13/viper"
1011
)
1112

@@ -24,6 +25,12 @@ func New() *Config {
2425
v.SetDefault("build_date", "null")
2526
v.SetDefault("deployed_at", time.Now().UTC().Format(time.RFC3339))
2627
v.SetDefault("max_concurrency_per_batch", "0")
28+
// When true, the backend will always trigger CI workflows using the
29+
// repository's default branch (instead of using the branch provided in
30+
// the job spec). When using OIDC for cloud authentication, this can be
31+
// used as a security measure to prevent workflows from untrusted branches
32+
// from assuming roles.
33+
v.SetDefault("force_trigger_from_default_branch", false)
2734
v.BindEnv()
2835
return v
2936
}

0 commit comments

Comments
 (0)