Skip to content

Commit a87ab48

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 a87ab48

File tree

3 files changed

+254
-2
lines changed

3 files changed

+254
-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: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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+
// setupTestClientAndSpec centralizes common test setup: set the feature flag,
27+
// create a client and GithubActionCi configured to point to the test server,
28+
// and build a basic Spec with the given branch. Tests should use this helper
29+
// to keep setups consistent and small.
30+
func setupTestClientAndSpec(ts *httptest.Server, forceDefault bool, branch string) (GithubActionCi, spec.Spec) {
31+
config.DiggerConfig.Set("force_trigger_from_default_branch", forceDefault)
32+
client := newTestGithubClient(ts)
33+
ga := GithubActionCi{Client: client}
34+
35+
s := spec.Spec{}
36+
s.VCS.RepoOwner = "owner"
37+
s.VCS.RepoName = "repo"
38+
s.VCS.WorkflowFile = "workflow.yml"
39+
s.Job.Branch = branch
40+
41+
return ga, s
42+
}
43+
44+
func TestTriggerWorkflow_UsesJobBranchWhenNotForced(t *testing.T) {
45+
// server returns error if repo default branch is requested (shouldn't be called)
46+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47+
if r.Method == http.MethodPost {
48+
// Expect the ref to be the job branch
49+
bodyBytes, _ := io.ReadAll(r.Body)
50+
defer r.Body.Close()
51+
var payload map[string]interface{}
52+
_ = json.Unmarshal(bodyBytes, &payload)
53+
if payload["ref"] != "feature/abc" {
54+
t.Fatalf("expected ref 'feature/abc', got %v", payload["ref"])
55+
}
56+
w.WriteHeader(http.StatusCreated)
57+
return
58+
}
59+
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
60+
}))
61+
defer ts.Close()
62+
63+
// Ensure flag is false
64+
config.DiggerConfig.Set("force_trigger_from_default_branch", false)
65+
66+
ga, s := setupTestClientAndSpec(ts, false, "feature/abc")
67+
68+
if err := ga.TriggerWorkflow(s, "run", "token"); err != nil {
69+
t.Fatalf("TriggerWorkflow failed: %v", err)
70+
}
71+
}
72+
73+
func TestTriggerWorkflow_UsesRepoDefaultBranchWhenForced(t *testing.T) {
74+
// Server returns repo info and accept the dispatch
75+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76+
switch r.Method {
77+
case http.MethodGet:
78+
// repos/{owner}/{repo}
79+
resp := map[string]string{"default_branch": "main"}
80+
_ = json.NewEncoder(w).Encode(resp)
81+
return
82+
case http.MethodPost:
83+
// Check dispatched ref == main
84+
bodyBytes, _ := io.ReadAll(r.Body)
85+
defer r.Body.Close()
86+
var payload map[string]interface{}
87+
_ = json.Unmarshal(bodyBytes, &payload)
88+
if payload["ref"] != "main" {
89+
t.Fatalf("expected ref 'main' when forced, got %v", payload["ref"])
90+
}
91+
92+
// Accept the dispatch — assertion for the spec contents is handled
93+
// in a separate dedicated test below.
94+
w.WriteHeader(http.StatusCreated)
95+
return
96+
default:
97+
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
98+
}
99+
}))
100+
defer ts.Close()
101+
102+
// Enable the flag and prepare client + spec
103+
ga, s := setupTestClientAndSpec(ts, true, "feature/abc")
104+
105+
if err := ga.TriggerWorkflow(s, "run", "token"); err != nil {
106+
t.Fatalf("TriggerWorkflow failed: %v", err)
107+
}
108+
}
109+
110+
func TestTriggerWorkflow_SpecStillContainsJobBranchWhenForced(t *testing.T) {
111+
// Server returns repo info and accept the dispatch
112+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
113+
switch r.Method {
114+
case http.MethodGet:
115+
// repos/{owner}/{repo}
116+
resp := map[string]string{"default_branch": "main"}
117+
_ = json.NewEncoder(w).Encode(resp)
118+
return
119+
case http.MethodPost:
120+
// Check inputs.spec still contains the original PR branch
121+
bodyBytes, _ := io.ReadAll(r.Body)
122+
defer r.Body.Close()
123+
var payload map[string]interface{}
124+
_ = json.Unmarshal(bodyBytes, &payload)
125+
126+
inputs, ok := payload["inputs"].(map[string]interface{})
127+
if !ok {
128+
t.Fatalf("expected inputs to be map, got %T", payload["inputs"])
129+
}
130+
specStr, ok := inputs["spec"].(string)
131+
if !ok {
132+
t.Fatalf("expected inputs.spec to be string, got %T", inputs["spec"])
133+
}
134+
135+
var decoded spec.Spec
136+
if err := json.Unmarshal([]byte(specStr), &decoded); err != nil {
137+
t.Fatalf("failed to unmarshal spec from inputs: %v", err)
138+
}
139+
if decoded.Job.Branch != "feature/abc" {
140+
t.Fatalf("expected spec.job.branch to still be feature/abc, got %v", decoded.Job.Branch)
141+
}
142+
143+
w.WriteHeader(http.StatusCreated)
144+
return
145+
default:
146+
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
147+
}
148+
}))
149+
defer ts.Close()
150+
151+
// Enable flag and create test client/spec
152+
ga, s := setupTestClientAndSpec(ts, true, "feature/abc")
153+
154+
if err := ga.TriggerWorkflow(s, "run", "token"); err != nil {
155+
t.Fatalf("TriggerWorkflow failed: %v", err)
156+
}
157+
}
158+
159+
func TestResolveWorkflowRef_NotForcedReturnsJobBranch(t *testing.T) {
160+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
161+
t.Fatalf("no requests expected when flag is disabled, got %s %s", r.Method, r.URL.Path)
162+
}))
163+
defer ts.Close()
164+
165+
config.DiggerConfig.Set("force_trigger_from_default_branch", false)
166+
167+
client := newTestGithubClient(ts)
168+
ga := GithubActionCi{Client: client}
169+
170+
s := spec.Spec{}
171+
s.Job.Branch = "feature/xyz"
172+
173+
ref, err := ga.resolveWorkflowRef(context.Background(), s)
174+
if err != nil {
175+
t.Fatalf("unexpected error: %v", err)
176+
}
177+
if ref != "feature/xyz" {
178+
t.Fatalf("expected feature/xyz branch, got %v", ref)
179+
}
180+
}
181+
182+
func TestResolveWorkflowRef_ForcedWithNoDefaultBranchFallsBackToMain(t *testing.T) {
183+
// server returns repo info without default_branch
184+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
185+
if r.Method == http.MethodGet {
186+
// repos/{owner}/{repo} -> respond with empty object
187+
w.WriteHeader(http.StatusOK)
188+
_, _ = io.WriteString(w, `{}`)
189+
return
190+
}
191+
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
192+
}))
193+
defer ts.Close()
194+
195+
config.DiggerConfig.Set("force_trigger_from_default_branch", true)
196+
197+
client := newTestGithubClient(ts)
198+
ga := GithubActionCi{Client: client}
199+
200+
s := spec.Spec{}
201+
s.VCS.RepoOwner = "owner"
202+
s.VCS.RepoName = "repo"
203+
s.Job.Branch = "feature/xyz"
204+
205+
ref, err := ga.resolveWorkflowRef(context.Background(), s)
206+
if err != nil {
207+
t.Fatalf("unexpected error: %v", err)
208+
}
209+
if ref != "main" {
210+
t.Fatalf("expected fallback main, got %v", ref)
211+
}
212+
}

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)