diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f57dac4..4b26373c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,3 +77,29 @@ jobs: with: name: dotnet-packages-${{ github.run_number }} path: ~/drop/dotnet/NuGetPackages/* + + # The CLI wraps az/npm/func in cmd.exe on Windows; run those tests against a real cmd.exe. + windows-cmd-tests: + name: Windows cmd.exe tests + runs-on: windows-latest + timeout-minutes: 20 + defaults: + run: + working-directory: ./src + + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 # Fetch all history for nbgv to calculate version + + - name: Setup .NET 8.0.x + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: "8.0.x" + + - name: Restore test dependencies + run: dotnet restore tests.proj + + - name: Run cmd.exe wrapper tests + run: dotnet test tests.proj --no-restore --configuration Release --filter "FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~CommandArgumentGuardTests" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be935fe..4fdd296a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- Agent and service principal IDs read from configuration or Microsoft Graph are now validated before they are passed to Azure CLI commands (#524). + ## [2.0.0] - 2026-10-20 ### Upgrade Notes diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs index cacef735..303562a0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs @@ -800,7 +800,7 @@ private static Command CreateInstanceCleanupCommand( if (!string.IsNullOrWhiteSpace(config.AgenticAppId)) { logger.LogInformation("Deleting agent identity service principal..."); - await executor.ExecuteAsync("az", $"ad app delete --id {config.AgenticAppId}", null, true, false, ct); + await executor.ExecuteAsync("az", $"ad app delete --id {CommandArgumentGuard.RequireGuid(config.AgenticAppId, nameof(config.AgenticAppId))}", null, true, false, ct); logger.LogInformation("Agent identity service principal deleted"); } @@ -808,7 +808,7 @@ private static Command CreateInstanceCleanupCommand( if (!string.IsNullOrWhiteSpace(config.AgenticUserId)) { logger.LogInformation("Deleting agent user..."); - await executor.ExecuteAsync("az", $"ad user delete --id {config.AgenticUserId}", null, true, false, ct); + await executor.ExecuteAsync("az", $"ad user delete --id {CommandArgumentGuard.RequireGuid(config.AgenticUserId, nameof(config.AgenticUserId))}", null, true, false, ct); logger.LogInformation("Agent user deleted"); } @@ -1111,7 +1111,7 @@ private static async Task ExecuteAllCleanupAsync( if (!string.IsNullOrWhiteSpace(config.AgenticUserId)) { logger.LogInformation("Deleting agent user..."); - await executor.ExecuteAsync("az", $"ad user delete --id {config.AgenticUserId}", null, true, false, ct); + await executor.ExecuteAsync("az", $"ad user delete --id {CommandArgumentGuard.RequireGuid(config.AgenticUserId, nameof(config.AgenticUserId))}", null, true, false, ct); logger.LogInformation("Agent user deleted"); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs index 6a5b0cb7..f60ab5f1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs @@ -437,6 +437,14 @@ private static Command CreateInstanceScopesSubcommand( logger.LogInformation("{IdentityType} ID: {IdentityId}", identityType, agenticAppId); logger.LogInformation(""); + // The ID is passed to an az command line, so it must be a GUID. + if (!Guid.TryParse(agenticAppId, out _)) + { + logger.LogError("{IdentityType} ID in configuration is not a valid GUID.", identityType); + context.ExitCode = 1; + return; + } + // Query Entra ID for the agent identity and OAuth2 grants. logger.LogInformation("Querying Microsoft Entra ID for agent identity and OAuth2 grants..."); @@ -483,6 +491,12 @@ private static Command CreateInstanceScopesSubcommand( } agentServicePrincipalObjectId = objectIdElement.GetString(); + if (!Guid.TryParse(agentServicePrincipalObjectId, out _)) + { + logger.LogError("Service principal lookup returned an objectId that is not a valid GUID."); + context.ExitCode = 1; + return; + } if (spElement.TryGetProperty("displayName", out var nameElement) && nameElement.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(nameElement.GetString())) @@ -550,14 +564,16 @@ private static Command CreateInstanceScopesSubcommand( var scope = grantElement.TryGetProperty("scope", out var scopeElement) ? scopeElement.GetString() : "Unknown"; var resourceId = grantElement.TryGetProperty("resourceId", out var resourceIdElement) ? resourceIdElement.GetString() : "Unknown"; - // Get the resource display name using Graph API - var resourceResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"{graphApiService.GraphBaseUrl}/v1.0/servicePrincipals/{resourceId}?$select=displayName,appId\" --output json"); + // Get the resource display name using Graph API; resourceId comes from Graph and must be a GUID before it reaches the az command line. + var resourceResult = Guid.TryParse(resourceId, out _) + ? await executor.ExecuteAsync("az", + $"rest --method GET --url \"{graphApiService.GraphBaseUrl}/v1.0/servicePrincipals/{resourceId}?$select=displayName,appId\" --output json") + : null; string resourceName = "Unknown Resource"; string resourceAppId = "Unknown"; - if (resourceResult.Success) + if (resourceResult?.Success == true) { try { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestConsentRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestConsentRunner.cs index 88c1d66b..0805abd3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestConsentRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestConsentRunner.cs @@ -159,7 +159,7 @@ private static async Task GrantOneAsync( } var resourceSpId = TryExtractFirstId(resourceSpResult.StandardOutput); - if (string.IsNullOrWhiteSpace(resourceSpId)) + if (string.IsNullOrWhiteSpace(resourceSpId) || !GuidPattern().IsMatch(resourceSpId)) { logger.LogWarning( " '{Name}': resource service principal not found in tenant — cannot grant consent.", @@ -203,6 +203,13 @@ private static async Task GrantOneAsync( var scopeValue = string.Join(' ', mergedScopes.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)); if (existingGrantId is not null) { + // Grant IDs are base64url; anything else must not reach the az command line. + if (!SafeScopePattern().IsMatch(existingGrantId)) + { + logger.LogWarning(" '{Name}': existing oauth2PermissionGrant has an unexpected id format - skipping.", spec.ResourceName); + return false; + } + var patchBody = JsonSerializer.Serialize(new { scope = scopeValue }); var patched = await ExecuteAzRestWithBodyAsync( executor, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/CommandArgumentGuard.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/CommandArgumentGuard.cs new file mode 100644 index 00000000..1a8cd9b8 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/CommandArgumentGuard.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Helpers; + +/// +/// Validates values that are passed to external command lines. +/// +public static class CommandArgumentGuard +{ + /// + /// Returns the value as a canonical GUID string, or throws if it is not a GUID. + /// + public static string RequireGuid(string? value, string fieldName) + { + if (!Guid.TryParse(value, out var guid)) + { + throw new ArgumentException($"{fieldName} must be a valid GUID.", fieldName); + } + + return guid.ToString("D"); + } + + /// + /// Throws if a command line passed to 'cmd.exe /c' could chain or redirect commands. + /// Metacharacters are allowed only inside balanced double quotes; '%' is rejected everywhere + /// because cmd.exe expands %VAR% (which can add or remove quotes) before it parses quotes. + /// + public static void EnsureSafeForCmd(string commandLine) + { + var inQuotes = false; + foreach (var c in commandLine) + { + if (c is '\r' or '\n' or '\0') + { + throw new ArgumentException("Command line contains a line break or null character.", nameof(commandLine)); + } + + if (c == '%') + { + throw new ArgumentException("Command line contains '%', which cmd.exe expands even inside quotes.", nameof(commandLine)); + } + + if (c == '"') + { + inQuotes = !inQuotes; + continue; + } + + if (!inQuotes && (c is '&' or '|' or '<' or '>' or '^')) + { + throw new ArgumentException($"Command line contains the shell metacharacter '{c}' outside quotes.", nameof(commandLine)); + } + } + + if (inQuotes) + { + throw new ArgumentException("Command line contains unbalanced double quotes.", nameof(commandLine)); + } + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/CommandExecutor.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/CommandExecutor.cs index 7b885072..b5dc45ba 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/CommandExecutor.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/CommandExecutor.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Runtime.InteropServices; using System.Text; +using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Extensions.Logging; namespace Microsoft.Agents.A365.DevTools.Cli.Services; @@ -38,7 +39,7 @@ public virtual async Task ExecuteAsync( { _logger.LogTrace("Wrapping command with cmd.exe for Windows batch file"); fileName = "cmd.exe"; - fileArguments = $"/c {command} {arguments}"; + fileArguments = BuildCmdWrapperArguments(command, arguments); } var startInfo = new ProcessStartInfo @@ -145,7 +146,7 @@ public virtual async Task ExecuteWithStreamingAsync( { _logger.LogTrace("Wrapping command with cmd.exe for Windows batch file"); fileName = "cmd.exe"; - fileArguments = $"/c {command} {arguments}"; + fileArguments = BuildCmdWrapperArguments(command, arguments); } // In interactive mode we keep stdout/err redirected (so we can still display/prefix), @@ -289,6 +290,17 @@ public virtual async Task ExecuteWithStreamingAsync( return result; } + /// + /// Builds the 'cmd.exe' argument string, refusing command lines that cmd.exe would split into extra commands. + /// Delayed expansion is forced off so '!' stays literal regardless of the user's registry settings. + /// + internal static string BuildCmdWrapperArguments(string command, string arguments) + { + var commandLine = $"{command} {arguments}"; + CommandArgumentGuard.EnsureSafeForCmd(commandLine); + return $"/v:off /c {commandLine}"; + } + private bool NeedsCmdWrapper(string command) { var extension = Path.GetExtension(command).ToLowerInvariant(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs index 228aa4f7..ff59e79a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs @@ -406,6 +406,17 @@ public async Task ValidateAsync(Agent365Config config) ValidateGuid(config.AgenticAppId, nameof(config.AgenticAppId), errors); } + // These IDs are passed to az command lines, so they must be GUIDs. + if (!string.IsNullOrWhiteSpace(config.AgenticUserId)) + { + ValidateGuid(config.AgenticUserId, nameof(config.AgenticUserId), errors); + } + + if (!string.IsNullOrWhiteSpace(config.AgentBlueprintId)) + { + ValidateGuid(config.AgentBlueprintId, nameof(config.AgentBlueprintId), errors); + } + if (config.BotId != null) { ValidateGuid(config.BotId, nameof(config.BotId), errors); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs index d827c5cc..b434c42a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs @@ -84,6 +84,13 @@ public static async Task PollAdminConsentAsync( if (BypassConsentChecksForTests) return true; + // appId and the returned SP id are passed to az command lines, so they must be GUIDs. + if (!Guid.TryParse(appId, out _)) + { + logger.LogWarning("Cannot poll for admin consent: app id is not a valid GUID."); + return false; + } + var start = DateTime.UtcNow; var baseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); string? spId = null; @@ -120,7 +127,8 @@ public static async Task PollAdminConsentAsync( var value = doc.RootElement.GetProperty("value"); if (value.GetArrayLength() > 0) { - spId = value[0].GetProperty("id").GetString(); + var candidateSpId = value[0].GetProperty("id").GetString(); + spId = Guid.TryParse(candidateSpId, out _) ? candidateSpId : null; } } catch { } @@ -429,12 +437,20 @@ public static async Task CheckConsentExistsAsync( return false; } + if (!Guid.TryParse(blueprintSpId, out _) || !Guid.TryParse(resourceSpId, out _) || + (!string.IsNullOrWhiteSpace(consentType) && !consentType.All(char.IsAsciiLetter))) + { + logger.LogDebug("Cannot check consent: service principal IDs must be GUIDs and consentType must be letters only"); + return false; + } + + // Not URL-encoded: the value is passed through cmd.exe, which expands '%' even inside quotes. var filter = $"clientId eq '{blueprintSpId}' and resourceId eq '{resourceSpId}'"; if (!string.IsNullOrWhiteSpace(consentType)) filter += $" and consentType eq '{consentType}'"; var grantsResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"{baseUrl}/v1.0/oauth2PermissionGrants?$filter={Uri.EscapeDataString(filter)}\"", + $"rest --method GET --url \"{baseUrl}/v1.0/oauth2PermissionGrants?$filter={filter}\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); if (!grantsResult.Success) @@ -486,6 +502,9 @@ public static async Task CheckConsentExistsAsync( private static async Task LookupSpObjectIdByAppIdAsync( CommandExecutor executor, string appId, string graphBaseUrl, CancellationToken ct) { + if (!Guid.TryParse(appId, out _)) + return null; + var spResult = await executor.ExecuteAsync("az", $"rest --method GET --url \"{graphBaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'&$select=id\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); @@ -497,7 +516,8 @@ public static async Task CheckConsentExistsAsync( { using var doc = JsonDocument.Parse(spResult.StandardOutput); var value = doc.RootElement.GetProperty("value"); - return value.GetArrayLength() > 0 ? value[0].GetProperty("id").GetString() : null; + var spId = value.GetArrayLength() > 0 ? value[0].GetProperty("id").GetString() : null; + return Guid.TryParse(spId, out _) ? spId : null; } catch { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestConsentRunnerTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestConsentRunnerTests.cs index 26098e5f..b6784ee1 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestConsentRunnerTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestConsentRunnerTests.cs @@ -227,6 +227,59 @@ public void TryExtractFirstGrantIdAndScope_EmptyArray_ReturnsBothNull() scope.Should().BeNull(); } + [Fact] + public async Task NonGuidResourceSpId_DoesNotQueryGrants() + { + StubResourceSpLookup(returnsEmpty: false, resourceSpId: "not-a-guid"); + + var (attempted, succeeded) = await AzRestConsentRunner.TryRunAsync( + _executor, BlueprintSpId, new[] { ObsSpec() }, _logger, ct: default); + + attempted.Should().BeTrue(); + succeeded.Should().BeFalse(because: "a resource SP id from Graph that is not a GUID must not be placed on the az command line"); + await _executor.DidNotReceive().ExecuteAsync( + "az", Arg.Is(s => s.Contains("oauth2PermissionGrants")), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UnsafeExistingGrantId_DoesNotPatch() + { + StubResourceSpLookup(returnsEmpty: false); + StubExistingGrantLookup(existingScope: "SomeOtherScope", grantId: "abc\" & other & \""); + + var (attempted, succeeded) = await AzRestConsentRunner.TryRunAsync( + _executor, BlueprintSpId, new[] { ObsSpec() }, _logger, ct: default); + + attempted.Should().BeTrue(); + succeeded.Should().BeFalse(because: "a grant id outside [A-Za-z0-9._-] must not be placed in the PATCH URL"); + await _executor.DidNotReceive().ExecuteAsync( + "az", Arg.Is(s => s.Contains("--method PATCH") || s.Contains("--method POST")), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Base64UrlExistingGrantId_IsPatched() + { + // Real oauth2PermissionGrant ids are base64url and contain '-' and '_'. + const string grantId = "R2gA-_xZ8k6abc_DEF-123"; + StubResourceSpLookup(returnsEmpty: false); + StubExistingGrantLookup(existingScope: "SomeOtherScope", grantId: grantId); + _executor + .ExecuteAsync("az", Arg.Is(s => s.Contains("--method PATCH")), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new CommandResult { ExitCode = 0 })); + + var (attempted, succeeded) = await AzRestConsentRunner.TryRunAsync( + _executor, BlueprintSpId, new[] { ObsSpec() }, _logger, ct: default); + + attempted.Should().BeTrue(); + succeeded.Should().BeTrue(because: "base64url grant ids are valid and existing grants must still be updated"); + await _executor.Received().ExecuteAsync( + "az", Arg.Is(s => s.Contains("--method PATCH") && s.Contains($"oauth2PermissionGrants/{grantId}")), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + // ───────────────────────────────────────────────────────────────────────── // Test scaffolding // ───────────────────────────────────────────────────────────────────────── @@ -237,11 +290,11 @@ private static ResourcePermissionSpec ObsSpec() => new[] { ConfigConstants.ObservabilityApiOtelWriteScope }, SetInheritable: false); - private void StubResourceSpLookup(bool returnsEmpty) + private void StubResourceSpLookup(bool returnsEmpty, string resourceSpId = ResourceSpId) { var json = returnsEmpty ? "{\"value\":[]}" - : $"{{\"value\":[{{\"id\":\"{ResourceSpId}\"}}]}}"; + : $"{{\"value\":[{{\"id\":\"{resourceSpId}\"}}]}}"; _executor .ExecuteAsync("az", Arg.Is(s => s.Contains("/servicePrincipals?") && s.Contains($"appId eq '{ObsAppId}'")), @@ -249,7 +302,7 @@ private void StubResourceSpLookup(bool returnsEmpty) .Returns(Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = json })); } - private void StubExistingGrantLookup(string? existingScope = null, bool empty = false) + private void StubExistingGrantLookup(string? existingScope = null, bool empty = false, string grantId = ExistingGrantId) { string json; if (empty) @@ -258,7 +311,7 @@ private void StubExistingGrantLookup(string? existingScope = null, bool empty = } else { - json = "{\"value\":[{\"id\":\"" + ExistingGrantId + "\",\"scope\":\"" + (existingScope ?? string.Empty) + "\"}]}"; + json = "{\"value\":[{\"id\":\"" + grantId.Replace("\"", "\\\"") + "\",\"scope\":\"" + (existingScope ?? string.Empty) + "\"}]}"; } _executor .ExecuteAsync("az", diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs index ab6aaf24..4d5ccb00 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs @@ -142,6 +142,33 @@ public async Task CleanupInstance_WithValidConfig_ShouldReturnSuccess() } } + [Fact] + public async Task CleanupInstance_WithNonGuidAgenticUserId_DoesNotRunAzAndFails() + { + var config = CreateValidConfig(); + config.AgenticUserId = "00000000-0000-0000-0000-000000000000 & other"; + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBackendConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); + + var originalIn = Console.In; + try + { + Console.SetIn(new StringReader("y\ny\n")); + + var result = await command.InvokeAsync(new[] { "cleanup", "instance" }); + + result.Should().Be(1, because: "a non-GUID agenticUserId must fail cleanup instead of being passed to az"); + await _mockExecutor.DidNotReceive().ExecuteAsync( + Arg.Any(), + Arg.Is(a => a.Contains("ad user delete")), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + finally + { + Console.SetIn(originalIn); + } + } + [Fact(Skip = "Test requires interactive confirmation - cleanup commands now enforce user confirmation instead of --force")] public async Task Cleanup_WithoutSubcommand_ShouldExecuteCompleteCleanup() { @@ -543,8 +570,8 @@ private static Agent365Config CreateValidConfig() TenantId = "test-tenant-id", MessagingEndpoint = "https://test-bot.example.com/api/messages", AgentBlueprintId = "test-blueprint-id", - AgenticAppId = "test-identity-id", - AgenticUserId = "test-user-id", + AgenticAppId = "37fc1c47-5eda-46ca-93b2-76baba49e605", + AgenticUserId = "6a0f6b0e-5d1c-4c0e-9a3b-2f7d8e9c1b42", AgentDescription = "test-agent-description" }; } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/CommandArgumentGuardTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/CommandArgumentGuardTests.cs new file mode 100644 index 00000000..eb8a7cc6 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/CommandArgumentGuardTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Helpers; + +/// +/// Tests for validating command lines passed to 'cmd.exe /c'. +/// +public class CommandArgumentGuardTests +{ + private const string SampleGuid = "37fc1c47-5eda-46ca-93b2-76baba49e605"; + + [Fact] + public void RequireGuid_ValidGuid_ReturnsCanonicalForm() + { + CommandArgumentGuard.RequireGuid("{37FC1C47-5EDA-46CA-93B2-76BABA49E605}", "agenticUserId") + .Should().Be(SampleGuid, because: "only the canonical GUID text may reach the command line"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("x & other")] + [InlineData(SampleGuid + " & other")] + public void RequireGuid_NonGuid_Throws(string? value) + { + var act = () => CommandArgumentGuard.RequireGuid(value, "agenticUserId"); + + act.Should().Throw().WithMessage("*agenticUserId*GUID*"); + } + + [Theory] + [InlineData("az ad user delete --id x & other")] + [InlineData("az ad user delete --id x && other")] + [InlineData("az ad user delete --id x | other")] + [InlineData("az ad user delete --id x > out.txt")] + [InlineData("az ad user delete --id x < in.txt")] + [InlineData("az ad user delete --id x ^& other")] + [InlineData("az ad sp list --filter \"appId eq 'x' \" & other & \"\"")] + [InlineData("az ad sp list --filter \"appId eq 'x")] + [InlineData("az ad user delete --id x\r\nother")] + [InlineData("az ad user delete --id x\nother")] + [InlineData("az rest --url \"https://g/v1.0/grants/%OS:\"=%\" & other & rem \"\"\"")] + [InlineData("az rest --url \"https://g/v1.0/grants/%CD%\"")] + public void EnsureSafeForCmd_UnquotedMetacharacters_Throws(string commandLine) + { + var act = () => CommandArgumentGuard.EnsureSafeForCmd(commandLine); + + act.Should().Throw(because: "cmd.exe would run a second command, redirect output, or expand %VAR% and shift the quotes"); + } + + // Shapes of the real az command lines the CLI builds; a false positive here breaks setup or cleanup. + [Theory] + [InlineData("az ad app delete --id " + SampleGuid)] + [InlineData("az ad user delete --id " + SampleGuid)] + [InlineData("az ad sp create --id " + SampleGuid)] + [InlineData("az ad sp list --filter \"appId eq '" + SampleGuid + "'\" --query \"[].{objectId:id,appId:appId,displayName:displayName}\" --output json")] + [InlineData("az rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '" + SampleGuid + "'&$select=id,appRoles\"")] + [InlineData("az rest --method GET --url \"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '" + SampleGuid + "' and consentType eq 'AllPrincipals'\"")] + [InlineData("az rest --method PATCH --url \"https://graph.microsoft.com/v1.0/oauth2PermissionGrants/abc_DEF-123\" --body @\"C:\\Users\\me\\AppData\\Local\\Temp\\tmp1.json\" --headers Content-Type=application/json")] + [InlineData("az account show --query tenantId -o tsv")] + [InlineData("az login --tenant " + SampleGuid)] + [InlineData("az --version")] + public void EnsureSafeForCmd_RealAzCommandLines_DoNotThrow(string commandLine) + { + var act = () => CommandArgumentGuard.EnsureSafeForCmd(commandLine); + + act.Should().NotThrow(because: "metacharacters inside balanced double quotes are literal to cmd.exe"); + } + + [Fact] + public void BuildCmdWrapperArguments_SafeArguments_PrefixesSlashC() + { + CommandExecutor.BuildCmdWrapperArguments("az", "ad user delete --id " + SampleGuid) + .Should().Be("/v:off /c az ad user delete --id " + SampleGuid, because: "delayed expansion must be off so '!' is never expanded"); + } + + [Fact] + public void BuildCmdWrapperArguments_UnsafeArguments_Throws() + { + var act = () => CommandExecutor.BuildCmdWrapperArguments("az", "ad user delete --id x & other"); + + act.Should().Throw(because: "the executor must not pass an unsafe command line to cmd.exe"); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs index c4eff2dc..6a78af94 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs @@ -20,7 +20,7 @@ public async Task PollAdminConsentAsync_ReturnsTrue_WhenGrantExists() var logger = Substitute.For(); // Mock service principal lookup - var spJson = JsonDocument.Parse("{\"value\":[{\"id\":\"sp-123\"}]}", new JsonDocumentOptions()).RootElement.GetRawText(); + var spJson = JsonDocument.Parse("{\"value\":[{\"id\":\"33333333-3333-3333-3333-333333333333\"}]}", new JsonDocumentOptions()).RootElement.GetRawText(); executor.ExecuteAsync("az", Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(ci => Task.FromResult(new Microsoft.Agents.A365.DevTools.Cli.Services.CommandResult { ExitCode = 0, StandardOutput = spJson })); @@ -31,7 +31,7 @@ public async Task PollAdminConsentAsync_ReturnsTrue_WhenGrantExists() var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var result = await AdminConsentHelper.PollAdminConsentAsync( - executor, logger, "appId-1", "Test", 10, 1, cts.Token, + executor, logger, ValidBlueprintAppId, "Test", 10, 1, cts.Token, graphBaseUrl: "https://graph.example"); result.Should().BeTrue(); @@ -58,7 +58,7 @@ public async Task PollAdminConsentAsync_PropagatesCancellation_WhenTokenCanceled var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); - Func act = () => AdminConsentHelper.PollAdminConsentAsync(executor, logger, "appId-1", "Test", 10, 1, cts.Token); + Func act = () => AdminConsentHelper.PollAdminConsentAsync(executor, logger, ValidBlueprintAppId, "Test", 10, 1, cts.Token); await act.Should().ThrowAsync( because: "OCE must propagate so Ctrl+C aborts setup via AllSubcommand's OCE handler instead of falling into the az rest delegated-consent fallback prompt with a stale 'permission(s)?' question"); @@ -330,7 +330,7 @@ public async Task CheckConsentExistsAsync_AzCli_ReturnsTrue_WhenAllScopesGranted if (args.Contains("servicePrincipals?$filter=appId eq")) { // SP lookup — return a stable object id per appId so the helper threads through. - var spId = args.Contains(ValidBlueprintAppId) ? "bp-sp-id" : "res-sp-id"; + var spId = args.Contains(ValidBlueprintAppId) ? "44444444-4444-4444-4444-444444444444" : "55555555-5555-5555-5555-555555555555"; return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = $"{{\"value\":[{{\"id\":\"{spId}\"}}]}}" }); } if (args.Contains("oauth2PermissionGrants")) @@ -417,7 +417,7 @@ public async Task CheckConsentExistsAsync_AzCli_ReturnsFalse_WhenExistingGrantMi { var args = ci.ArgAt(1); if (args.Contains("servicePrincipals?$filter=appId eq")) - return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "{\"value\":[{\"id\":\"sp-id\"}]}" }); + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "{\"value\":[{\"id\":\"33333333-3333-3333-3333-333333333333\"}]}" }); return Task.FromResult(new CommandResult { ExitCode = 0, @@ -451,7 +451,7 @@ public async Task CheckConsentExistsAsync_AzCli_AggregatesScopesAcrossMultipleGr { var args = ci.ArgAt(1); if (args.Contains("servicePrincipals?$filter=appId eq")) - return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "{\"value\":[{\"id\":\"sp-id\"}]}" }); + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "{\"value\":[{\"id\":\"33333333-3333-3333-3333-333333333333\"}]}" }); return Task.FromResult(new CommandResult { ExitCode = 0, diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ConfigServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ConfigServiceTests.cs index 2d0e8f8c..7710c782 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ConfigServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ConfigServiceTests.cs @@ -568,6 +568,26 @@ public async Task ValidateAsync_ReturnsErrors_ForInvalidGuidFormat() Assert.Contains(result.Errors, e => e.Contains("TenantId") && e.Contains("GUID")); } + [Theory] + [InlineData(nameof(Agent365Config.AgenticUserId))] + [InlineData(nameof(Agent365Config.AgentBlueprintId))] + public async Task ValidateAsync_RejectsNonGuidAgentIds_FromGeneratedConfig(string fieldName) + { + var config = new Agent365Config + { + TenantId = "12345678-1234-1234-1234-123456789012", + ClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", + AgentIdentityDisplayName = "Test Agent", + DeploymentProjectPath = "./test" + }; + typeof(Agent365Config).GetProperty(fieldName)!.SetValue(config, "00000000-0000-0000-0000-000000000000 & other"); + + var result = await _service.ValidateAsync(config); + + result.IsValid.Should().BeFalse(because: "agent IDs are interpolated into az command lines and must be GUIDs"); + result.Errors.Should().Contain(e => e.Contains(fieldName) && e.Contains("GUID")); + } + #endregion #region Helper Method Tests diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/CommandExecutorTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/CommandExecutorTests.cs index c7f69d67..49f3bb1b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/CommandExecutorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/CommandExecutorTests.cs @@ -398,4 +398,58 @@ public void IsNonActionableStderrLine_FiltersCorrectly(string line, bool expecte } #endregion + + #region cmd.exe wrapper + + [Fact] + public async Task ExecuteAsync_CmdWrappedBatchFile_PassesQuotedAmpersandLiterally() + { + if (!OperatingSystem.IsWindows()) return; + + var dir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"cmdwrap_{Guid.NewGuid():N}")).FullName; + try + { + var script = Path.Combine(dir, "echoargs.cmd"); + await File.WriteAllTextAsync(script, "@echo ARGS:%*\r\n"); + + // Relative to its own directory so a profile path with spaces (or NoDefaultCurrentDirectoryInExePath) does not affect the test. + var result = await _executor.ExecuteAsync(@".\echoargs.cmd", "--url \"https://graph.example/v1.0/sp?$filter=a eq 'b'&$select=id\"", workingDirectory: dir, captureOutput: true); + + result.Success.Should().BeTrue(); + result.StandardOutput.Should().Contain("&$select=id", + because: "an '&' inside double quotes must reach the batch file as data, not start a second command"); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task ExecuteAsync_CmdWrappedBatchFile_PercentExpansionQuoteShift_IsRefusedBeforeStart() + { + if (!OperatingSystem.IsWindows()) return; + + var dir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"cmdwrap_{Guid.NewGuid():N}")).FullName; + try + { + var script = Path.Combine(dir, "echoargs.cmd"); + await File.WriteAllTextAsync(script, "@echo ARGS:%*\r\n"); + var marker = Path.Combine(dir, "marker.txt"); + + // %OS:"=% expands to OS with quotes removed, which would leave the '&' outside quotes. + // Quotes are balanced, so only the '%' check can refuse this command line. + var act = () => _executor.ExecuteAsync(@".\echoargs.cmd", "--url \"https://graph.example/x/%OS:\"=%\" & type nul > marker.txt & rem \"\"\"", workingDirectory: dir, captureOutput: true); + + await act.Should().ThrowAsync( + because: "cmd.exe expands %VAR% before parsing quotes, so the command line must be refused before the process starts"); + File.Exists(marker).Should().BeFalse(because: "no second command may run"); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + #endregion }