diff --git a/doBuild.ps1 b/doBuild.ps1 index 6eab1d609..15293d99b 100644 --- a/doBuild.ps1 +++ b/doBuild.ps1 @@ -34,9 +34,19 @@ function DoBuild Copy-Item -Path "./LICENSE" -Dest "$BuildOutPath" # Copy notice - Write-Verbose -Verbose -Message "Copying ThirdPartyNotices.txt to '$BuildOutPath'" + Write-Verbose -Verbose -Message "Copying Notice.txt to '$BuildOutPath'" Copy-Item -Path "./Notice.txt" -Dest "$BuildOutPath" + # Copy Group Policy files + Write-Verbose -Verbose -Message "Copying InstallPSResourceGetPolicyDefinitions.ps1 to '$BuildOutPath'" + Copy-Item -Path "${SrcPath}/InstallPSResourceGetPolicyDefinitions.ps1" -Dest "$BuildOutPath" -Force + + Write-Verbose -Verbose -Message "Copying PSResourceRepository.adml to '$BuildOutPath'" + Copy-Item -Path "${SrcPath}/PSResourceRepository.adml" -Dest "$BuildOutPath" -Force + + Write-Verbose -Verbose -Message "Copying PSResourceRepository.admx to '$BuildOutPath'" + Copy-Item -Path "${SrcPath}/PSResourceRepository.admx" -Dest "$BuildOutPath" -Force + # Build and place binaries if ( Test-Path "${SrcPath}/code" ) { Write-Verbose -Verbose -Message "Building assembly and copying to '$BuildOutPath'" diff --git a/global.json b/global.json index 8acf2f3a1..120c43985 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.400" + "version": "8.0.403" } } diff --git a/src/InstallPSResourceGetPolicyDefinitions.ps1 b/src/InstallPSResourceGetPolicyDefinitions.ps1 new file mode 100644 index 000000000..e0f2d15d4 --- /dev/null +++ b/src/InstallPSResourceGetPolicyDefinitions.ps1 @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.Synopsis + Group Policy tools use administrative template files (.admx, .adml) to populate policy settings in the user interface. + This allows administrators to manage registry-based policy settings. + This script installs PSResourceGet Administrative Templates for Windows. +.Notes + The PSResourceRepository.admx and PSResourceRepository.adml files are + expected to be at the location specified by the Path parameter with default value of the location of this script. +#> +[CmdletBinding()] +param +( + [ValidateNotNullOrEmpty()] + [string] $Path = $PSScriptRoot +) +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +function Test-Elevated +{ + [CmdletBinding()] + [OutputType([bool])] + Param() + + # if the current Powershell session was called with administrator privileges, + # the Administrator Group's well-known SID will show up in the Groups for the current identity. + # Note that the SID won't show up unless the process is elevated. + return (([Security.Principal.WindowsIdentity]::GetCurrent()).Groups -contains "S-1-5-32-544") +} +$IsWindowsOs = $PSHOME.EndsWith('\WindowsPowerShell\v1.0', [System.StringComparison]::OrdinalIgnoreCase) -or $IsWindows + +if (-not $IsWindowsOs) +{ + throw 'This script must be run on Windows.' +} + +if (-not (Test-Elevated)) +{ + throw 'This script must be run from an elevated process.' +} + +if ([System.Management.Automation.Platform]::IsNanoServer) +{ + throw 'Group policy definitions are not supported on Nano Server.' +} + +$admxName = 'PSResourceRepository.admx' +$admlName = 'PSResourceRepository.adml' +$admx = Get-Item -Path (Join-Path -Path $Path -ChildPath $admxName) +$adml = Get-Item -Path (Join-Path -Path $Path -ChildPath $admlName) +$admxTargetPath = Join-Path -Path $env:WINDIR -ChildPath "PolicyDefinitions" +$admlTargetPath = Join-Path -Path $admxTargetPath -ChildPath "en-US" + +$files = @($admx, $adml) +foreach ($file in $files) +{ + if (-not (Test-Path -Path $file)) + { + throw "Could not find $($file.Name) at $Path" + } +} + +Write-Verbose "Copying $admx to $admxTargetPath" +Copy-Item -Path $admx -Destination $admxTargetPath -Force +$admxTargetFullPath = Join-Path -Path $admxTargetPath -ChildPath $admxName +if (Test-Path -Path $admxTargetFullPath) +{ + Write-Verbose "$admxName was installed successfully" +} +else +{ + Write-Error "Could not install $admxName" +} + +Write-Verbose "Copying $adml to $admlTargetPath" +Copy-Item -Path $adml -Destination $admlTargetPath -Force +$admlTargetFullPath = Join-Path -Path $admlTargetPath -ChildPath $admlName +if (Test-Path -Path $admlTargetFullPath) +{ + Write-Verbose "$admlName was installed successfully" +} +else +{ + Write-Error "Could not install $admlName" +} diff --git a/src/PSGet.Format.ps1xml b/src/PSGet.Format.ps1xml index 18140432b..e81b0728a 100644 --- a/src/PSGet.Format.ps1xml +++ b/src/PSGet.Format.ps1xml @@ -94,6 +94,7 @@ + @@ -102,6 +103,7 @@ Uri Trusted Priority + IsAllowedByPolicy diff --git a/src/PSResourceRepository.adml b/src/PSResourceRepository.adml new file mode 100644 index 000000000..5da96427f --- /dev/null +++ b/src/PSResourceRepository.adml @@ -0,0 +1,20 @@ + + + + PSResourceGet Repository Policy + This creates an allow list of repositories for PSResourceGet. + + + At least Windows 11* + PSResourceGet Repository Policy + This creates an allow list of repositories for PSResourceGet. + PSResourceGet Repository Policies + + + + Please create an allow list of repositories using a name value pair like following: Name=PSGallery;Uri=https://www.powershellgallery.com/api/v2 + + + + + diff --git a/src/PSResourceRepository.admx b/src/PSResourceRepository.admx new file mode 100644 index 000000000..6e8db3ec6 --- /dev/null +++ b/src/PSResourceRepository.admx @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/code/FindHelper.cs b/src/code/FindHelper.cs index a2a14fa72..327d0e024 100644 --- a/src/code/FindHelper.cs +++ b/src/code/FindHelper.cs @@ -9,6 +9,7 @@ using System.Management.Automation; using System.Net; using System.Runtime.ExceptionServices; +using System.Text.RegularExpressions; using System.Threading; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets @@ -182,9 +183,24 @@ public IEnumerable FindByResourceName( } List repositoryNamesToSearch = new List(); + for (int i = 0; i < repositoriesToSearch.Count; i++) { PSRepositoryInfo currentRepository = repositoriesToSearch[i]; + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{currentRepository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + continue; + } + repositoryNamesToSearch.Add(currentRepository.Name); _networkCredential = Utils.SetNetworkCredential(currentRepository, _networkCredential, _cmdletPassedIn); ServerApiCall currentServer = ServerFactory.GetServer(currentRepository, _cmdletPassedIn, _networkCredential); @@ -230,7 +246,7 @@ public IEnumerable FindByResourceName( // Scenarios: Find-PSResource -Name "pkg" -Repository *Gallery -> write error if only if pkg wasn't found in any matching repositories. foreach(string pkgName in pkgsDiscovered) { - var msg = repository == null ? $"Package '{pkgName}' could not be found in any registered repositories." : + var msg = repository == null ? $"Package '{pkgName}' could not be found in any registered repositories." : $"Package '{pkgName}' could not be found in registered repositories: '{string.Join(", ", repositoryNamesToSearch)}'."; _cmdletPassedIn.WriteError(new ErrorRecord( @@ -355,6 +371,20 @@ public IEnumerable FindByCommandOrDscResource( for (int i = 0; i < repositoriesToSearch.Count; i++) { PSRepositoryInfo currentRepository = repositoriesToSearch[i]; + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{currentRepository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + continue; + } + repositoryNamesToSearch.Add(currentRepository.Name); _networkCredential = Utils.SetNetworkCredential(currentRepository, _networkCredential, _cmdletPassedIn); ServerApiCall currentServer = ServerFactory.GetServer(currentRepository, _cmdletPassedIn, _networkCredential); @@ -393,9 +423,9 @@ public IEnumerable FindByCommandOrDscResource( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"'{String.Join(", ", _tag)}' could not be found", currentResult.exception), - "FindCmdOrDscToPSResourceObjFailure", - ErrorCategory.NotSpecified, + new ResourceNotFoundException($"'{String.Join(", ", _tag)}' could not be found", currentResult.exception), + "FindCmdOrDscToPSResourceObjFailure", + ErrorCategory.NotSpecified, this); if (shouldReportErrorForEachRepo) @@ -421,7 +451,7 @@ public IEnumerable FindByCommandOrDscResource( if (!isCmdOrDSCTagFound && !shouldReportErrorForEachRepo) { string parameterName = isSearchingForCommands ? "CommandName" : "DSCResourceName"; - var msg = repository == null ? $"Package with {parameterName} '{String.Join(", ", _tag)}' could not be found in any registered repositories." : + var msg = repository == null ? $"Package with {parameterName} '{String.Join(", ", _tag)}' could not be found in any registered repositories." : $"Package with {parameterName} '{String.Join(", ", _tag)}' could not be found in registered repositories: '{string.Join(", ", repositoryNamesToSearch)}'."; _cmdletPassedIn.WriteError(new ErrorRecord( @@ -545,6 +575,20 @@ public IEnumerable FindByTag( for (int i = 0; i < repositoriesToSearch.Count; i++) { PSRepositoryInfo currentRepository = repositoriesToSearch[i]; + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{currentRepository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + continue; + } + repositoryNamesToSearch.Add(currentRepository.Name); _networkCredential = Utils.SetNetworkCredential(currentRepository, _networkCredential, _cmdletPassedIn); ServerApiCall currentServer = ServerFactory.GetServer(currentRepository, _cmdletPassedIn, _networkCredential); @@ -591,9 +635,9 @@ public IEnumerable FindByTag( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { errRecord = new ErrorRecord( - new ResourceNotFoundException($"Tags '{String.Join(", ", _tag)}' could not be found" , currentResult.exception), - "FindTagConvertToPSResourceFailure", - ErrorCategory.InvalidResult, + new ResourceNotFoundException($"Tags '{String.Join(", ", _tag)}' could not be found" , currentResult.exception), + "FindTagConvertToPSResourceFailure", + ErrorCategory.InvalidResult, this); if (shouldReportErrorForEachRepo) @@ -615,7 +659,7 @@ public IEnumerable FindByTag( if (!isTagFound && !shouldReportErrorForEachRepo) { - var msg = repository == null ? $"Package with Tags '{String.Join(", ", _tag)}' could not be found in any registered repositories." : + var msg = repository == null ? $"Package with Tags '{String.Join(", ", _tag)}' could not be found in any registered repositories." : $"Package with Tags '{String.Join(", ", _tag)}' could not be found in registered repositories: '{string.Join(", ", repositoryNamesToSearch)}'."; _cmdletPassedIn.WriteError(new ErrorRecord( @@ -665,9 +709,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - currentResult.exception, - "FindAllConvertToPSResourceFailure", - ErrorCategory.InvalidResult, + currentResult.exception, + "FindAllConvertToPSResourceFailure", + ErrorCategory.InvalidResult, this)); continue; @@ -721,9 +765,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - currentResult.exception, - "FindNameGlobbingConvertToPSResourceFailure", - ErrorCategory.InvalidResult, + currentResult.exception, + "FindNameGlobbingConvertToPSResourceFailure", + ErrorCategory.InvalidResult, this)); continue; @@ -772,9 +816,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { // This scenario may occur when the package version requested is unlisted. _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Package with name '{pkgName}'{tagsAsString} could not be found in repository '{repository.Name}'"), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{pkgName}'{tagsAsString} could not be found in repository '{repository.Name}'"), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this)); continue; @@ -783,9 +827,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - currentResult.exception, - "FindNameConvertToPSResourceFailure", - ErrorCategory.ObjectNotFound, + currentResult.exception, + "FindNameConvertToPSResourceFailure", + ErrorCategory.ObjectNotFound, this)); continue; @@ -804,8 +848,8 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( new ArgumentException("Name cannot contain or equal wildcard when using specific version."), - "InvalidWildCardUsage", - ErrorCategory.InvalidOperation, + "InvalidWildCardUsage", + ErrorCategory.InvalidOperation, this)); continue; @@ -846,9 +890,9 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { // This scenario may occur when the package version requested is unlisted. _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Package with name '{pkgName}', version '{_nugetVersion.ToNormalizedString()}'{tagsAsString} could not be found in repository '{repository.Name}'"), - "PackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Package with name '{pkgName}', version '{_nugetVersion.ToNormalizedString()}'{tagsAsString} could not be found in repository '{repository.Name}'"), + "PackageNotFound", + ErrorCategory.ObjectNotFound, this)); continue; @@ -858,8 +902,8 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( currentResult.exception, - "FindVersionConvertToPSResourceFailure", - ErrorCategory.ObjectNotFound, + "FindVersionConvertToPSResourceFailure", + ErrorCategory.ObjectNotFound, this)); continue; @@ -879,8 +923,8 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( new ArgumentException("Name cannot contain or equal wildcard when using version range"), - "InvalidWildCardUsage", - ErrorCategory.InvalidOperation, + "InvalidWildCardUsage", + ErrorCategory.InvalidOperation, this)); } else @@ -897,7 +941,7 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( new ArgumentException("-Tag parameter cannot be specified when using version range."), - "InvalidWildCardUsage", + "InvalidWildCardUsage", ErrorCategory.InvalidOperation, this)); @@ -925,14 +969,14 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R { _cmdletPassedIn.WriteError(new ErrorRecord( currentResult.exception, - "FindVersionGlobbingConvertToPSResourceFailure", - ErrorCategory.ObjectNotFound, + "FindVersionGlobbingConvertToPSResourceFailure", + ErrorCategory.ObjectNotFound, this)); continue; } - // Check to see if version falls within version range + // Check to see if version falls within version range PSResourceInfo foundPkg = currentResult.returnedObject; string versionStr = $"{foundPkg.Version}"; if (foundPkg.IsPrerelease) @@ -1014,7 +1058,7 @@ private bool TryAddToPackagesFound(PSResourceInfo foundPkg) _packagesFound.Add(foundPkg.Name, new List { foundPkgVersion }); addedToHash = true; } - + _cmdletPassedIn.WriteDebug($"Found package '{foundPkg.Name}' version '{foundPkg.Version}'"); return addedToHash; @@ -1070,9 +1114,9 @@ internal IEnumerable FindDependencyPackages( { // This scenario may occur when the package version requested is unlisted. _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Dependency package with name '{dep.Name}' could not be found in repository '{repository.Name}'"), - "DependencyPackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Dependency package with name '{dep.Name}' could not be found in repository '{repository.Name}'"), + "DependencyPackageNotFound", + ErrorCategory.ObjectNotFound, this)); yield return null; continue; @@ -1081,9 +1125,9 @@ internal IEnumerable FindDependencyPackages( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Dependency package with name '{dep.Name}' could not be found in repository '{repository.Name}'", currentResult.exception), - "DependencyPackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Dependency package with name '{dep.Name}' could not be found in repository '{repository.Name}'", currentResult.exception), + "DependencyPackageNotFound", + ErrorCategory.ObjectNotFound, this)); yield return null; continue; @@ -1130,9 +1174,9 @@ internal IEnumerable FindDependencyPackages( if (responses.IsFindResultsEmpty()) { _cmdletPassedIn.WriteError(new ErrorRecord( - new InvalidOrEmptyResponse($"Dependency package with name {dep.Name} and version range {dep.VersionRange} could not be found in repository '{repository.Name}"), - "FindDepPackagesFindVersionGlobbingFailure", - ErrorCategory.InvalidResult, + new InvalidOrEmptyResponse($"Dependency package with name {dep.Name} and version range {dep.VersionRange} could not be found in repository '{repository.Name}"), + "FindDepPackagesFindVersionGlobbingFailure", + ErrorCategory.InvalidResult, this)); yield return null; continue; @@ -1143,16 +1187,16 @@ internal IEnumerable FindDependencyPackages( if (currentResult.exception != null && !currentResult.exception.Message.Equals(string.Empty)) { _cmdletPassedIn.WriteError(new ErrorRecord( - new ResourceNotFoundException($"Dependency package with name '{dep.Name}' and version range '{dep.VersionRange}' could not be found in repository '{repository.Name}'", currentResult.exception), - "DependencyPackageNotFound", - ErrorCategory.ObjectNotFound, + new ResourceNotFoundException($"Dependency package with name '{dep.Name}' and version range '{dep.VersionRange}' could not be found in repository '{repository.Name}'", currentResult.exception), + "DependencyPackageNotFound", + ErrorCategory.ObjectNotFound, this)); - + yield return null; continue; } - // Check to see if version falls within version range + // Check to see if version falls within version range PSResourceInfo foundDep = currentResult.returnedObject; string depVersionStr = $"{foundDep.Version}"; if (foundDep.IsPrerelease) { diff --git a/src/code/GroupPolicyRepositoryEnforcement.cs b/src/code/GroupPolicyRepositoryEnforcement.cs new file mode 100644 index 000000000..ac4f7ee98 --- /dev/null +++ b/src/code/GroupPolicyRepositoryEnforcement.cs @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using Microsoft.Win32; + +namespace Microsoft.PowerShell.PSResourceGet.Cmdlets +{ + /// + /// This class is used to enforce group policy for repositories. + /// + public class GroupPolicyRepositoryEnforcement + { + const string userRoot = "HKEY_CURRENT_USER"; + const string psresourcegetGPPath = @"SOFTWARE\Policies\Microsoft\PSResourceGetRepository"; + const string gpRootPath = @"Software\Microsoft\Windows\CurrentVersion\Group Policy Objects"; + + private GroupPolicyRepositoryEnforcement() + { + } + + /// + /// This method is used to see if the group policy is enabled. + /// + /// + /// True if the group policy is enabled, false otherwise. + public static bool IsGroupPolicyEnabled() + { + if (Environment.OSVersion.Platform != PlatformID.Win32NT) + { + // Always return false for non-Windows platforms and Group Policy is not available. + return false; + } + + if (InternalHooks.EnableGPRegistryHook) + { + return InternalHooks.GPEnabledStatus; + } + + var values = ReadGPFromRegistry(); + + if (values is not null && values.Count > 0) + { + return true; + } + + return false; + } + + /// + /// Get allowed list of URIs for allowed repositories. + /// + /// Array of allowed URIs. + /// Thrown when the group policy is not enabled. + public static Uri[]? GetAllowedRepositoryURIs() + { + if (Environment.OSVersion.Platform != PlatformID.Win32NT) + { + throw new PlatformNotSupportedException("Group policy is only supported on Windows."); + } + + if (InternalHooks.EnableGPRegistryHook) + { + var uri = new Uri(InternalHooks.AllowedUri); + return new Uri[] { uri }; + } + + if (!IsGroupPolicyEnabled()) + { + return null; + } + else + { + List allowedUris = new List(); + + var allowedRepositories = ReadGPFromRegistry(); + + if (allowedRepositories is not null && allowedRepositories.Count > 0) + { + foreach (var allowedRepository in allowedRepositories) + { + allowedUris.Add(allowedRepository.Value); + } + } + + return allowedUris.ToArray(); + } + } + + internal static bool IsRepositoryAllowed(Uri repositoryUri) + { + bool isAllowed = false; + + if(GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled()) + { + var allowedList = GroupPolicyRepositoryEnforcement.GetAllowedRepositoryURIs(); + + if (allowedList != null && allowedList.Length > 0) + { + isAllowed = allowedList.Any(uri => uri.Equals(repositoryUri)); + } + } + else + { + isAllowed = true; + } + + return isAllowed; + } + + private static List>? ReadGPFromRegistry() + { + List> allowedRepositories = new List>(); + + using (var key = Registry.CurrentUser.OpenSubKey(gpRootPath)) + { + if (key is null) + { + return null; + } + + var subKeys = key.GetSubKeyNames(); + + if (subKeys is null) + { + return null; + } + + foreach (var subKey in subKeys) + { + if (subKey.EndsWith("Machine")) + { + continue; + } + + using (var psrgKey = key.OpenSubKey(subKey + "\\" + psresourcegetGPPath)) + { + if (psrgKey is null) + { + // this GPO does not have PSResourceGetRepository key + continue; + } + + var valueNames = psrgKey.GetValueNames(); + + // This means it is disabled + if (valueNames is null || valueNames.Length == 0 || valueNames.Length == 1 && valueNames[0].Equals("**delvals.", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + else + { + foreach (var valueName in valueNames) + { + if (valueName.Equals("**delvals.", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var value = psrgKey.GetValue(valueName); + + if (value is null) + { + throw new InvalidOperationException("Invalid registry value."); + } + + string valueString = value.ToString(); + var kvRegValue = ConvertRegValue(valueString); + allowedRepositories.Add(kvRegValue); + } + } + } + } + } + + return allowedRepositories; + } + + private static KeyValuePair ConvertRegValue(string regValue) + { + if (string.IsNullOrEmpty(regValue)) + { + throw new ArgumentException("Registry value is empty."); + } + + var KvPairs = regValue.Split(new char[] { ';' }); + + string? nameValue = null; + string? uriValue = null; + + foreach (var kvPair in KvPairs) + { + var kv = kvPair.Split(new char[] { '=' }, 2); + + if (kv.Length != 2) + { + throw new InvalidOperationException("Invalid registry value."); + } + + if (kv[0].Equals("Name", StringComparison.OrdinalIgnoreCase)) + { + nameValue = kv[1]; + } + + if (kv[0].Equals("Uri", StringComparison.OrdinalIgnoreCase)) + { + uriValue = kv[1]; + } + } + + if (nameValue is not null && uriValue is not null) + { + return new KeyValuePair(nameValue, new Uri(uriValue)); + } + else + { + throw new InvalidOperationException("Invalid registry value."); + } + } + } +} \ No newline at end of file diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 8cc212eb7..b916268c2 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -269,6 +269,20 @@ private List ProcessRepositories( for (int i = 0; i < listOfRepositories.Count && _pkgNamesToInstall.Count > 0; i++) { PSRepositoryInfo currentRepository = listOfRepositories[i]; + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(currentRepository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{currentRepository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + continue; + } + string repoName = currentRepository.Name; sourceTrusted = currentRepository.Trusted || trustRepository; diff --git a/src/code/InternalHooks.cs b/src/code/InternalHooks.cs index 9b5ea0294..2078d1d41 100644 --- a/src/code/InternalHooks.cs +++ b/src/code/InternalHooks.cs @@ -9,6 +9,12 @@ public class InternalHooks { internal static bool InvokedFromCompat; + internal static bool EnableGPRegistryHook; + + internal static bool GPEnabledStatus; + + internal static string AllowedUri; + public static void SetTestHook(string property, object value) { var fieldInfo = typeof(InternalHooks).GetField(property, BindingFlags.Static | BindingFlags.NonPublic); diff --git a/src/code/PSRepositoryInfo.cs b/src/code/PSRepositoryInfo.cs index 7faa02bc4..b660c6690 100644 --- a/src/code/PSRepositoryInfo.cs +++ b/src/code/PSRepositoryInfo.cs @@ -27,7 +27,7 @@ public enum APIVersion #region Constructor - public PSRepositoryInfo(string name, Uri uri, int priority, bool trusted, PSCredentialInfo credentialInfo, APIVersion apiVersion) + public PSRepositoryInfo(string name, Uri uri, int priority, bool trusted, PSCredentialInfo credentialInfo, APIVersion apiVersion, bool allowed) { Name = name; Uri = uri; @@ -35,6 +35,7 @@ public PSRepositoryInfo(string name, Uri uri, int priority, bool trusted, PSCred Trusted = trusted; CredentialInfo = credentialInfo; ApiVersion = apiVersion; + IsAllowedByPolicy = allowed; } #endregion @@ -88,6 +89,11 @@ public enum RepositoryProviderType /// public APIVersion ApiVersion { get; } + // + /// is it allowed by policy + /// + public bool IsAllowedByPolicy { get; set; } + #endregion } } diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index 6e15dd459..5470da611 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -355,6 +355,19 @@ internal void PushResource(string Repository, string modulePrefix, bool SkipDepe return; } + bool isAllowed = GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(repository.Uri); + + if (!isAllowed) + { + _cmdletPassedIn.WriteError(new ErrorRecord( + new PSInvalidOperationException($"Repository '{repository.Name}' is not allowed by Group Policy."), + "RepositoryNotAllowedByGroupPolicy", + ErrorCategory.PermissionDenied, + this)); + + return; + } + _networkCredential = Utils.SetNetworkCredential(repository, _networkCredential, _cmdletPassedIn); // Check if dependencies already exist within the repo if: diff --git a/src/code/RepositorySettings.cs b/src/code/RepositorySettings.cs index b42e7e581..97e9f80b7 100644 --- a/src/code/RepositorySettings.cs +++ b/src/code/RepositorySettings.cs @@ -9,6 +9,7 @@ using System.Management.Automation; using System.Xml; using System.Xml.Linq; +using Microsoft.PowerShell.PSResourceGet.Cmdlets; using static Microsoft.PowerShell.PSResourceGet.UtilClasses.PSRepositoryInfo; namespace Microsoft.PowerShell.PSResourceGet.UtilClasses @@ -279,7 +280,9 @@ public static PSRepositoryInfo Add(string repoName, Uri repoUri, int repoPriorit throw new PSInvalidOperationException(String.Format("Adding to repository store failed: {0}", e.Message)); } - return new PSRepositoryInfo(repoName, repoUri, repoPriority, repoTrusted, repoCredentialInfo, apiVersion); + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(repoUri) : true; + + return new PSRepositoryInfo(repoName, repoUri, repoPriority, repoTrusted, repoCredentialInfo, apiVersion, isAllowed); } /// @@ -436,13 +439,22 @@ public static PSRepositoryInfo Update(string repoName, Uri repoUri, int repoPrio node.Attribute(PSCredentialInfo.SecretNameAttribute).Value); } + if (GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled()) + { + var allowedList = GroupPolicyRepositoryEnforcement.GetAllowedRepositoryURIs(); + + } + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(thisUrl) : true; + RepositoryProviderType repositoryProvider= GetRepositoryProviderType(thisUrl); updatedRepo = new PSRepositoryInfo(repoName, thisUrl, Int32.Parse(node.Attribute("Priority").Value), Boolean.Parse(node.Attribute("Trusted").Value), thisCredentialInfo, - resolvedAPIVersion); + resolvedAPIVersion, + isAllowed); // Close the file root.Save(FullRepositoryPath); @@ -522,6 +534,9 @@ public static List Remove(string[] repoNames, out string[] err string attributeUrlUriName = urlAttributeExists ? "Url" : "Uri"; Uri repoUri = new Uri(node.Attribute(attributeUrlUriName).Value); + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(repoUri) : true; + RepositoryProviderType repositoryProvider= GetRepositoryProviderType(repoUri); removedRepos.Add( new PSRepositoryInfo(repo, @@ -529,7 +544,8 @@ public static List Remove(string[] repoNames, out string[] err Int32.Parse(node.Attribute("Priority").Value), Boolean.Parse(node.Attribute("Trusted").Value), repoCredentialInfo, - (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true))); + (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true), + isAllowed)); // Remove item from file node.Remove(); @@ -654,12 +670,16 @@ public static List Read(string[] repoNames, out string[] error } RepositoryProviderType repositoryProvider= GetRepositoryProviderType(thisUrl); + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(thisUrl) : true; + PSRepositoryInfo currentRepoItem = new PSRepositoryInfo(repo.Attribute("Name").Value, thisUrl, Int32.Parse(repo.Attribute("Priority").Value), Boolean.Parse(repo.Attribute("Trusted").Value), thisCredentialInfo, - (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), repo.Attribute("APIVersion").Value, ignoreCase: true)); + (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), repo.Attribute("APIVersion").Value, ignoreCase: true), + isAllowed); foundRepos.Add(currentRepoItem); } @@ -758,12 +778,16 @@ public static List Read(string[] repoNames, out string[] error } RepositoryProviderType repositoryProvider= GetRepositoryProviderType(thisUrl); + + bool isAllowed = GroupPolicyRepositoryEnforcement.IsGroupPolicyEnabled() ? GroupPolicyRepositoryEnforcement.IsRepositoryAllowed(thisUrl) : true; + PSRepositoryInfo currentRepoItem = new PSRepositoryInfo(node.Attribute("Name").Value, thisUrl, Int32.Parse(node.Attribute("Priority").Value), Boolean.Parse(node.Attribute("Trusted").Value), thisCredentialInfo, - (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true)); + (PSRepositoryInfo.APIVersion)Enum.Parse(typeof(PSRepositoryInfo.APIVersion), node.Attribute("APIVersion").Value, ignoreCase: true), + isAllowed); foundRepos.Add(currentRepoItem); } diff --git a/src/code/ServerFactory.cs b/src/code/ServerFactory.cs index 5127346ab..10c43b1a3 100644 --- a/src/code/ServerFactory.cs +++ b/src/code/ServerFactory.cs @@ -1,11 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using Microsoft.PowerShell.PSResourceGet.UtilClasses; using System.Collections; using System.Management.Automation; -using System.Management.Automation.Runspaces; using System.Net; +using Microsoft.PowerShell.PSResourceGet.UtilClasses; namespace Microsoft.PowerShell.PSResourceGet.Cmdlets { diff --git a/test/GroupPolicyEnforcement.Tests.ps1 b/test/GroupPolicyEnforcement.Tests.ps1 new file mode 100644 index 000000000..2353056e2 --- /dev/null +++ b/test/GroupPolicyEnforcement.Tests.ps1 @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft Corporation. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$checkIfWindows = [System.Environment]::OSVersion.Platform -eq 'Win32NT' + +# Add Pester test to check the API for GroupPolicyEnforcement +Describe 'GroupPolicyEnforcement API Tests' -Tags 'CI' { + + It 'IsGroupPolicyEnabled should return the correct policy enforcement status' -Skip:(-not $checkIfWindows) { + $actualStatus = [Microsoft.PowerShell.PSResourceGet.Cmdlets.GroupPolicyRepositoryEnforcement]::IsGroupPolicyEnabled() + $actualStatus | Should -BeFalse + } + + It 'IsGroupPolicyEnabled should return false on non-windows platform' -Skip:$checkIfWindows { + [Microsoft.PowerShell.PSResourceGet.Cmdlets.GroupPolicyRepositoryEnforcement]::IsGroupPolicyEnabled() | Should -BeFalse + } + + It 'GetAllowedRepositoryURIs return null if Group Policy is not enabled' -Skip:(-not $checkIfWindows) { + [Microsoft.PowerShell.PSResourceGet.Cmdlets.GroupPolicyRepositoryEnforcement]::GetAllowedRepositoryURIs() | Should -BeNullOrEmpty + + try { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('EnableGPRegistryHook', $true) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('GPEnabledStatus', $true) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', "https://www.example.com/") + + $allowedReps = [Microsoft.PowerShell.PSResourceGet.Cmdlets.GroupPolicyRepositoryEnforcement]::GetAllowedRepositoryURIs() + $allowedReps.AbsoluteUri | Should -Be @("https://www.example.com/") + } + finally { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('EnableGPRegistryHook', $false) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('GPEnabledStatus', $false) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', $null) + } + } +} + +Describe 'GroupPolicyEnforcement Cmdlet Tests' -Tags 'CI' { + BeforeEach { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('EnableGPRegistryHook', $true) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('GPEnabledStatus', $true) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', "https://www.example.com/") + } + + AfterEach { + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('EnableGPRegistryHook', $false) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('GPEnabledStatus', $false) + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', $null) + } + + It 'Get-PSResourceRepository lists the allowed repository' -Skip:(-not $checkIfWindows) { + try { + Register-PSResourceRepository -Name 'Example' -Uri 'https://www.example.com/' + $psrep = Get-PSResourceRepository -Name 'Example' + $psrep | Should -Not -BeNullOrEmpty + $psrep.IsAllowedByPolicy | Should -BeTrue + } + finally { + Unregister-PSResourceRepository -Name 'Example' + } + } + + It 'Find-PSResource is blocked by policy' -Skip:(-not $checkIfWindows) { + try { + Register-PSResourceRepository -Name 'Example' -Uri 'https://www.example.com/' -ApiVersion 'v3' + { Find-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop } | Should -Throw "Repository 'PSGallery' is not allowed by Group Policy." + + # Allow PSGallery and it should not fail + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', " https://www.powershellgallery.com/api/v2") + { Find-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop } | Should -Not -Throw + } + finally { + Unregister-PSResourceRepository -Name 'Example' + } + } + + It 'Install-PSResource is blocked by policy' -Skip:(-not $checkIfWindows) { + try { + Register-PSResourceRepository -Name 'Example' -Uri 'https://www.example.com/' -ApiVersion 'v3' + { Install-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop } | Should -Throw "Repository 'PSGallery' is not allowed by Group Policy." + + # Allow PSGallery and it should not fail + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', " https://www.powershellgallery.com/api/v2") + { Install-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop -TrustRepository} | Should -Not -Throw + } + finally { + Unregister-PSResourceRepository -Name 'Example' + } + } + + It 'Save-PSResource is blocked by policy' -Skip:(-not $checkIfWindows) { + try { + Register-PSResourceRepository -Name 'Example' -Uri 'https://www.example.com/' -ApiVersion 'v3' + { Save-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop } | Should -Throw "Repository 'PSGallery' is not allowed by Group Policy." + + # Allow PSGallery and it should not fail + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::SetTestHook('AllowedUri', " https://www.powershellgallery.com/api/v2") + { Save-PSResource -Repository PSGallery -Name 'Az.Accounts' -ErrorAction Stop -TrustRepository} | Should -Not -Throw + } + finally { + Unregister-PSResourceRepository -Name 'Example' + } + } +}