Skip to content

Commit 30ad312

Browse files
committed
Add runner-level config to enforce PR security
This addresses #494 at the runner level to give "just enough protection" to allow using self-hosted runners with public repos. By default, the current behaviour is unchanged -- all jobs passed to the runner are executed. If the `.runner` config file has this block added to it: ``` "pullRequestSecurity": {} ``` Then by only PRs from "CONTRIBUTORS" (as defined by the field in https://docs.github.com/en/free-pro-team@latest/graphql/reference/objects#pullrequest -- nothing for us to have to work out ourselves.) It is also possible to explicitly list users that are allowed to run jobs on this worker: ``` "pullRequestSecurity": { "allowedAuthors": ["ashb"] } ``` Or to _only_ allow the given users, but not all contributors: ``` "pullRequestSecurity": { "allowContributors": false, "allowedAuthors": ["ashb"] } ``` Owners of the repo are always allowed to run jobs.
1 parent 3b34e20 commit 30ad312

File tree

3 files changed

+111
-0
lines changed

3 files changed

+111
-0
lines changed

src/Runner.Common/ConfigurationStore.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using GitHub.Runner.Common.Util;
22
using GitHub.Runner.Sdk;
33
using System;
4+
using System.Collections.Generic;
45
using System.IO;
56
using System.Linq;
67
using System.Runtime.Serialization;
@@ -45,6 +46,9 @@ public sealed class RunnerSettings
4546
[DataMember(EmitDefaultValue = false)]
4647
public string MonitorSocketAddress { get; set; }
4748

49+
[DataMember(Name = "PullRequestSecurity", EmitDefaultValue = false)]
50+
public PullRequestSecuritySettings PullRequestSecuritySettings { get; set; }
51+
4852
[IgnoreDataMember]
4953
public bool IsHostedServer
5054
{
@@ -98,6 +102,18 @@ private void OnSerializing(StreamingContext context)
98102
}
99103
}
100104

105+
[DataContract]
106+
public sealed class PullRequestSecuritySettings
107+
{
108+
// pullRequestSecurity is optional in the config -- if the key is
109+
// defined, assume that we only want collaborators to run PRs.
110+
[DataMember(EmitDefaultValue = false)]
111+
public HashSet<string> AllowedAuthors = new HashSet<string>();
112+
113+
[DataMember(EmitDefaultValue = false)]
114+
public bool AllowContributors = true;
115+
}
116+
101117
[ServiceLocator(Default = typeof(ConfigurationStore))]
102118
public interface IConfigurationStore : IRunnerService
103119
{

src/Runner.Worker/GitHubContext.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,16 @@ public GitHubContext ShallowCopy()
5656

5757
return copy;
5858
}
59+
60+
public bool IsPullRequest()
61+
{
62+
PipelineContextData data;
63+
if (TryGetValue("event_name", out data))
64+
{
65+
var eventName = data as StringContextData;
66+
return eventName == "pull_request";
67+
}
68+
return false;
69+
}
5970
}
6071
}

src/Runner.Worker/JobRunner.cs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using System.Net.Http;
1313
using GitHub.Runner.Common;
1414
using GitHub.Runner.Sdk;
15+
using GitHub.DistributedTask.Pipelines.ContextData;
1516

1617
namespace GitHub.Runner.Worker
1718
{
@@ -63,6 +64,14 @@ public async Task<TaskResult> RunAsync(Pipelines.AgentJobRequestMessage message,
6364
jobContext.InitializeJob(message, jobRequestCancellationToken);
6465
Trace.Info("Starting the job execution context.");
6566
jobContext.Start();
67+
var githubContext = jobContext.ExpressionValues["github"] as GitHubContext;
68+
69+
if (!JobPassesSecurityRestrictions(jobContext))
70+
{
71+
jobContext.Error("Running job on this worker disallowed by security policy");
72+
return await CompleteJobAsync(jobServer, jobContext, message, TaskResult.Failed);
73+
}
74+
6675
jobContext.Debug($"Starting: {message.JobDisplayName}");
6776

6877
runnerShutdownRegistration = HostContext.RunnerShutdownToken.Register(() =>
@@ -189,6 +198,81 @@ public async Task<TaskResult> RunAsync(Pipelines.AgentJobRequestMessage message,
189198
}
190199
}
191200

201+
private bool JobPassesSecurityRestrictions(IExecutionContext jobContext)
202+
{
203+
var gitHubContext = jobContext.ExpressionValues["github"] as GitHubContext;
204+
205+
try {
206+
if (gitHubContext.IsPullRequest())
207+
{
208+
return OkayToRunPullRequest(gitHubContext);
209+
}
210+
211+
return true;
212+
}
213+
catch (Exception ex)
214+
{
215+
Trace.Error("Caught exception in JobPassesSecurityRestrictions");
216+
Trace.Error("As a safety precaution we are not allowing this job to run");
217+
Trace.Error(ex);
218+
return false;
219+
}
220+
}
221+
222+
private bool OkayToRunPullRequest(GitHubContext gitHubContext)
223+
{
224+
var configStore = HostContext.GetService<IConfigurationStore>();
225+
var settings = configStore.GetSettings();
226+
var prSecuritySettings = settings.PullRequestSecuritySettings;
227+
228+
if (prSecuritySettings is null) {
229+
Trace.Info("No pullRequestSecurity defined in settings, allowing this build");
230+
return true;
231+
}
232+
233+
var githubEvent = gitHubContext["event"] as DictionaryContextData;
234+
var prData = githubEvent["pull_request"] as DictionaryContextData;
235+
236+
var authorAssociation = prData.TryGetValue("author_association", out var value)
237+
? value as StringContextData : null;
238+
239+
240+
// TODO: Allow COLLABORATOR, MEMBER too -- possibly by a config setting
241+
if (authorAssociation == "OWNER")
242+
{
243+
Trace.Info("PR is from the repo owner, always allowed");
244+
return true;
245+
}
246+
else if (prSecuritySettings.AllowContributors && authorAssociation == "COLLABORATOR") {
247+
Trace.Info("PR is from the repo collaborator, allowing");
248+
return true;
249+
}
250+
251+
var prHead = prData["head"] as DictionaryContextData;
252+
var prUser = prHead["user"] as DictionaryContextData;
253+
var prUserLogin = prUser["login"] as StringContextData;
254+
255+
Trace.Info($"GitHub PR author is {prUserLogin as StringContextData}");
256+
257+
if (prUserLogin == null)
258+
{
259+
Trace.Info("Unable to get PR author, not allowing PR to run");
260+
return false;
261+
}
262+
263+
if (prSecuritySettings.AllowedAuthors.Contains(prUserLogin))
264+
{
265+
Trace.Info("Author in PR allowed list");
266+
return true;
267+
}
268+
else
269+
{
270+
Trace.Info($"Not running job as author ({prUserLogin}) is not in {{{string.Join(", ", prSecuritySettings.AllowedAuthors)}}}");
271+
272+
return false;
273+
}
274+
}
275+
192276
private async Task<TaskResult> CompleteJobAsync(IJobServer jobServer, IExecutionContext jobContext, Pipelines.AgentJobRequestMessage message, TaskResult? taskResult = null)
193277
{
194278
jobContext.Debug($"Finishing: {message.JobDisplayName}");

0 commit comments

Comments
 (0)