diff --git a/src/Runner.Worker/Dap/DapReplExecutor.cs b/src/Runner.Worker/Dap/DapReplExecutor.cs index 6977c3be3..fa432a8f6 100644 --- a/src/Runner.Worker/Dap/DapReplExecutor.cs +++ b/src/Runner.Worker/Dap/DapReplExecutor.cs @@ -207,7 +207,7 @@ namespace GitHub.Runner.Worker.Dap /// replaced with its masked string result, mirroring the semantics of /// expression interpolation in a workflow run: step body. /// - private string ExpandExpressions(string input, IExecutionContext context) + internal string ExpandExpressions(string input, IExecutionContext context) { if (string.IsNullOrEmpty(input) || !input.Contains("${{")) { @@ -269,7 +269,7 @@ namespace GitHub.Runner.Worker.Dap /// Resolves the default shell the same way /// does: check job defaults, then fall back to platform default. /// - private string ResolveDefaultShell(IExecutionContext context) + internal string ResolveDefaultShell(IExecutionContext context) { // Check job defaults if (context.Global?.JobDefaults != null && @@ -295,7 +295,7 @@ namespace GitHub.Runner.Worker.Dap /// /// Merges the job context environment with any REPL-specific overrides. /// - private Dictionary BuildEnvironment( + internal Dictionary BuildEnvironment( IExecutionContext context, Dictionary replEnv) { diff --git a/src/Test/L0/Worker/DapDebugSessionL0.cs b/src/Test/L0/Worker/DapDebugSessionL0.cs index 1f0b2f8aa..2bb27be24 100644 --- a/src/Test/L0/Worker/DapDebugSessionL0.cs +++ b/src/Test/L0/Worker/DapDebugSessionL0.cs @@ -786,9 +786,17 @@ namespace GitHub.Runner.Common.Tests.Worker Assert.Single(_sentResponses); Assert.True(_sentResponses[0].Success); - // The response body is serialized — we can't easily inspect it from - // the mock, but the important thing is it succeeded without exposing - // raw secrets (which is tested in DapVariableProviderL0). + + // Verify the response body actually contains redacted values + var body = _sentResponses[0].Body; + Assert.NotNull(body); + var varsBody = Assert.IsType(body); + Assert.NotEmpty(varsBody.Variables); + foreach (var variable in varsBody.Variables) + { + Assert.Equal(DapVariableProvider.RedactedValue, variable.Value); + Assert.DoesNotContain("ghp_verysecret", variable.Value); + } // Resume to unblock var continueJson = JsonConvert.SerializeObject(new Request diff --git a/src/Test/L0/Worker/DapReplExecutorL0.cs b/src/Test/L0/Worker/DapReplExecutorL0.cs new file mode 100644 index 000000000..63e6779fb --- /dev/null +++ b/src/Test/L0/Worker/DapReplExecutorL0.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.Expressions2; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.Runner.Common.Tests; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; +using Moq; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapReplExecutorL0 + { + private TestHostContext _hc; + private DapReplExecutor _executor; + private Mock _mockServer; + private List _sentEvents; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + _hc = new TestHostContext(this, testName); + _sentEvents = new List(); + _mockServer = new Mock(); + _mockServer.Setup(x => x.SendEvent(It.IsAny())) + .Callback(e => _sentEvents.Add(e)); + _executor = new DapReplExecutor(_hc, _mockServer.Object); + return _hc; + } + + private Mock CreateMockContext( + DictionaryContextData exprValues = null, + IDictionary> jobDefaults = null) + { + var mock = new Mock(); + mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData()); + mock.Setup(x => x.ExpressionFunctions).Returns(new List()); + + var global = new GlobalContext + { + PrependPath = new List(), + JobDefaults = jobDefaults + ?? new Dictionary>(StringComparer.OrdinalIgnoreCase), + }; + mock.Setup(x => x.Global).Returns(global); + + return mock; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ExecuteRunCommand_NullContext_ReturnsError() + { + using (CreateTestContext()) + { + var command = new RunCommand { Script = "echo hello" }; + var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None); + + Assert.Equal("error", result.Type); + Assert.Contains("No execution context available", result.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_NoExpressions_ReturnsInput() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions("echo hello", context.Object); + + Assert.Equal("echo hello", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_NullInput_ReturnsEmpty() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions(null, context.Object); + + Assert.Equal(string.Empty, result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_EmptyInput_ReturnsEmpty() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions("", context.Object); + + Assert.Equal(string.Empty, result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExpandExpressions_UnterminatedExpression_KeepsLiteral() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ExpandExpressions("echo ${{ github.repo", context.Object); + + Assert.Equal("echo ${{ github.repo", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveDefaultShell_NoJobDefaults_ReturnsSh() + { + using (CreateTestContext()) + { + var context = CreateMockContext(); + var result = _executor.ResolveDefaultShell(context.Object); + + Assert.Equal("sh", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResolveDefaultShell_WithJobDefault_ReturnsJobDefault() + { + using (CreateTestContext()) + { + var jobDefaults = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["run"] = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["shell"] = "bash" + } + }; + var context = CreateMockContext(jobDefaults: jobDefaults); + var result = _executor.ResolveDefaultShell(context.Object); + + Assert.Equal("bash", result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void BuildEnvironment_MergesEnvContextAndReplOverrides() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var envData = new DictionaryContextData + { + ["FOO"] = new StringContextData("bar"), + }; + exprValues["env"] = envData; + + var context = CreateMockContext(exprValues); + var replEnv = new Dictionary { { "BAZ", "qux" } }; + var result = _executor.BuildEnvironment(context.Object, replEnv); + + Assert.Equal("bar", result["FOO"]); + Assert.Equal("qux", result["BAZ"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void BuildEnvironment_ReplOverridesWin() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var envData = new DictionaryContextData + { + ["FOO"] = new StringContextData("original"), + }; + exprValues["env"] = envData; + + var context = CreateMockContext(exprValues); + var replEnv = new Dictionary { { "FOO", "override" } }; + var result = _executor.BuildEnvironment(context.Object, replEnv); + + Assert.Equal("override", result["FOO"]); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void BuildEnvironment_NullReplEnv_ReturnsContextEnvOnly() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var envData = new DictionaryContextData + { + ["FOO"] = new StringContextData("bar"), + }; + exprValues["env"] = envData; + + var context = CreateMockContext(exprValues); + var result = _executor.BuildEnvironment(context.Object, null); + + Assert.Equal("bar", result["FOO"]); + Assert.False(result.ContainsKey("BAZ")); + } + } + } +} diff --git a/src/Test/L0/Worker/DapServerL0.cs b/src/Test/L0/Worker/DapServerL0.cs index ffda39465..2bc4f5fff 100644 --- a/src/Test/L0/Worker/DapServerL0.cs +++ b/src/Test/L0/Worker/DapServerL0.cs @@ -1,5 +1,10 @@ -using System; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Reflection; using System.Runtime.CompilerServices; +using System.Text; using System.Threading; using System.Threading.Tasks; using GitHub.Runner.Worker.Dap; @@ -166,5 +171,172 @@ namespace GitHub.Runner.Common.Tests.Worker await _server.StopAsync(); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task MessageFraming_ValidMessage_ProcessedSuccessfully() + { + using (var hc = CreateTestContext()) + { + var receivedMessages = new List(); + var mockSession = new Mock(); + mockSession.Setup(x => x.HandleMessageAsync(It.IsAny(), It.IsAny())) + .Callback((json, ct) => receivedMessages.Add(json)) + .Returns(Task.CompletedTask); + _server.SetSession(mockSession.Object); + + var cts = new CancellationTokenSource(); + await _server.StartAsync(0, cts.Token); + + var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance); + var listener = (TcpListener)listenerField.GetValue(_server); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port); + var stream = client.GetStream(); + + // Wait for server to accept connection + await Task.Delay(100); + + // Send a valid DAP request with Content-Length framing + var requestJson = "{\"seq\":1,\"type\":\"request\",\"command\":\"initialize\"}"; + var body = Encoding.UTF8.GetBytes(requestJson); + var header = $"Content-Length: {body.Length}\r\n\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(header); + + await stream.WriteAsync(headerBytes, 0, headerBytes.Length); + await stream.WriteAsync(body, 0, body.Length); + await stream.FlushAsync(); + + // Wait for processing + await Task.Delay(500); + + Assert.Single(receivedMessages); + Assert.Contains("initialize", receivedMessages[0]); + + cts.Cancel(); + await _server.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CentralizedMasking_SecretsInResponseAreMasked() + { + using (var hc = CreateTestContext()) + { + // Register a secret + hc.SecretMasker.AddValue("super-secret-token"); + + var cts = new CancellationTokenSource(); + await _server.StartAsync(0, cts.Token); + + var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance); + var listener = (TcpListener)listenerField.GetValue(_server); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port); + var stream = client.GetStream(); + + await Task.Delay(100); + + // Send a response that contains the secret (through the server API) + var response = new Response + { + Type = "response", + RequestSeq = 1, + Command = "evaluate", + Success = true, + Body = new EvaluateResponseBody + { + Result = "The value is super-secret-token here", + Type = "string", + VariablesReference = 0 + } + }; + + _server.SendResponse(response); + + // Read what the client received + await Task.Delay(200); + var buffer = new byte[4096]; + var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); + var received = Encoding.UTF8.GetString(buffer, 0, bytesRead); + + // The response should NOT contain the raw secret + Assert.DoesNotContain("super-secret-token", received); + // It should contain the masked version + Assert.Contains("***", received); + + cts.Cancel(); + await _server.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CentralizedMasking_SecretsInEventsAreMasked() + { + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue("event-secret-value"); + + var cts = new CancellationTokenSource(); + await _server.StartAsync(0, cts.Token); + + var listenerField = typeof(DapServer).GetField("_listener", BindingFlags.NonPublic | BindingFlags.Instance); + var listener = (TcpListener)listenerField.GetValue(_server); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port); + var stream = client.GetStream(); + + await Task.Delay(100); + + _server.SendEvent(new Event + { + EventType = "output", + Body = new OutputEventBody + { + Category = "stdout", + Output = "Output contains event-secret-value here" + } + }); + + await Task.Delay(200); + var buffer = new byte[4096]; + var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); + var received = Encoding.UTF8.GetString(buffer, 0, bytesRead); + + Assert.DoesNotContain("event-secret-value", received); + Assert.Contains("***", received); + + cts.Cancel(); + await _server.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StopAsync_AwaitsConnectionLoopShutdown() + { + using (CreateTestContext()) + { + var cts = new CancellationTokenSource(); + await _server.StartAsync(0, cts.Token); + + // Stop should complete within a reasonable time + var stopTask = _server.StopAsync(); + var completed = await Task.WhenAny(stopTask, Task.Delay(10000)); + Assert.Equal(stopTask, completed); + } + } } } diff --git a/src/Test/L0/Worker/DapVariableProviderL0.cs b/src/Test/L0/Worker/DapVariableProviderL0.cs index 3dfb83f57..2f84b9f34 100644 --- a/src/Test/L0/Worker/DapVariableProviderL0.cs +++ b/src/Test/L0/Worker/DapVariableProviderL0.cs @@ -665,5 +665,108 @@ namespace GitHub.Runner.Common.Tests.Worker } #endregion + + #region Non-string secret type redaction + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsNumberContextData() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "NUMERIC_SECRET", new NumberContextData(12345) } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("NUMERIC_SECRET", variables[0].Name); + Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value); + Assert.Equal("string", variables[0].Type); + Assert.Equal(0, variables[0].VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsBooleanContextData() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "BOOL_SECRET", new BooleanContextData(true) } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("BOOL_SECRET", variables[0].Name); + Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value); + Assert.Equal("string", variables[0].Type); + Assert.Equal(0, variables[0].VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsNestedDictionary() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["secrets"] = new DictionaryContextData + { + { "NESTED_SECRET", new DictionaryContextData + { + { "inner_key", new StringContextData("inner_value") } + } + } + }; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("NESTED_SECRET", variables[0].Name); + Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value); + Assert.Equal("string", variables[0].Type); + // Nested container should NOT be drillable under secrets + Assert.Equal(0, variables[0].VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void GetVariables_SecretsScopeRedactsNullValue() + { + using (CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var secrets = new DictionaryContextData(); + secrets["NULL_SECRET"] = null; + exprValues["secrets"] = secrets; + + var ctx = CreateMockContext(exprValues); + var variables = _provider.GetVariables(ctx.Object, 6); + + Assert.Single(variables); + Assert.Equal("NULL_SECRET", variables[0].Name); + Assert.Equal(DapVariableProvider.RedactedValue, variables[0].Value); + Assert.Equal(0, variables[0].VariablesReference); + } + } + + #endregion } }