Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ci/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ stages:
Write-Verbose -Verbose "Importing build utilities (buildtools.psd1)"
Import-Module -Name $(Build.SourcesDirectory)/buildtools.psd1 -Force
#
$(Build.SourcesDirectory)/build.ps1 -Build -Clean -BuildConfiguration Release -BuildFramework 'net472'
$(Build.SourcesDirectory)/build.ps1 -Build -Clean -BuildConfiguration Release -BuildFramework 'net8.0'
displayName: Build Module

- pwsh: |
Expand Down
4 changes: 2 additions & 2 deletions .ci/ci_auto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ stages:
Write-Verbose -Verbose "Importing build utilities (buildtools.psd1)"
Import-Module -Name $(Build.SourcesDirectory)/buildtools.psd1 -Force
#
# Build for net472 framework
$(Build.SourcesDirectory)/build.ps1 -Build -Clean -BuildConfiguration Release -BuildFramework 'net472'
# Build for net8.0 framework
$(Build.SourcesDirectory)/build.ps1 -Build -Clean -BuildConfiguration Release -BuildFramework 'net8.0'
displayName: Build module

- pwsh: |
Expand Down
4 changes: 2 additions & 2 deletions build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ param (
[ValidateSet("Debug", "Release")]
[string] $BuildConfiguration = "Debug",

[ValidateSet("net472")]
[string] $BuildFramework = "net472"
[ValidateSet("net8.0")]
[string] $BuildFramework = "net8.0"
)

Import-Module -Name "$PSScriptRoot/buildtools.psd1" -Force
Expand Down
17 changes: 5 additions & 12 deletions doBuild.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,12 @@ function DoBuild
'Azure.Core'
'Azure.Identity'
'Microsoft.Bcl.AsyncInterfaces'
'Microsoft.Extensions.Caching.Abstractions'
'Microsoft.Extensions.Caching.Memory'
'Microsoft.Extensions.FileProviders.Abstractions'
'Microsoft.Extensions.FileSystemGlobbing'
'Microsoft.Extensions.Logging.Abstractions'
'Microsoft.Extensions.Options'
'Microsoft.Extensions.Primitives'
'Microsoft.Identity.Client'
'Microsoft.Identity.Client.Extensions.Msal'
Expand All @@ -113,21 +117,10 @@ function DoBuild
'NuGet.ProjectModel'
'NuGet.Protocol'
'NuGet.Versioning'
'System.Buffers'
'OrasProject.Oras'
'System.ClientModel'
'System.Diagnostics.DiagnosticSource'
'System.IO.FileSystem.AccessControl'
'System.Memory.Data'
'System.Memory'
'System.Numerics.Vectors'
'System.Runtime.CompilerServices.Unsafe'
'System.Security.AccessControl'
'System.Security.Cryptography.ProtectedData'
'System.Security.Principal.Windows'
'System.Text.Encodings.Web'
'System.Text.Json'
'System.Threading.Tasks.Extensions'
'System.ValueTuple'
)

$buildSuccess = $true
Expand Down
1,513 changes: 250 additions & 1,263 deletions src/code/ContainerRegistryServerAPICalls.cs

Large diffs are not rendered by default.

166 changes: 116 additions & 50 deletions src/code/FindHelper.cs

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion src/code/GroupPolicyRepositoryEnforcement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Versioning;
using Microsoft.PowerShell.PSResourceGet.UtilClasses;
using Microsoft.Win32;

Expand All @@ -14,6 +15,7 @@ namespace Microsoft.PowerShell.PSResourceGet.Cmdlets
/// <summary>
/// This class is used to enforce group policy for repositories.
/// </summary>
[SupportedOSPlatform("windows")]
public class GroupPolicyRepositoryEnforcement
{
const string userRoot = "HKEY_CURRENT_USER";
Expand All @@ -29,6 +31,7 @@ private GroupPolicyRepositoryEnforcement()
/// </summary>
///
/// <returns>True if the group policy is enabled, false otherwise.</returns>
[SupportedOSPlatform("windows")]
public static bool IsGroupPolicyEnabled()
{
if (Environment.OSVersion.Platform != PlatformID.Win32NT)
Expand Down Expand Up @@ -57,6 +60,7 @@ public static bool IsGroupPolicyEnabled()
/// </summary>
/// <returns>Array of allowed URIs.</returns>
/// <exception cref="InvalidOperationException">Thrown when the group policy is not enabled.</exception>
[SupportedOSPlatform("windows")]
public static Uri[]? GetAllowedRepositoryURIs()
{
if (Environment.OSVersion.Platform != PlatformID.Win32NT)
Expand Down Expand Up @@ -92,6 +96,7 @@ public static bool IsGroupPolicyEnabled()
}
}

[SupportedOSPlatform("windows")]
internal static bool IsRepositoryAllowed(Uri repositoryUri)
{
bool isAllowed = false;
Expand All @@ -113,6 +118,7 @@ internal static bool IsRepositoryAllowed(Uri repositoryUri)
return isAllowed;
}

[SupportedOSPlatform("windows")]
private static List<KeyValuePair<string, Uri>>? ReadGPFromRegistry()
{
List<KeyValuePair<string, Uri>> allowedRepositories = new List<KeyValuePair<string, Uri>>();
Expand Down Expand Up @@ -169,7 +175,13 @@ internal static bool IsRepositoryAllowed(Uri repositoryUri)
throw new InvalidOperationException("Invalid registry value.");
}

string valueString = value.ToString();
string? valueString = value.ToString();

if (string.IsNullOrEmpty(valueString))
{
throw new InvalidOperationException("Invalid registry value.");
}

var kvRegValue = ConvertRegValue(valueString);
allowedRepositories.Add(kvRegValue);
}
Expand Down
9 changes: 7 additions & 2 deletions src/code/InstallHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,12 @@ private List<PSResourceInfo> ProcessRepositories(
{
PSRepositoryInfo currentRepository = listOfRepositories[i];

bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri);
bool isAllowed = true;

if (OperatingSystem.IsWindows())
{
isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri);
}

if (!isAllowed)
{
Expand Down Expand Up @@ -610,7 +615,7 @@ private List<PSResourceInfo> InstallPackages(
ErrorCategory.InvalidOperation,
_cmdletPassedIn));

throw e;
throw;
}
finally
{
Expand Down
9 changes: 4 additions & 5 deletions src/code/Microsoft.PowerShell.PSResourceGet.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
<AssemblyVersion>1.1.0.1</AssemblyVersion>
<FileVersion>1.1.0.1</FileVersion>
<InformationalVersion>1.1.0.1</InformationalVersion>
<TargetFrameworks>net472</TargetFrameworks>
<LangVersion>9.0</LangVersion>
<TargetFrameworks>net8.0</TargetFrameworks>
<LangVersion>12.0</LangVersion>
<SuppressNETCoreSdkPreviewMessage>true</SuppressNETCoreSdkPreviewMessage>
</PropertyGroup>

Expand All @@ -21,13 +21,12 @@
<PackageReference Include="NuGet.ProjectModel" Version="6.9.1" />
<PackageReference Include="NuGet.Protocol" Version="6.9.1" />
<PackageReference Include="PowerShellStandard.Library" Version="7.0.0-preview.1" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Text.Json" Version="8.0.6" />
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
<PackageReference Include="OrasProject.Oras" Version="0.5.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
<PackageReference Include="Azure.Identity" Version="1.17.2" />
<PackageReference Include="System.Buffers" Version="4.6.1" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="6.0.5" />
<Reference Include="System.Web" />
</ItemGroup>

<ItemGroup>
Expand Down
1 change: 0 additions & 1 deletion src/code/ModuleInitAndCleanup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ public class UnsafeAssemblyHandler : IModuleAssemblyInitializer, IModuleAssembly
"System.Threading.Tasks.Extensions",
"System.Runtime.CompilerServices.Unsafe",
"System.Memory",
"System.Diagnostics.DiagnosticSource",
"System.Text.Json",
"System.Security.Cryptography.ProtectedData"
};
Expand Down
167 changes: 167 additions & 0 deletions src/code/PSResourceGetCredentialProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.PowerShell.PSResourceGet.UtilClasses;
using OrasProject.Oras.Registry.Remote.Auth;

namespace Microsoft.PowerShell.PSResourceGet
{
/// <summary>
/// Implements the ORAS ICredentialProvider interface for PSResourceGet.
/// Handles three authentication pathways:
/// 1. Credentials from SecretManagement vault (CredentialInfo provided)
/// 2. Azure Identity via Utils.GetAzAccessToken (existing helper)
/// 3. Anonymous/unauthenticated access
/// </summary>
internal class PSResourceGetCredentialProvider : ICredentialProvider
{
private readonly PSRepositoryInfo _repository;
private readonly PSCmdlet _cmdletPassedIn;
private readonly Runspace _callerRunspace;
private readonly string _registryHost;
private readonly HttpClient _httpClient;
private Credential _cachedCredential;
private DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue;

// Template for the ACR OAuth2 exchange endpoint
private const string OAuthExchangeUrlTemplate = "https://{0}/oauth2/exchange";
private const string RefreshTokenRequestBodyTemplate = "grant_type=access_token&service={0}&tenant={1}&access_token={2}";
private const string RefreshTokenRequestBodyNoTenantTemplate = "grant_type=access_token&service={0}&access_token={1}";

internal PSResourceGetCredentialProvider(PSRepositoryInfo repository, PSCmdlet cmdletPassedIn, HttpClient httpClient = null)
{
_repository = repository;
_cmdletPassedIn = cmdletPassedIn;
_callerRunspace = Runspace.DefaultRunspace;
_registryHost = repository.Uri.Host;
_httpClient = httpClient ?? new HttpClient();
_cachedCredential = new Credential();
}

public async Task<Credential> ResolveCredentialAsync(string hostname, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(hostname))
{
throw new ArgumentException("Hostname cannot be null or empty.", nameof(hostname));
}

// ORAS invokes this callback on a thread pool thread which has no
// PowerShell Runspace. Restore the caller's Runspace so that
// InvokeCommand.InvokeScript, WriteVerbose, WriteWarning and any
// nested PowerShell script invocations (SecretManagement, etc.) work.
var previousRunspace = Runspace.DefaultRunspace;
Runspace.DefaultRunspace = _callerRunspace;

try
{
return await ResolveCredentialCoreAsync(hostname, cancellationToken).ConfigureAwait(false);
}
finally
{
Runspace.DefaultRunspace = previousRunspace;
}
}

private async Task<Credential> ResolveCredentialCoreAsync(string hostname, CancellationToken cancellationToken)
{
// Return cached credential if still valid
if (!string.IsNullOrEmpty(_cachedCredential.RefreshToken) && DateTimeOffset.UtcNow < _tokenExpiry)
{
Utils.WriteVerboseOnCmdlet(_cmdletPassedIn, "Using cached ORAS credential.");
return _cachedCredential;
}

string aadAccessToken;
string tenantId;

var repositoryCredentialInfo = _repository.CredentialInfo;
if (repositoryCredentialInfo != null)
{
// Path 1: Credential from SecretsManagement vault
Utils.WriteVerboseOnCmdlet(_cmdletPassedIn, "Retrieving access token from SecretManagement vault.");
aadAccessToken = Utils.GetContainerRegistryAccessTokenFromSecretManagement(
_repository.Name,
repositoryCredentialInfo,
_cmdletPassedIn);

if (string.IsNullOrEmpty(aadAccessToken))
{
Utils.WriteWarningOnCmdlet(_cmdletPassedIn, "Failed to retrieve access token from SecretManagement vault.");
return new Credential();
}

tenantId = repositoryCredentialInfo.SecretName;
}
else
{
// Path 2: Azure Identity via existing Utils helper
Utils.WriteVerboseOnCmdlet(_cmdletPassedIn, "Acquiring AAD access token via Utils.GetAzAccessToken.");
aadAccessToken = Utils.GetAzAccessToken(_cmdletPassedIn);

if (string.IsNullOrEmpty(aadAccessToken))
{
// If Azure Identity fails, return empty credential for anonymous access
Utils.WriteVerboseOnCmdlet(_cmdletPassedIn, "No AAD token available; attempting anonymous access.");
return new Credential();
}

tenantId = null;
}

// Exchange AAD access token for ACR refresh token via OAuth2 exchange endpoint
Utils.WriteVerboseOnCmdlet(_cmdletPassedIn, "Exchanging AAD access token for ACR refresh token.");
try
{
string refreshToken = await ExchangeForAcrRefreshTokenAsync(aadAccessToken, tenantId, cancellationToken).ConfigureAwait(false);

if (string.IsNullOrEmpty(refreshToken))
{
Utils.WriteWarningOnCmdlet(_cmdletPassedIn, "Failed to obtain ACR refresh token from exchange.");
return new Credential();
}

_cachedCredential = new Credential(RefreshToken: refreshToken);
_tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(55); // ACR tokens typically valid for ~60 min
return _cachedCredential;
}
catch (Exception ex)
{
Utils.WriteWarningOnCmdlet(_cmdletPassedIn, $"Failed to exchange AAD token for ACR refresh token: {ex.Message}");
return new Credential();
}
}

/// <summary>
/// Exchanges an AAD access token for an ACR refresh token via the OAuth2 exchange endpoint.
/// </summary>
private async Task<string> ExchangeForAcrRefreshTokenAsync(string aadAccessToken, string tenantId, CancellationToken cancellationToken)
{
string exchangeUrl = string.Format(OAuthExchangeUrlTemplate, _registryHost);
string requestBody = string.IsNullOrEmpty(tenantId)
? string.Format(RefreshTokenRequestBodyNoTenantTemplate, _registryHost, aadAccessToken)
: string.Format(RefreshTokenRequestBodyTemplate, _registryHost, tenantId, aadAccessToken);

using var content = new StringContent(requestBody, System.Text.Encoding.UTF8, "application/x-www-form-urlencoded");

Check warning

Code scanning / CodeQL

Information exposure through transmitted data Medium

This data transmitted to the user depends on
sensitive information
.
This data transmitted to the user depends on
sensitive information
.

Copilot Autofix

AI 1 day ago

In general, the right fix is not to stop sending the access token to the token-exchange service (that’s necessary) but to (1) ensure it is only ever transmitted over a secure channel to the expected host, and (2) make sure it is never reflected back to the user via error messages or logs. This aligns with the guideline “avoid transmitting passwords or exceptions to the user”: we continue to transmit the token to the auth service, but we prevent accidental disclosure elsewhere and explicitly constrain the transmission to HTTPS.

For this specific code, the best minimally invasive fix is:

  1. Validate exchangeUrl before sending the HTTP request in ExchangeForAcrRefreshTokenAsync:
    • Parse exchangeUrl as a Uri.
    • Ensure its scheme is https; if not, throw or return null with a safe warning, without ever sending the sensitive requestBody.
    • Optionally, we can also ensure the host matches the expected registry host domain, but we can do that without changing functionality by at least enforcing HTTPS.
  2. Keep the existing behavior of using aadAccessToken in the request body; we are only adding a guard to ensure that this sensitive value is not sent over an insecure protocol.
  3. Avoid introducing any logs that include the token value; the current code does not log it, so we leave logging as-is.

All required changes are localized to ExchangeForAcrRefreshTokenAsync in src/code/PSResourceGetCredentialProvider.cs. No changes are needed in Utils.cs, and we can rely on System.Uri, which is already part of the BCL and requires no new imports.

Concretely:

  • In ExchangeForAcrRefreshTokenAsync, right after string exchangeUrl = ..., add code to create a Uri from exchangeUrl and verify uri.Scheme == Uri.UriSchemeHttps. If not, throw an InvalidOperationException (or return null) before constructing requestBody or sending it.
  • This makes CodeQL recognize that any transmission of the tainted value only happens over HTTPS and mitigates accidental exposure via non-secure channels.

Suggested changeset 1
src/code/PSResourceGetCredentialProvider.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/code/PSResourceGetCredentialProvider.cs b/src/code/PSResourceGetCredentialProvider.cs
--- a/src/code/PSResourceGetCredentialProvider.cs
+++ b/src/code/PSResourceGetCredentialProvider.cs
@@ -145,6 +145,14 @@
         private async Task<string> ExchangeForAcrRefreshTokenAsync(string aadAccessToken, string tenantId, CancellationToken cancellationToken)
         {
             string exchangeUrl = string.Format(OAuthExchangeUrlTemplate, _registryHost);
+
+            // Ensure that we only transmit the access token over a secure (HTTPS) channel.
+            if (!Uri.TryCreate(exchangeUrl, UriKind.Absolute, out Uri exchangeUri) ||
+                !string.Equals(exchangeUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
+            {
+                throw new InvalidOperationException($"Exchange URL must use HTTPS. Actual URL: {exchangeUrl}");
+            }
+
             string requestBody = string.IsNullOrEmpty(tenantId)
                 ? string.Format(RefreshTokenRequestBodyNoTenantTemplate, _registryHost, aadAccessToken)
                 : string.Format(RefreshTokenRequestBodyTemplate, _registryHost, tenantId, aadAccessToken);
EOF
@@ -145,6 +145,14 @@
private async Task<string> ExchangeForAcrRefreshTokenAsync(string aadAccessToken, string tenantId, CancellationToken cancellationToken)
{
string exchangeUrl = string.Format(OAuthExchangeUrlTemplate, _registryHost);

// Ensure that we only transmit the access token over a secure (HTTPS) channel.
if (!Uri.TryCreate(exchangeUrl, UriKind.Absolute, out Uri exchangeUri) ||
!string.Equals(exchangeUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Exchange URL must use HTTPS. Actual URL: {exchangeUrl}");
}

string requestBody = string.IsNullOrEmpty(tenantId)
? string.Format(RefreshTokenRequestBodyNoTenantTemplate, _registryHost, aadAccessToken)
: string.Format(RefreshTokenRequestBodyTemplate, _registryHost, tenantId, aadAccessToken);
Copilot is powered by AI and may make mistakes. Always verify output.
@adityapatwardhan adityapatwardhan committed this autofix suggestion 1 day ago.
using var response = await _httpClient.PostAsync(exchangeUrl, content, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();

string responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
using var jsonDoc = JsonDocument.Parse(responseBody);

if (jsonDoc.RootElement.TryGetProperty("refresh_token", out JsonElement refreshTokenElement))
{
return refreshTokenElement.GetString();
}

return null;
}
}
}
2 changes: 1 addition & 1 deletion src/code/PSScriptMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public PSScriptMetadata(
}

Version = !String.IsNullOrEmpty(version) ? new NuGetVersion (version) : new NuGetVersion("1.0.0.0");
Guid = (guid == null || guid == Guid.Empty) ? Guid.NewGuid() : guid;
Guid = guid;
Author = !String.IsNullOrEmpty(author) ? author : Environment.UserName;
CompanyName = companyName;
Copyright = copyright;
Expand Down
7 changes: 6 additions & 1 deletion src/code/PublishHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,12 @@ internal void PushResource(string Repository, string modulePrefix, bool SkipDepe
return;
}

bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(repository.Uri);
bool isAllowed = true;

if (OperatingSystem.IsWindows())
{
isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(repository.Uri);
}

if (!isAllowed)
{
Expand Down
Loading
Loading