diff --git a/.pipelines/templates/mac-package-build.yml b/.pipelines/templates/mac-package-build.yml index 89dab90aa04..bc80c1c0e6d 100644 --- a/.pipelines/templates/mac-package-build.yml +++ b/.pipelines/templates/mac-package-build.yml @@ -1,6 +1,7 @@ parameters: parentJob: '' buildArchitecture: x64 + OfficialBuild: true jobs: - job: package_macOS_${{ parameters.buildArchitecture }} @@ -78,12 +79,13 @@ jobs: continueOnError: true - pwsh: | - $signedDir = "$(Pipeline.Workspace)/CoOrdinatedBuildPipeline/drop_macos_sign_${{ parameters.buildArchitecture }}/Signed-${{ parameters.buildArchitecture }}" - Get-ChildItem $signedDir -Recurse -Include 'pwsh', '*.dylib' | ForEach-Object { - codesign --verify --deep --strict --verbose=4 $_.FullName - if ($LASTEXITCODE -ne 0) { throw "codesign verification failed for $($_.FullName)" } + $signatureParameters = @{ + Path = "$(Pipeline.Workspace)/CoOrdinatedBuildPipeline/drop_macos_sign_${{ parameters.buildArchitecture }}/Signed-${{ parameters.buildArchitecture }}" + OfficialBuild = [System.Convert]::ToBoolean('${{ parameters.OfficialBuild }}') + EntitlementsPath = "$(Build.SourcesDirectory)/assets/macos-entitlements.plist" } - displayName: 'Verify Apple codesign on signed binaries' + & "$(Build.SourcesDirectory)/tools/packaging/Update-MacOSCodeSignature.ps1" @signatureParameters + displayName: 'Apply and verify Apple codesign on signed binaries' - pwsh: | # Add -SkipReleaseChecks as a mitigation to unblock release. diff --git a/.pipelines/templates/mac.yml b/.pipelines/templates/mac.yml index d1efd0c72bc..1af45eb76c2 100644 --- a/.pipelines/templates/mac.yml +++ b/.pipelines/templates/mac.yml @@ -190,21 +190,51 @@ jobs: - pwsh: | $signedDir = "$(ob_outputDirectory)/Signed-$(Runtime)" - $expected = 'Developer ID Application: Microsoft Corporation' + $officialBuild = [System.Convert]::ToBoolean('$(ps_official_build)') + $officialSignature = 'Developer ID Application: Microsoft Corporation' + $codeDirectoryMagic = [byte[]](0xfa, 0xde, 0x0c, 0x02) $missing = @() Get-ChildItem $signedDir -Recurse -Include 'pwsh', '*.dylib' | ForEach-Object { $bytes = [System.IO.File]::ReadAllBytes($_.FullName) $text = [System.Text.Encoding]::Latin1.GetString($bytes) - if (-not $text.Contains($expected)) { + $hasOfficialSignature = $text.Contains($officialSignature) + $hasAdHocSignature = $false + + if (-not $officialBuild -and -not $hasOfficialSignature) { + # The package stage runs codesign --verify; here the Windows signing job + # verifies that unofficial output contains an ad-hoc CodeDirectory. + for ($offset = 0; $offset -le $bytes.Length - 16; $offset++) { + if ($bytes[$offset] -eq $codeDirectoryMagic[0] -and + $bytes[$offset + 1] -eq $codeDirectoryMagic[1] -and + $bytes[$offset + 2] -eq $codeDirectoryMagic[2] -and + $bytes[$offset + 3] -eq $codeDirectoryMagic[3] -and + ($bytes[$offset + 15] -band 0x02) -ne 0) { + $hasAdHocSignature = $true + break + } + } + } + + if (-not $hasOfficialSignature -and ($officialBuild -or -not $hasAdHocSignature)) { $missing += $_.FullName - Write-Host "##[error]Missing '$expected' signature in $($_.FullName)" + $expectedSignature = if ($officialBuild) { + "'$officialSignature'" + } else { + 'a Developer ID signature or ad-hoc CodeDirectory' + } + Write-Host "##[error]Missing $expectedSignature in $($_.FullName)" } else { - Write-Host "OK: $($_.FullName)" + $signatureDescription = if ($hasOfficialSignature) { + 'a Developer ID signature' + } else { + 'an ad-hoc CodeDirectory' + } + Write-Host "OK: $($_.FullName) contains $signatureDescription" } } if ($missing.Count -gt 0) { - throw "ESRP did not apply a Developer ID signature to $($missing.Count) file(s): $($missing -join ', ')" + throw "The expected Apple signature was not found in $($missing.Count) file(s): $($missing -join ', ')" } - displayName: 'Verify Developer ID signature on Mach-O binaries' + displayName: 'Verify Apple signature on Mach-O binaries' - template: /.pipelines/templates/step/finalize.yml@self diff --git a/.pipelines/templates/stages/PowerShell-Packages-Stages.yml b/.pipelines/templates/stages/PowerShell-Packages-Stages.yml index 2c84885e445..3e2dde75797 100644 --- a/.pipelines/templates/stages/PowerShell-Packages-Stages.yml +++ b/.pipelines/templates/stages/PowerShell-Packages-Stages.yml @@ -50,10 +50,12 @@ stages: - template: /.pipelines/templates/mac-package-build.yml@self parameters: buildArchitecture: x64 + OfficialBuild: ${{ parameters.OfficialBuild }} - template: /.pipelines/templates/mac-package-build.yml@self parameters: buildArchitecture: arm64 + OfficialBuild: ${{ parameters.OfficialBuild }} - stage: windows_package_build displayName: 'Win Pkg (unsigned)' diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index 419a4cae95f..63996cebdf0 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Management.Automation.Internal; +using System.Security; using System.Text; using System.Threading; @@ -411,6 +412,23 @@ private T ReadValueFromFile(ConfigScope scope, string key, T defaultValue = d configData = serializer.Deserialize(jsonReader) ?? emptyConfig; } + catch (Exception exc) when ( + scope == ConfigScope.CurrentUser && + (exc is IOException || exc is UnauthorizedAccessException || exc is SecurityException || exc is JsonException)) + { + // The per-user configuration file is unreadable (e.g. OneDrive cloud file provider + // not running, permission denied, ACL denied, or corrupt/malformed JSON). Rather + // than failing pwsh startup, fall back to defaults and emit a warning so the user + // knows the file was skipped. See https://github.com/PowerShell/PowerShell/issues/27370. + // + // We intentionally do NOT apply this fallback to ConfigScope.AllUsers: that file is + // admin-owned and on non-Windows platforms is the only source for security-relevant + // policies (ScriptBlockLogging, Transcription, ConsoleSessionConfiguration, etc.). + // If we cannot prove the admin's intent we must fail closed. + TryWriteConfigWarning(StringUtil.Format(PSConfigurationStrings.CanNotReadConfigurationFile, fileName, exc.Message)); + + configData = emptyConfig; + } catch (Exception exc) { throw PSTraceSource.NewInvalidOperationException(exc, PSConfigurationStrings.CanNotConfigurationFile, args: fileName); @@ -435,12 +453,47 @@ private T ReadValueFromFile(ConfigScope scope, string key, T defaultValue = d if (configData != emptyConfig && configData.TryGetValue(key, StringComparison.OrdinalIgnoreCase, out JToken jToken)) { - return jToken.ToObject(serializer) ?? defaultValue; + try + { + return jToken.ToObject(serializer) ?? defaultValue; + } + catch (JsonException exc) when (scope == ConfigScope.CurrentUser) + { + // The per-user JSON parsed successfully, but the value at can't be + // materialized as T (wrong type, malformed sub-document, etc.). Fall back + // to the caller's default for this setting and emit a warning, mirroring + // the file-level fallback above. AllUsers stays fail-closed for + // security-relevant policies. + // See https://github.com/PowerShell/PowerShell/issues/27370. + TryWriteConfigWarning(StringUtil.Format(PSConfigurationStrings.CanNotReadConfigurationValue, key, fileName, exc.Message)); + return defaultValue; + } } return defaultValue; } + /// + /// Best-effort emission of a warning when the per-user configuration file (or one of its + /// values) can't be read. is invoked very early in pwsh + /// startup -- before any host, runspace, or PowerShell warning stream exists -- so we + /// write directly to . This matches existing startup-time + /// stderr writes in ConsoleHost (e.g. CannotLoadPSReadline). Centralizing this here + /// keeps formatting consistent and gives future hosts a single seam to swap the channel. + /// Failures inside the writer itself are swallowed so logging issues never block startup. + /// + private static void TryWriteConfigWarning(string message) + { + try + { + Console.Error.WriteLine("WARNING: " + message); + } + catch + { + // Best-effort warning; never let logging failures block startup. + } + } + private static FileStream OpenFileStreamWithRetry(string fullPath, FileMode mode, FileAccess access, FileShare share) { const int MaxTries = 5; diff --git a/src/System.Management.Automation/resources/PSConfigurationStrings.resx b/src/System.Management.Automation/resources/PSConfigurationStrings.resx index 26dff6b06fb..0e665322092 100644 --- a/src/System.Management.Automation/resources/PSConfigurationStrings.resx +++ b/src/System.Management.Automation/resources/PSConfigurationStrings.resx @@ -120,4 +120,10 @@ PowerShell has stopped working because of a security issue: Cannot read the configuration file: {0} + + Cannot read the PowerShell configuration file '{0}': {1}. Falling back to default settings. + + + Cannot read setting '{0}' from PowerShell configuration file '{1}': {2}. Falling back to default for this setting. + diff --git a/test/xUnit/csharp/test_PSConfiguration.cs b/test/xUnit/csharp/test_PSConfiguration.cs index de94107fc6b..bdf34e09de5 100644 --- a/test/xUnit/csharp/test_PSConfiguration.cs +++ b/test/xUnit/csharp/test_PSConfiguration.cs @@ -382,6 +382,28 @@ public void SetupConfigFile5() CreateBrokenConfigFile(currentUserConfigFile); } + public void SetupBrokenCurrentUserConfigOnly() + { + CleanupConfigFiles(); + + // Only the per-user config file is broken; the system-wide file is absent. + // Used to verify the #27370 fail-open path for ConfigScope.CurrentUser. + CreateBrokenConfigFile(currentUserConfigFile); + } + + public void SetupTypeMismatchedCurrentUserConfig() + { + CleanupConfigFiles(); + + // Per-user config is syntactically valid JSON, but PowerShellPolicies has the + // wrong nested shape: ScriptExecution should be an object but is a string. + // This triggers JsonSerializationException inside jToken.ToObject() and + // exercises the per-key fail-open path for #27370. + File.WriteAllText( + currentUserConfigFile, + "{ \"PowerShellPolicies\": { \"ScriptExecution\": \"not-an-object\" } }"); + } + private static void CreateBrokenConfigFile(string fileName) { File.WriteAllText(fileName, "[abbra"); @@ -978,14 +1000,105 @@ public void Utils_GetPolicySetting_BothConfigFilesNotExist() fixture.CompareConsoleSessionConfiguration(consoleSessionConfiguration, null); } - [Fact, Priority(11)] - public void PowerShellConfig_GetPowerShellPolicies_BrokenSystemConfig() + [Fact] + [Priority(11)] + public void PowerShellConfig_BrokenSystemConfig_Throws_BrokenCurrentUserConfig_FallsBack() { fixture.SetupConfigFile5(); fixture.ForceReadingFromFile(); + // Broken system-wide config still hard-fails (admin-owned, security-relevant). Assert.Throws(() => PowerShellConfig.Instance.GetPowerShellPolicies(ConfigScope.AllUsers)); - Assert.Throws(() => PowerShellConfig.Instance.GetPowerShellPolicies(ConfigScope.CurrentUser)); + + // Broken per-user config falls back to defaults (#27370). + PowerShellPolicies currentUserPolicies = PowerShellConfig.Instance.GetPowerShellPolicies(ConfigScope.CurrentUser); + Assert.Null(currentUserPolicies); + } + + [Fact] + [Priority(12)] + public void PowerShellConfig_BrokenCurrentUserConfig_FallsBackToDefaults() + { + fixture.SetupBrokenCurrentUserConfigOnly(); + fixture.ForceReadingFromFile(); + + // ExecutionPolicy returns null (caller falls back to GP / registry / Restricted). + string execPolicy = PowerShellConfig.Instance.GetExecutionPolicy(ConfigScope.CurrentUser, "Microsoft.PowerShell"); + Assert.Null(execPolicy); + + // Module path returns null (caller uses built-in defaults). + string modulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser); + Assert.Null(modulePath); + + // Experimental features list is empty. + string[] features = PowerShellConfig.Instance.GetExperimentalFeatures(); + Assert.Empty(features); + } + + [Fact] + [Priority(13)] + public void PowerShellConfig_TypeMismatchInCurrentUserConfig_FallsBackToDefaults() + { + // Verifies the per-key fail-open path: the file parses as JSON, but a value + // can't be materialized as its target type. Without this fallback the + // JsonSerializationException would propagate out of GetPolicySetting and + // crash startup just like #27370. + fixture.SetupTypeMismatchedCurrentUserConfig(); + fixture.ForceReadingFromFile(); + + PowerShellPolicies policies = PowerShellConfig.Instance.GetPowerShellPolicies(ConfigScope.CurrentUser); + Assert.Null(policies); + } + + [Fact] + [Priority(14)] + public void PowerShellConfig_BrokenCurrentUserConfig_EmitsWarningToStderr() + { + // A regression in the warning path would still let startup succeed (this is the + // whole point of the fail-open) -- but the user would silently lose their config + // with no indication. This test pins the user-visible warning so that doesn't + // happen quietly. + fixture.SetupBrokenCurrentUserConfigOnly(); + fixture.ForceReadingFromFile(); + + string stderr = CaptureStderr(() => + PowerShellConfig.Instance.GetExecutionPolicy(ConfigScope.CurrentUser, "Microsoft.PowerShell")); + + Assert.Contains("WARNING:", stderr); + Assert.Contains("Falling back to default settings", stderr); + } + + [Fact] + [Priority(15)] + public void PowerShellConfig_TypeMismatchInCurrentUserConfig_EmitsWarningToStderr() + { + // Pin the user-visible warning for the per-key fail-open path as well. + fixture.SetupTypeMismatchedCurrentUserConfig(); + fixture.ForceReadingFromFile(); + + string stderr = CaptureStderr(() => + PowerShellConfig.Instance.GetPowerShellPolicies(ConfigScope.CurrentUser)); + + Assert.Contains("WARNING:", stderr); + Assert.Contains("PowerShellPolicies", stderr); + Assert.Contains("Falling back to default for this setting", stderr); + } + + private static string CaptureStderr(Action action) + { + var captured = new StringWriter(); + TextWriter originalError = Console.Error; + try + { + Console.SetError(captured); + action(); + } + finally + { + Console.SetError(originalError); + } + + return captured.ToString(); } } } diff --git a/tools/packages.microsoft.com/mapping.json b/tools/packages.microsoft.com/mapping.json index 24cd61b586f..6da204395e3 100644 --- a/tools/packages.microsoft.com/mapping.json +++ b/tools/packages.microsoft.com/mapping.json @@ -60,6 +60,22 @@ "PackageFormat": "PACKAGE_NAME-POWERSHELL_RELEASE-1.cm.x86_64.rpm", "channel": "preview" }, + { + "url": "azurelinux-4.0-beta-microsoft-aarch64", + "distribution": [ + "azl" + ], + "PackageFormat": "PACKAGE_NAME-POWERSHELL_RELEASE-1.cm.aarch64.rpm", + "channel": "stable" + }, + { + "url": "azurelinux-4.0-beta-microsoft-x86_64", + "distribution": [ + "azl" + ], + "PackageFormat": "PACKAGE_NAME-POWERSHELL_RELEASE-1.cm.x86_64.rpm", + "channel": "stable" + }, { "url": "microsoft-debian-stretch-prod", "distribution": [ diff --git a/tools/packaging/Update-MacOSCodeSignature.ps1 b/tools/packaging/Update-MacOSCodeSignature.ps1 new file mode 100644 index 00000000000..193c7a1cbb1 --- /dev/null +++ b/tools/packaging/Update-MacOSCodeSignature.ps1 @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] + [string] $Path, + + [Parameter(Mandatory)] + [bool] $OfficialBuild, + + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] + [string] $EntitlementsPath +) + +$binaries = @( + Get-ChildItem -LiteralPath $Path -Recurse -File | + Where-Object { $_.Name -eq 'pwsh' -or $_.Extension -eq '.dylib' } +) + +if ($binaries.Count -eq 0) { + throw "No Mach-O binaries were found in '$Path'." +} + +foreach ($binary in $binaries) { + & codesign --verify --deep --strict --verbose=4 $binary.FullName + if ($LASTEXITCODE -eq 0) { + continue + } + + if ($OfficialBuild) { + throw "codesign verification failed for '$($binary.FullName)'." + } + + # Nonofficial signing can leave only one slice of a universal binary signed. + # Re-signing on macOS applies an ad-hoc signature across every architecture. + Write-Verbose -Message "Applying an ad-hoc signature to '$($binary.FullName)' for the nonofficial package." -Verbose + $signArguments = @('--sign', '-', '--force', '--options', 'runtime') + if ($binary.Name -eq 'pwsh') { + $signArguments += @('--entitlements', $EntitlementsPath) + } + $signArguments += $binary.FullName + + & codesign @signArguments + if ($LASTEXITCODE -ne 0) { + throw "Ad-hoc codesign failed for '$($binary.FullName)'." + } + + & codesign --verify --deep --strict --verbose=4 $binary.FullName + if ($LASTEXITCODE -ne 0) { + throw "codesign verification failed for '$($binary.FullName)' after applying an ad-hoc signature." + } +} diff --git a/tools/packaging/packaging.psm1 b/tools/packaging/packaging.psm1 index 1825104f0dc..775558711bd 100644 --- a/tools/packaging/packaging.psm1 +++ b/tools/packaging/packaging.psm1 @@ -2106,20 +2106,20 @@ function Get-PackageDependencies "libicu" ) } elseif ($Distribution -eq 'cm') { - # Taken from the list here: - # https://github.com/dotnet/dotnet-docker/blob/d451d6e9427f58c8508f1297c862663a27eb609f/src/runtime-deps/6.0/cbl-mariner1.0/amd64/Dockerfile#L6 + # Azure Linux 3 and 4 use the same .NET 11 runtime dependencies: + # https://github.com/dotnet/dotnet-docker/blob/7962e8c9336303584720344bd644287a1fe77844/src/runtime-deps/11.0/azurelinux4.0/amd64/Dockerfile $Dependencies = @( + "ca-certificates" "glibc" + "icu" "libgcc" - "krb5" "libstdc++" - "zlib" - "icu" "openssl-libs" + "tzdata" ) if($Script:Options.Runtime -like 'fx*') { $Dependencies += @( - "dotnet-runtime-10.0" + "dotnet-runtime-11.0" ) } } elseif ($Distribution -eq 'macOS') {