mirror of
https://github.com/actions/runner.git
synced 2026-08-03 09:52:46 +08:00
Compare commits
1 Commits
main
...
rentziass-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ad1d89308 |
@@ -1656,5 +1656,137 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#region Secret masking regression tests
|
||||
//
|
||||
// The DAP transport is a secret-carrying channel that bypasses the job
|
||||
// log's masking, so every user-visible string a DAP producer builds must
|
||||
// go through the runner's SecretMasker at the point of construction (the
|
||||
// raw protocol JSON deliberately isn't masked — see SendMessageInternal).
|
||||
// These tests pin the DapDebugger-owned sinks; DapReplExecutorL0 and
|
||||
// DapVariableProviderL0 cover the REPL and expression sinks.
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ErrorResponseMasksSecretsInExceptionMessage()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
const string secret = "super-secret-token";
|
||||
hc.SecretMasker.AddValue(secret);
|
||||
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
|
||||
using var client = await ConnectClientAsync(port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
// A non-integer frameId makes argument deserialization throw, and
|
||||
// Newtonsoft embeds the offending value in the exception message —
|
||||
// exercising the HandleMessageAsync catch-all error response path.
|
||||
await SendRequestAsync(stream, new Request
|
||||
{
|
||||
Seq = 1,
|
||||
Type = "request",
|
||||
Command = "evaluate",
|
||||
Arguments = JObject.FromObject(new
|
||||
{
|
||||
expression = "github.repository",
|
||||
context = "repl",
|
||||
frameId = secret
|
||||
})
|
||||
});
|
||||
|
||||
var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"success\":false", response);
|
||||
Assert.Contains("***", response);
|
||||
Assert.DoesNotContain(secret, response, StringComparison.Ordinal);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ThreadsResponseMasksSecretsInJobName()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
const string secret = "super-secret-token";
|
||||
hc.SecretMasker.AddValue(secret);
|
||||
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, jobName: $"build-{secret}");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
|
||||
using var client = await ConnectClientAsync(port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await SendRequestAsync(stream, new Request
|
||||
{
|
||||
Seq = 1,
|
||||
Type = "request",
|
||||
Command = "threads"
|
||||
});
|
||||
|
||||
var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"command\":\"threads\"", response);
|
||||
Assert.Contains("Job: build-***", response);
|
||||
Assert.DoesNotContain(secret, response, StringComparison.Ordinal);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task StoppedEventMasksSecretsInStepDescription()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
const string secret = "super-secret-token";
|
||||
hc.SecretMasker.AddValue(secret);
|
||||
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
|
||||
var waitTask = _debugger.WaitUntilReadyAsync();
|
||||
using var client = await ConnectClientAsync(port);
|
||||
var stream = client.GetStream();
|
||||
await SendRequestAsync(stream, new Request
|
||||
{
|
||||
Seq = 1,
|
||||
Type = "request",
|
||||
Command = "configurationDone"
|
||||
});
|
||||
await waitTask;
|
||||
|
||||
// configurationDone response + welcome message
|
||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
|
||||
var step = CreateStep($"Run deploy {secret}");
|
||||
var stepTask = _debugger.OnStepStartingAsync(step.Object);
|
||||
|
||||
var stoppedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"event\":\"stopped\"", stoppedEvent);
|
||||
Assert.Contains("Run deploy ***", stoppedEvent);
|
||||
Assert.DoesNotContain(secret, stoppedEvent, StringComparison.Ordinal);
|
||||
|
||||
cts.Cancel();
|
||||
await stepTask;
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.Expressions2;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Tests;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
@@ -18,10 +21,73 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class DapReplExecutorL0
|
||||
{
|
||||
private const string Secret = "super-secret-token";
|
||||
private TestHostContext _hc;
|
||||
private DapReplExecutor _executor;
|
||||
private List<Event> _sentEvents;
|
||||
|
||||
/// <summary>
|
||||
/// A step host that never launches a process. It replays canned stdout
|
||||
/// and stderr lines through the same events a real process raises, so
|
||||
/// the REPL output pipeline — including secret masking — can be
|
||||
/// exercised without executing anything.
|
||||
/// </summary>
|
||||
private sealed class FakeStepHost : RunnerService, IDefaultStepHost
|
||||
{
|
||||
private readonly IEnumerable<string> _stdout;
|
||||
private readonly IEnumerable<string> _stderr;
|
||||
private readonly Exception _executeException;
|
||||
|
||||
public FakeStepHost(
|
||||
IEnumerable<string> stdout = null,
|
||||
IEnumerable<string> stderr = null,
|
||||
Exception executeException = null)
|
||||
{
|
||||
_stdout = stdout ?? Array.Empty<string>();
|
||||
_stderr = stderr ?? Array.Empty<string>();
|
||||
_executeException = executeException;
|
||||
}
|
||||
|
||||
public event EventHandler<ProcessDataReceivedEventArgs> OutputDataReceived;
|
||||
public event EventHandler<ProcessDataReceivedEventArgs> ErrorDataReceived;
|
||||
|
||||
public string ResolvePathForStepHost(IExecutionContext executionContext, string path) => path;
|
||||
|
||||
public Task<string> DetermineNodeRuntimeVersion(IExecutionContext executionContext, string preferredVersion)
|
||||
=> Task.FromResult(preferredVersion);
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
IExecutionContext context,
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
bool inheritConsoleHandler,
|
||||
string standardInInput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_executeException != null)
|
||||
{
|
||||
throw _executeException;
|
||||
}
|
||||
|
||||
foreach (var line in _stdout)
|
||||
{
|
||||
OutputDataReceived?.Invoke(this, new ProcessDataReceivedEventArgs(line));
|
||||
}
|
||||
|
||||
foreach (var line in _stderr)
|
||||
{
|
||||
ErrorDataReceived?.Invoke(this, new ProcessDataReceivedEventArgs(line));
|
||||
}
|
||||
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
|
||||
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
|
||||
{
|
||||
_hc = new TestHostContext(this, testName);
|
||||
@@ -41,6 +107,25 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
return _hc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Concatenates the text of every output event emitted so far, optionally
|
||||
/// filtered to a single DAP output category.
|
||||
/// </summary>
|
||||
private string CapturedOutput(string category = null)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
foreach (var evt in _sentEvents)
|
||||
{
|
||||
var body = (OutputEventBody)evt.Body;
|
||||
if (category == null || string.Equals(body.Category, category, StringComparison.Ordinal))
|
||||
{
|
||||
builder.Append(body.Output);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private Mock<IExecutionContext> CreateMockContext(
|
||||
DictionaryContextData exprValues = null,
|
||||
IDictionary<string, IDictionary<string, string>> jobDefaults = null,
|
||||
@@ -56,12 +141,35 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
JobDefaults = jobDefaults
|
||||
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
|
||||
Container = container,
|
||||
FileTable = new List<string>(),
|
||||
Variables = new Variables(_hc, new Dictionary<string, VariableValue>()),
|
||||
};
|
||||
mock.Setup(x => x.Global).Returns(global);
|
||||
|
||||
// ToPipelineTemplateEvaluator builds a trace writer that calls
|
||||
// context.Write — provide a no-op so expression expansion doesn't NRE.
|
||||
mock.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()));
|
||||
|
||||
return mock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a REPL command end-to-end against a <see cref="FakeStepHost"/>.
|
||||
/// The runner's temp directory is created up front because the executor
|
||||
/// writes the generated script there before invoking the step host.
|
||||
/// </summary>
|
||||
private Task<EvaluateResponseBody> ExecuteWithFakeStepHostAsync(
|
||||
TestHostContext hc,
|
||||
FakeStepHost stepHost,
|
||||
RunCommand command,
|
||||
DictionaryContextData exprValues = null)
|
||||
{
|
||||
Directory.CreateDirectory(hc.GetDirectory(WellKnownDirectory.Temp));
|
||||
hc.EnqueueInstance<IDefaultStepHost>(stepHost);
|
||||
var context = CreateMockContext(exprValues);
|
||||
return _executor.ExecuteRunCommandAsync(command, context.Object, isActionStep: false, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
@@ -334,5 +442,146 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ExecuteRunCommand_EchoesScriptVerbatimAndTruncatesLongScripts()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
// The console echo reflects the user's own input back to the
|
||||
// session that typed it, so it is intentionally not masked. It
|
||||
// is truncated at 80 characters to keep the console readable.
|
||||
var script = new string('a', 90);
|
||||
await ExecuteWithFakeStepHostAsync(hc, new FakeStepHost(), new RunCommand { Script = script });
|
||||
|
||||
var console = CapturedOutput("console");
|
||||
Assert.Contains($"{new string('a', 80)}...\n", console, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(new string('a', 81), console, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
#region Secret masking regression tests
|
||||
//
|
||||
// Output the REPL relays from elsewhere — process output, exception
|
||||
// messages, evaluated expressions — must run through the runner's
|
||||
// SecretMasker before it reaches the DAP transport, because the DAP
|
||||
// console bypasses the normal job-log masking. (The console echo of the
|
||||
// user's own typed input is deliberately excluded; see the test above.)
|
||||
// The tests below pin each sink independently so a future refactor
|
||||
// cannot silently drop masking from one of them.
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ExecuteRunCommand_MasksSecretsInStdout()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SecretMasker.AddValue(Secret);
|
||||
var stepHost = new FakeStepHost(stdout: new[] { $"the token is {Secret}", "second line" });
|
||||
|
||||
var result = await ExecuteWithFakeStepHostAsync(hc, stepHost, new RunCommand { Script = "echo hi" });
|
||||
|
||||
Assert.Equal("string", result.Type);
|
||||
var stdout = CapturedOutput("stdout");
|
||||
Assert.DoesNotContain(Secret, stdout, StringComparison.Ordinal);
|
||||
Assert.Contains("the token is ***", stdout, StringComparison.Ordinal);
|
||||
Assert.Contains("second line", stdout, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(Secret, CapturedOutput(), StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ExecuteRunCommand_MasksSecretsInStderr()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SecretMasker.AddValue(Secret);
|
||||
var stepHost = new FakeStepHost(stderr: new[] { $"auth failed for {Secret}" });
|
||||
|
||||
await ExecuteWithFakeStepHostAsync(hc, stepHost, new RunCommand { Script = "echo hi" });
|
||||
|
||||
var stderr = CapturedOutput("stderr");
|
||||
Assert.DoesNotContain(Secret, stderr, StringComparison.Ordinal);
|
||||
Assert.Contains("auth failed for ***", stderr, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(Secret, CapturedOutput(), StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ExecuteRunCommand_MasksSecretsInFailureResult()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SecretMasker.AddValue(Secret);
|
||||
var stepHost = new FakeStepHost(
|
||||
executeException: new InvalidOperationException($"spawn failed using {Secret}"));
|
||||
|
||||
var result = await ExecuteWithFakeStepHostAsync(hc, stepHost, new RunCommand { Script = "echo hi" });
|
||||
|
||||
Assert.Equal("error", result.Type);
|
||||
Assert.DoesNotContain(Secret, result.Result, StringComparison.Ordinal);
|
||||
Assert.Contains("spawn failed using ***", result.Result, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(Secret, CapturedOutput(), StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ExpandExpressions_MasksSecretsInEvaluatedResult()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SecretMasker.AddValue(Secret);
|
||||
var exprValues = new DictionaryContextData
|
||||
{
|
||||
["env"] = new DictionaryContextData
|
||||
{
|
||||
["TOKEN"] = new StringContextData(Secret)
|
||||
}
|
||||
};
|
||||
|
||||
var context = CreateMockContext(exprValues);
|
||||
var result = _executor.ExpandExpressions("echo ${{ env.TOKEN }} done", context.Object);
|
||||
|
||||
Assert.DoesNotContain(Secret, result, StringComparison.Ordinal);
|
||||
Assert.Equal("echo *** done", result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void BuildEnvironment_MasksNothingButExpandsSecretValuesForExecution()
|
||||
{
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
// The environment handed to the process is *not* a user-visible
|
||||
// sink — it must keep the real value so the command still works.
|
||||
// Only what we echo back over DAP gets masked.
|
||||
hc.SecretMasker.AddValue(Secret);
|
||||
var exprValues = new DictionaryContextData
|
||||
{
|
||||
["env"] = new DictionaryContextData
|
||||
{
|
||||
["TOKEN"] = new StringContextData(Secret)
|
||||
}
|
||||
};
|
||||
|
||||
var context = CreateMockContext(exprValues);
|
||||
var result = _executor.BuildEnvironment(context.Object, replEnv: null);
|
||||
|
||||
Assert.Equal(Secret, result["TOKEN"]);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user