diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 63f59bbe5c..03a7ed2f71 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -357,6 +357,7 @@ testsettingname TEXTFORMAT TEXTINCLUDE threadpool +TOCTOU tpl TRACELOGGING triaged diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index dde4d3c712..0181f9f11b 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -25,6 +25,7 @@ apfn apicontract apiset appdata +APPEXECLINK appinstallertest applic appname @@ -190,6 +191,7 @@ FONTHASH FORPARSING foundfr fsanitize +Fsctl FULLMUTEX FULLWIDTH fundraiser @@ -281,6 +283,7 @@ Kaido KNOWNFOLDERID kool ktf +LASTEXITCODE LCID learnxinyminutes LEBOM @@ -521,6 +524,7 @@ SETTINGCHANGE SETTINGMAPPING sfs sfsclient +SGNR SHCONTF shellapi SHGDN @@ -621,6 +625,7 @@ VERSIONINFO vns vsconfig vstest +vswhere waitable wal wcex @@ -648,6 +653,7 @@ wingetutil winreg winrtact winstring +wintrust WMI wmmc wnd @@ -663,6 +669,7 @@ wsb wsl wsv wto +WVT wwinmain WZDNCRFJ xcopy diff --git a/doc/windows/package-manager/winget/returnCodes.md b/doc/windows/package-manager/winget/returnCodes.md index 2b59deca7d..b96de35446 100644 --- a/doc/windows/package-manager/winget/returnCodes.md +++ b/doc/windows/package-manager/winget/returnCodes.md @@ -233,3 +233,4 @@ ms.localizationpriority: medium | 0x8A15C110 | -1978285814 | WINGET_CONFIG_ERROR_UNIT_SETTING_CONFIG_ROOT | A unit contains a setting that requires the config root. | | 0x8A15C111 | -1978285813 | WINGET_CONFIG_ERROR_UNIT_IMPORT_MODULE_ADMIN | Loading the module for the configuration unit failed because it requires administrator privileges to run. | | 0x8A15C112 | -1978285812 | WINGET_CONFIG_ERROR_NOT_SUPPORTED_BY_PROCESSOR | Operation is not supported by the configuration processor. | +| 0x8A15C113 | -1978285811 | WINGET_CONFIG_ERROR_PROCESSOR_HASH_MISMATCH | The DSC processor hash provided does not match hash of the target file. | diff --git a/src/AppInstallerCLI.sln b/src/AppInstallerCLI.sln index f2a67fd9e9..3a37b38505 100644 --- a/src/AppInstallerCLI.sln +++ b/src/AppInstallerCLI.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36915.13 d17.14 +VisualStudioVersion = 17.14.36915.13 MinimumVisualStudioVersion = 10.0.40219.1 Project("{C7167F0D-BC9F-4E6E-AFE1-012C56B48DB5}") = "AppInstallerCLIPackage", "AppInstallerCLIPackage\AppInstallerCLIPackage.wapproj", "{6AA3791A-0713-4548-A357-87A323E7AC3A}" ProjectSection(ProjectDependencies) = postProject @@ -40,7 +40,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Project", "Project", "{8D53 Get-VcxprojNugetPackageVersions.ps1 = Get-VcxprojNugetPackageVersions.ps1 ..\README.md = ..\README.md ..\doc\ReleaseNotes.md = ..\doc\ReleaseNotes.md - ..\doc\Settings.md = ..\doc\Settings.md Update-VcxprojNugetPackageVersions.ps1 = Update-VcxprojNugetPackageVersions.ps1 ..\WinGetInProcCom.nuspec = ..\WinGetInProcCom.nuspec ..\WinGetUtil.nuspec = ..\WinGetUtil.nuspec @@ -242,6 +241,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "copilot", "copilot", "{B978 ..\.github\copilot-instructions.md = ..\.github\copilot-instructions.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "doc", "doc", "{3FF6C881-2548-486E-8D70-7555A90030F5}" + ProjectSection(SolutionItems) = preProject + ..\doc\windows\package-manager\winget\returnCodes.md = ..\doc\windows\package-manager\winget\returnCodes.md + ..\doc\Settings.md = ..\doc\Settings.md + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -1114,6 +1119,7 @@ Global {40D7CA7F-EB86-4345-9641-AD27180C559D} = {7C218A3E-9BC8-48FF-B91B-BCACD828C0C9} {5421394F-5619-4E4B-8923-F3FB30D5EFAD} = {7C218A3E-9BC8-48FF-B91B-BCACD828C0C9} {B978E358-D2BE-4FA7-A21A-6661F3744DD7} = {8D53D749-D51C-46F8-A162-9371AAA6C2E7} + {3FF6C881-2548-486E-8D70-7555A90030F5} = {8D53D749-D51C-46F8-A162-9371AAA6C2E7} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B6FDB70C-A751-422C-ACD1-E35419495857} diff --git a/src/AppInstallerCLICore/Commands/DebugCommand.cpp b/src/AppInstallerCLICore/Commands/DebugCommand.cpp index 0df85d0f2a..c9149d562b 100644 --- a/src/AppInstallerCLICore/Commands/DebugCommand.cpp +++ b/src/AppInstallerCLICore/Commands/DebugCommand.cpp @@ -8,6 +8,7 @@ #include #include "AppInstallerDownloader.h" #include "Sixel.h" +#include using namespace AppInstaller::CLI::Execution; @@ -62,6 +63,7 @@ namespace AppInstaller::CLI std::make_unique(FullName()), std::make_unique(FullName()), std::make_unique(FullName()), + std::make_unique(FullName()), }); } @@ -365,6 +367,30 @@ namespace AppInstaller::CLI context.Reporter.Info() << context.Args.GetArg(WINGET_DEBUG_PROGRESS_POST) << std::endl; } } + + std::vector GetSignerCommand::GetArguments() const + { + return { + Argument{ "file", 'f', Args::Type::Manifest, Resource::String::SourceListUpdatedNever, ArgumentType::Positional }, + }; + } + + Resource::LocString GetSignerCommand::ShortDescription() const + { + return Utility::LocIndString("Get signer information"sv); + } + + Resource::LocString GetSignerCommand::LongDescription() const + { + return Utility::LocIndString("Gets the signing information for a given path."sv); + } + + void GetSignerCommand::ExecuteInternal(Execution::Context& context) const + { + std::string subject = Certificates::GetAuthenticodeSubject(context.Args.GetArg(Args::Type::Manifest)); + + context.Reporter.Info() << "Subject: " << subject << std::endl; + } } #endif diff --git a/src/AppInstallerCLICore/Commands/DebugCommand.h b/src/AppInstallerCLICore/Commands/DebugCommand.h index 5e37520b2d..60fc6eaec1 100644 --- a/src/AppInstallerCLICore/Commands/DebugCommand.h +++ b/src/AppInstallerCLICore/Commands/DebugCommand.h @@ -85,6 +85,20 @@ namespace AppInstaller::CLI protected: void ExecuteInternal(Execution::Context& context) const override; }; + + // Invokes signing information collection for a path. + struct GetSignerCommand final : public Command + { + GetSignerCommand(std::string_view parent) : Command("get-signer", {}, parent) {} + + std::vector GetArguments() const override; + + Resource::LocString ShortDescription() const override; + Resource::LocString LongDescription() const override; + + protected: + void ExecuteInternal(Execution::Context& context) const override; + }; } #endif diff --git a/src/AppInstallerCLICore/ConfigurationDynamicRuntimeFactory.cpp b/src/AppInstallerCLICore/ConfigurationDynamicRuntimeFactory.cpp index 4aade97ef7..8b7fd11726 100644 --- a/src/AppInstallerCLICore/ConfigurationDynamicRuntimeFactory.cpp +++ b/src/AppInstallerCLICore/ConfigurationDynamicRuntimeFactory.cpp @@ -341,10 +341,12 @@ namespace AppInstaller::CLI::ConfigurationRemoting { winrt::hstring dscExecutablePathPropertyName = ToHString(PropertyName::DscExecutablePath); std::optional dscExecutablePath = m_dynamicFactory->GetFactoryMapValue(dscExecutablePathPropertyName); + bool usingFoundPath = false; if (!dscExecutablePath) { dscExecutablePath = m_dynamicFactory->Lookup(ToHString(PropertyName::FoundDscExecutablePath)); + usingFoundPath = true; } if (dscExecutablePath->empty()) @@ -355,6 +357,37 @@ namespace AppInstaller::CLI::ConfigurationRemoting } json["processorPath"] = Utility::ConvertToUTF8(dscExecutablePath.value()); + + if (usingFoundPath) + { + // FoundDscExecutablePathHash/IsAlias are computed on the remote side alongside FoundDscExecutablePath. + winrt::hstring pathHash = m_dynamicFactory->Lookup(ToHString(PropertyName::FoundDscExecutablePathHash)); + if (!pathHash.empty()) + { + json["processorPathHash"] = Utility::ConvertToUTF8(pathHash); + } + + winrt::hstring pathIsAlias = m_dynamicFactory->Lookup(ToHString(PropertyName::FoundDscExecutablePathIsAlias)); + if (!pathIsAlias.empty()) + { + json["processorPathIsAlias"] = (pathIsAlias == L"True"); + } + } + else + { + // DscExecutablePathHash/IsAlias are set locally via the audit block. + auto pathHash = m_dynamicFactory->GetFactoryMapValue(ToHString(PropertyName::DscExecutablePathHash)); + if (pathHash) + { + json["processorPathHash"] = Utility::ConvertToUTF8(pathHash.value()); + } + + auto pathIsAlias = m_dynamicFactory->GetFactoryMapValue(ToHString(PropertyName::DscExecutablePathIsAlias)); + if (pathIsAlias) + { + json["processorPathIsAlias"] = (pathIsAlias.value() == L"true"); + } + } } Json::StreamWriterBuilder writerBuilder; diff --git a/src/AppInstallerCLICore/ConfigurationSetProcessorFactoryRemoting.cpp b/src/AppInstallerCLICore/ConfigurationSetProcessorFactoryRemoting.cpp index 746743b2c4..cc7e570257 100644 --- a/src/AppInstallerCLICore/ConfigurationSetProcessorFactoryRemoting.cpp +++ b/src/AppInstallerCLICore/ConfigurationSetProcessorFactoryRemoting.cpp @@ -381,8 +381,11 @@ namespace AppInstaller::CLI::ConfigurationRemoting case PropertyName::FoundDscExecutablePath: return L"FoundDscExecutablePath"; case PropertyName::DiagnosticTraceEnabled: return L"DiagnosticTraceEnabled"; case PropertyName::FindDscStateMachine: return L"FindDscStateMachine"; + case PropertyName::DscExecutablePathHash: return L"DscExecutablePathHash"; + case PropertyName::DscExecutablePathIsAlias: return L"DscExecutablePathIsAlias"; + case PropertyName::FoundDscExecutablePathHash: return L"FoundDscExecutablePathHash"; + case PropertyName::FoundDscExecutablePathIsAlias: return L"FoundDscExecutablePathIsAlias"; } - THROW_HR(E_UNEXPECTED); } } diff --git a/src/AppInstallerCLICore/Public/ConfigurationSetProcessorFactoryRemoting.h b/src/AppInstallerCLICore/Public/ConfigurationSetProcessorFactoryRemoting.h index c690ed589e..ed3ec7a34a 100644 --- a/src/AppInstallerCLICore/Public/ConfigurationSetProcessorFactoryRemoting.h +++ b/src/AppInstallerCLICore/Public/ConfigurationSetProcessorFactoryRemoting.h @@ -44,6 +44,22 @@ namespace AppInstaller::CLI::ConfigurationRemoting // We must respond to the value it returns to properly transition states. // Read only. FindDscStateMachine, + // The SHA256 hash of the DscExecutablePath content: file bytes for regular executables, or + // raw reparse data buffer bytes for app execution aliases. + // Must be set alongside DscExecutablePath when a custom processor path is used. + // Read / Write + DscExecutablePathHash, + // Whether DscExecutablePath refers to an app execution alias + // (e.g., a path under %LOCALAPPDATA%\Microsoft\WindowsApps). + // Read / Write + DscExecutablePathIsAlias, + // The SHA256 hash of the FoundDscExecutablePath content. + // Computed alongside FoundDscExecutablePath; available after FoundDscExecutablePath is queried. + // Read only. + FoundDscExecutablePathHash, + // Whether FoundDscExecutablePath refers to an app execution alias. + // Read only. + FoundDscExecutablePathIsAlias, }; // Gets the string for a property name. diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index 11b4b8d1a7..fffbb608bf 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -102,6 +102,13 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(ConfigurationNoTestRun); WINGET_DEFINE_RESOURCE_STRINGID(ConfigurationNotInDesiredState); WINGET_DEFINE_RESOURCE_STRINGID(ConfigurationProcessorPath); + WINGET_DEFINE_RESOURCE_STRINGID(ConfigurationProcessorPathAudit); + WINGET_DEFINE_RESOURCE_STRINGID(ConfigurationProcessorPathAuditHash); + WINGET_DEFINE_RESOURCE_STRINGID(ConfigurationProcessorPathAuditIsAlias); + WINGET_DEFINE_RESOURCE_STRINGID(ConfigurationProcessorPathAuditPath); + WINGET_DEFINE_RESOURCE_STRINGID(ConfigurationProcessorPathAuditSignature); + WINGET_DEFINE_RESOURCE_STRINGID(ConfigurationProcessorPathAuditUnsigned); + WINGET_DEFINE_RESOURCE_STRINGID(ConfigurationProcessorPathHashVerificationFailed); WINGET_DEFINE_RESOURCE_STRINGID(ConfigurationReadingConfigFile); WINGET_DEFINE_RESOURCE_STRINGID(ConfigurationSetStateCompleted); WINGET_DEFINE_RESOURCE_STRINGID(ConfigurationSetStateInProgress); diff --git a/src/AppInstallerCLICore/Workflows/ConfigurationFlow.cpp b/src/AppInstallerCLICore/Workflows/ConfigurationFlow.cpp index 224a3a31bf..ceae8238c8 100644 --- a/src/AppInstallerCLICore/Workflows/ConfigurationFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/ConfigurationFlow.cpp @@ -14,11 +14,13 @@ #include #include #include +#include #include #include #include #include #include +#include using namespace AppInstaller::CLI::Execution; using namespace winrt::Microsoft::Management::Configuration; @@ -129,6 +131,90 @@ namespace AppInstaller::CLI::Workflow return Logging::Level::Info; } + // Audit information gathered about a custom processor path. + struct ProcessorPathInfo + { + bool IsAlias = false; + std::string HashString; + std::string SigningSubject; + }; + + // Collects audit information for the given processor path. + // Throws on access failure so the caller is prevented from using an unverifiable path. + ProcessorPathInfo CollectProcessorPathInfo(const std::filesystem::path& processorPath) + { + ProcessorPathInfo result; + const std::wstring& pathStr = processorPath.wstring(); + + // Attempt to open the file for reading without FILE_FLAG_OPEN_REPARSE_POINT. + // App execution aliases (IO_REPARSE_TAG_APPEXECLINK) cannot be opened for reading + // this way and will fail with ERROR_CANT_ACCESS_FILE. + wil::unique_hfile fileHandle{ CreateFileW( + pathStr.c_str(), + GENERIC_READ, + FILE_SHARE_READ, + nullptr, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + nullptr) }; + + if (!fileHandle) + { + DWORD lastError = GetLastError(); + THROW_WIN32_IF(lastError, lastError != ERROR_CANT_ACCESS_FILE); + + // Re-open with FILE_FLAG_OPEN_REPARSE_POINT to inspect the reparse data. + wil::unique_hfile reparseHandle{ CreateFileW( + pathStr.c_str(), + 0, + FILE_SHARE_READ, + nullptr, + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + nullptr) }; + THROW_LAST_ERROR_IF(!reparseHandle); + + // Retrieve the reparse point data. + std::vector reparseBuffer(MAXIMUM_REPARSE_DATA_BUFFER_SIZE); + DWORD bytesReturned = 0; + THROW_LAST_ERROR_IF(!DeviceIoControl( + reparseHandle.get(), + FSCTL_GET_REPARSE_POINT, + nullptr, + 0, + reparseBuffer.data(), + static_cast(reparseBuffer.size()), + &bytesReturned, + nullptr)); + + // Confirm it is specifically an app execution alias, not another reparse type. + THROW_HR_IF(E_INVALIDARG, bytesReturned < sizeof(DWORD)); + DWORD reparseTag = *reinterpret_cast(reparseBuffer.data()); + THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_REPARSE_TAG_MISMATCH), reparseTag != IO_REPARSE_TAG_APPEXECLINK); + + result.IsAlias = true; + result.HashString = Utility::SHA256::ConvertToString( + Utility::SHA256::ComputeHash(reparseBuffer.data(), bytesReturned)); + } + else + { + // Regular file: hash the file bytes using the shared SHA256 utility. + result.HashString = Utility::SHA256::ConvertToString(Utility::SHA256::ComputeHashFromHandle(fileHandle.get())); + + // Attempt to extract signing info (handles both embedded and catalog signatures). + try + { + result.SigningSubject = Certificates::GetAuthenticodeSubject(processorPath); + } + catch (...) + { + AICLI_LOG(Config, Warning, << "Failed to retrieve signing info for processor path"); + } + } + + return result; + } + DiagnosticLevel ConvertLevel(Logging::Level level) { switch (level) @@ -228,7 +314,39 @@ namespace AppInstaller::CLI::Workflow if (context.Args.Contains(Args::Type::ConfigurationProcessorPath)) { - factoryMap.Insert(ConfigurationRemoting::ToHString(ConfigurationRemoting::PropertyName::DscExecutablePath), Utility::ConvertToUTF16(context.Args.GetArg(Args::Type::ConfigurationProcessorPath))); + progressScope.reset(); + + const auto& processorPathArg = context.Args.GetArg(Args::Type::ConfigurationProcessorPath); + std::filesystem::path processorPath{ Utility::ConvertToUTF16(processorPathArg) }; + + // Collect audit information; throws if the path cannot be opened or hashed. + auto pathInfo = anon::CollectProcessorPathInfo(processorPath); + + // Output audit information to the user as a warning since this is a non-default path. + context.Reporter.Info() << Resource::String::ConfigurationProcessorPathAudit << std::endl; + context.Reporter.Info() << Resource::String::ConfigurationProcessorPathAuditPath(Utility::LocIndString{ processorPathArg }) << std::endl; + context.Reporter.Info() << Resource::String::ConfigurationProcessorPathAuditHash(Utility::LocIndString{ pathInfo.HashString }) << std::endl; + if (pathInfo.IsAlias) + { + context.Reporter.Info() << Resource::String::ConfigurationProcessorPathAuditIsAlias << std::endl; + } + else if (!pathInfo.SigningSubject.empty()) + { + context.Reporter.Info() << Resource::String::ConfigurationProcessorPathAuditSignature(Utility::LocIndString{ pathInfo.SigningSubject }) << std::endl; + } + else + { + context.Reporter.Info() << Resource::String::ConfigurationProcessorPathAuditUnsigned << std::endl; + } + + AICLI_LOG(Config, Info, << "Processor path audit - Path: " << processorPathArg << ", Hash: " << pathInfo.HashString << ", IsAlias: " << pathInfo.IsAlias); + + factoryMap.Insert(ConfigurationRemoting::ToHString(ConfigurationRemoting::PropertyName::DscExecutablePath), processorPath.wstring()); + factoryMap.Insert(ConfigurationRemoting::ToHString(ConfigurationRemoting::PropertyName::DscExecutablePathHash), Utility::ConvertToUTF16(pathInfo.HashString)); + factoryMap.Insert(ConfigurationRemoting::ToHString(ConfigurationRemoting::PropertyName::DscExecutablePathIsAlias), pathInfo.IsAlias ? L"true" : L"false"); + + progressScope = context.Reporter.BeginAsyncProgress(true); + progressScope->Callback().SetProgressMessage(Resource::String::ConfigurationInitializing()); } else { diff --git a/src/AppInstallerCLIE2ETests/ConfigureProcessorPathCommand.cs b/src/AppInstallerCLIE2ETests/ConfigureProcessorPathCommand.cs new file mode 100644 index 0000000000..69cd37bede --- /dev/null +++ b/src/AppInstallerCLIE2ETests/ConfigureProcessorPathCommand.cs @@ -0,0 +1,139 @@ +// ----------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// +// ----------------------------------------------------------------------------- + +namespace AppInstallerCLIE2ETests +{ + using System; + using System.IO; + using System.Text.RegularExpressions; + using AppInstallerCLIE2ETests.Helpers; + using NUnit.Framework; + + /// + /// E2E tests for the `--processor-path` argument of the configure command. + /// These tests verify that audit information (path, hash, signing) is shown + /// when a custom DSC processor path is provided. + /// + public class ConfigureProcessorPathCommand + { + private const string Command = "configure"; + private const string LocalProcessorPathAdminSetting = "LocalProcessorPath"; + + // DSC app execution alias paths (stable then preview). + private static readonly string[] DscAliasCandidates = new[] + { + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + @"Microsoft\WindowsApps\Microsoft.DesiredStateConfiguration_8wekyb3d8bbwe\dsc.exe"), + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + @"Microsoft\WindowsApps\Microsoft.DesiredStateConfiguration-Preview_8wekyb3d8bbwe\dsc.exe"), + }; + + /// + /// Enables the LocalProcessorPath admin setting before each test. + /// + [SetUp] + public void SetUp() + { + WinGetSettingsHelper.EnableAdminSetting(LocalProcessorPathAdminSetting); + } + + /// + /// Disables the LocalProcessorPath admin setting after each test. + /// + [TearDown] + public void TearDown() + { + WinGetSettingsHelper.DisableAdminSetting(LocalProcessorPathAdminSetting); + } + + /// + /// Verifies that when the DSC executable is provided as the processor path, + /// the audit header, file path, SHA256 hash, and app-execution-alias marker + /// all appear in the output. + /// + [Test] + public void ProcessorPath_AuditOutput_ShowsPathAndHash() + { + string processorPath = FindDscExePath(); + if (processorPath == null) + { + Assert.Ignore("DSC is not installed; skipping processor path audit test."); + return; + } + + string configFile = TestCommon.GetTestDataFile(@"Configuration\ShowDetails_DSCv3.yml"); + + var result = TestCommon.RunAICLICommand( + Command, + $"--accept-configuration-agreements --processor-path \"{processorPath}\" \"{configFile}\" --no-progress"); + + // Audit header must appear regardless of whether the configure succeeds or fails, + // because audit output happens during factory setup before DSC is invoked. + Assert.True(result.StdOut.Contains("Custom DSC processor path in use:"), $"Expected audit header in output. StdOut: {result.StdOut}"); + Assert.True(result.StdOut.Contains($" Path: {processorPath}"), $"Expected path in audit output. StdOut: {result.StdOut}"); + Assert.True(result.StdOut.Contains(" Hash: "), $"Expected hash in audit output. StdOut: {result.StdOut}"); + + // dsc.exe is an app execution alias; the alias marker must be present. + Assert.True(result.StdOut.Contains("Type: App execution alias"), $"Expected app execution alias marker. StdOut: {result.StdOut}"); + } + + /// + /// Verifies that the hash in the audit output is a 64-character lowercase hex string + /// (SHA256 of the file or reparse buffer contents). + /// + [Test] + public void ProcessorPath_AuditOutput_HashIsValidSHA256() + { + string processorPath = FindDscExePath(); + if (processorPath == null) + { + Assert.Ignore("DSC is not installed; skipping processor path hash format test."); + return; + } + + string configFile = TestCommon.GetTestDataFile(@"Configuration\ShowDetails_DSCv3.yml"); + + var result = TestCommon.RunAICLICommand( + Command, + $"--accept-configuration-agreements --processor-path \"{processorPath}\" \"{configFile}\" --no-progress"); + + Assert.True(result.StdOut.Contains(" Hash: "), $"Expected hash in audit output. StdOut: {result.StdOut}"); + + // Extract the hash value from " Hash: " + int hashLabelIndex = result.StdOut.IndexOf(" Hash: "); + Assert.That(hashLabelIndex, Is.GreaterThanOrEqualTo(0)); + + int hashStart = hashLabelIndex + " Hash: ".Length; + int hashEnd = result.StdOut.IndexOfAny(new[] { '\r', '\n' }, hashStart); + string hashValue = hashEnd > hashStart + ? result.StdOut.Substring(hashStart, hashEnd - hashStart).Trim() + : result.StdOut.Substring(hashStart).Trim(); + + Assert.AreEqual(64, hashValue.Length, $"Expected 64-character SHA256 hash, got: '{hashValue}'"); + Assert.True( + Regex.IsMatch(hashValue, "^[0-9a-f]{64}$"), + $"Expected lowercase hex hash, got: '{hashValue}'"); + } + + /// + /// Gets the path to dsc.exe (app execution alias), or null if not installed. + /// + private static string FindDscExePath() + { + foreach (string candidate in DscAliasCandidates) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } + } +} diff --git a/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs b/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs index aa3420782e..95bf7f99b7 100644 --- a/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs +++ b/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs @@ -88,6 +88,24 @@ public static void InitializeWingetSettings() SetWingetSettings(JsonConvert.SerializeObject(settingsJson, Formatting.Indented)); } + /// + /// Enables an admin setting. + /// + /// The admin setting name. + public static void EnableAdminSetting(string settingName) + { + TestCommon.RunAICLICommand("settings", $"--enable {settingName}"); + } + + /// + /// Disables an admin setting. + /// + /// The admin setting name. + public static void DisableAdminSetting(string settingName) + { + TestCommon.RunAICLICommand("settings", $"--disable {settingName}"); + } + /// /// Configure experimental features. /// diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index 64fc8df0fb..4a79590158 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -3252,6 +3252,34 @@ Please specify one of them using the --source option to proceed. Specify the path to the configuration processor + + Custom DSC processor path in use: + Displayed as a warning header when a custom --processor-path argument is provided. + + + Hash: {0} + {Locked="{0}"} {0} is the SHA256 hash hex string of the processor executable or its reparse data. + + + Type: App execution alias + Indicates the processor path is an app execution alias (MSIX package link). + + + Path: {0} + {Locked="{0}"} {0} is the file system path to the custom DSC processor executable. + + + Signed By: {0} + {Locked="{0}"} {0} is the Authenticode signing subject name of the executable. + + + Signed By: (unsigned) + Indicates the custom processor executable does not have an Authenticode signature. + + + The integrity check failed for the custom DSC processor path. The path may have been modified. + Error when the server-side hash of the processor path does not match the client-computed hash. + DSC v3 resource commands DSC stands for "Desired State Configuration". It should already have a locked translation. @@ -3579,4 +3607,7 @@ An unlocalized JSON fragment will follow on another line. Results have been filtered to the highest matched source priority. A warning message to indicate to the user that the results of a search have been filtered by choosing only those matching the highest source priority amongst the results. - + + The DSC processor hash provided does not match hash of the target file. + + \ No newline at end of file diff --git a/src/AppInstallerSharedLib/Certificates.cpp b/src/AppInstallerSharedLib/Certificates.cpp index 96bff881b1..77cf52a1d7 100644 --- a/src/AppInstallerSharedLib/Certificates.cpp +++ b/src/AppInstallerSharedLib/Certificates.cpp @@ -12,6 +12,59 @@ namespace AppInstaller::Certificates { namespace { + // WTHelperProvDataFromStateData and WTHelperGetProvSignerFromChain are not in the wintrust import lib; + // resolve them at runtime via GetProcAddress. + using WTHelperProvDataFromStateDataPtr = decltype(&WTHelperProvDataFromStateData); + using WTHelperGetProvSignerFromChainPtr = decltype(&WTHelperGetProvSignerFromChain); + + struct WinTrustHelpers + { + WinTrustHelpers() + { + m_module.reset(LoadLibraryExW(L"wintrust.dll", NULL, LOAD_LIBRARY_SEARCH_SYSTEM32)); + if (!m_module) + { + AICLI_LOG(Core, Warning, << "Could not load wintrust.dll"); + return; + } + + m_provDataFromStateData = reinterpret_cast( + GetProcAddress(m_module.get(), "WTHelperProvDataFromStateData")); + if (!m_provDataFromStateData) + { + AICLI_LOG(Core, Warning, << "Could not get proc address of WTHelperProvDataFromStateData"); + } + + m_provSignerFromChain = reinterpret_cast( + GetProcAddress(m_module.get(), "WTHelperGetProvSignerFromChain")); + if (!m_provSignerFromChain) + { + AICLI_LOG(Core, Warning, << "Could not get proc address of WTHelperGetProvSignerFromChain"); + } + } + + CRYPT_PROVIDER_DATA* ProvDataFromStateData(HANDLE stateData) const + { + return m_provDataFromStateData ? m_provDataFromStateData(stateData) : nullptr; + } + + CRYPT_PROVIDER_SGNR* ProvSignerFromChain(CRYPT_PROVIDER_DATA* provData, DWORD signerIdx, BOOL counterSigner, DWORD counterSignerIdx) const + { + return m_provSignerFromChain ? m_provSignerFromChain(provData, signerIdx, counterSigner, counterSignerIdx) : nullptr; + } + + private: + wil::unique_hmodule m_module; + WTHelperProvDataFromStateDataPtr m_provDataFromStateData = nullptr; + WTHelperGetProvSignerFromChainPtr m_provSignerFromChain = nullptr; + }; + + const WinTrustHelpers& GetWinTrustHelpers() + { + static WinTrustHelpers s_helpers; + return s_helpers; + } + std::string GetNameString(PCCERT_CONTEXT certContext, DWORD nameType, bool forIssuer, void* typeParam = nullptr) { if (!certContext) @@ -736,4 +789,56 @@ namespace AppInstaller::Certificates return result; } + + std::string GetAuthenticodeSubject(const std::filesystem::path& filePath) + { + const std::wstring& pathStr = filePath.wstring(); + + WINTRUST_FILE_INFO fileInfo = {}; + fileInfo.cbStruct = sizeof(fileInfo); + fileInfo.pcwszFilePath = pathStr.c_str(); + + WINTRUST_DATA trustData = {}; + trustData.cbStruct = sizeof(trustData); + trustData.dwUIChoice = WTD_UI_NONE; + trustData.fdwRevocationChecks = WTD_REVOKE_NONE; + trustData.dwUnionChoice = WTD_CHOICE_FILE; + trustData.dwStateAction = WTD_STATEACTION_VERIFY; + trustData.dwProvFlags = WTD_CACHE_ONLY_URL_RETRIEVAL; + trustData.pFile = &fileInfo; + + GUID actionId = WINTRUST_ACTION_GENERIC_VERIFY_V2; + LONG trustResult = WinVerifyTrust(reinterpret_cast(INVALID_HANDLE_VALUE), &actionId, &trustData); + + auto cleanup = wil::scope_exit([&]() + { + trustData.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust(reinterpret_cast(INVALID_HANDLE_VALUE), &actionId, &trustData); + }); + + if (trustResult != 0) + { + return {}; + } + + CRYPT_PROVIDER_DATA* provData = GetWinTrustHelpers().ProvDataFromStateData(trustData.hWVTStateData); + if (!provData) + { + return {}; + } + + CRYPT_PROVIDER_SGNR* signer = GetWinTrustHelpers().ProvSignerFromChain(provData, 0, FALSE, 0); + if (!signer || signer->csCertChain == 0 || !signer->pasCertChain) + { + return {}; + } + + PCCERT_CONTEXT certContext = signer->pasCertChain[0].pCert; + if (!certContext) + { + return {}; + } + + return GetNameString(certContext, CERT_NAME_SIMPLE_DISPLAY_TYPE, false); + } } diff --git a/src/AppInstallerSharedLib/Errors.cpp b/src/AppInstallerSharedLib/Errors.cpp index fd83d8fece..0e9b811e87 100644 --- a/src/AppInstallerSharedLib/Errors.cpp +++ b/src/AppInstallerSharedLib/Errors.cpp @@ -296,6 +296,7 @@ namespace AppInstaller WINGET_HRESULT_INFO(WINGET_CONFIG_ERROR_UNIT_SETTING_CONFIG_ROOT, "A unit contains a setting that requires the config root."), WINGET_HRESULT_INFO(WINGET_CONFIG_ERROR_UNIT_IMPORT_MODULE_ADMIN, "Loading the module for the configuration unit failed because it requires administrator privileges to run."), WINGET_HRESULT_INFO(WINGET_CONFIG_ERROR_NOT_SUPPORTED_BY_PROCESSOR, "Operation is not supported by the configuration processor."), + WINGET_HRESULT_INFO(WINGET_CONFIG_ERROR_PROCESSOR_HASH_MISMATCH, "The DSC processor hash provided does not match hash of the target file."), // Errors without the error bit set WINGET_HRESULT_INFO(WINGET_INSTALLED_STATUS_INSTALL_LOCATION_NOT_APPLICABLE, "The install location is not applicable."), diff --git a/src/AppInstallerSharedLib/Public/AppInstallerErrors.h b/src/AppInstallerSharedLib/Public/AppInstallerErrors.h index a8bfdf40da..6510e363ad 100644 --- a/src/AppInstallerSharedLib/Public/AppInstallerErrors.h +++ b/src/AppInstallerSharedLib/Public/AppInstallerErrors.h @@ -234,6 +234,7 @@ #define WINGET_CONFIG_ERROR_UNIT_SETTING_CONFIG_ROOT ((HRESULT)0x8A15C110) #define WINGET_CONFIG_ERROR_UNIT_IMPORT_MODULE_ADMIN ((HRESULT)0x8A15C111) #define WINGET_CONFIG_ERROR_NOT_SUPPORTED_BY_PROCESSOR ((HRESULT)0x8A15C112) +#define WINGET_CONFIG_ERROR_PROCESSOR_HASH_MISMATCH ((HRESULT)0x8A15C113) namespace AppInstaller { diff --git a/src/AppInstallerSharedLib/Public/AppInstallerSHA256.h b/src/AppInstallerSharedLib/Public/AppInstallerSHA256.h index 7e24ce075a..2272683b8b 100644 --- a/src/AppInstallerSharedLib/Public/AppInstallerSHA256.h +++ b/src/AppInstallerSharedLib/Public/AppInstallerSHA256.h @@ -68,6 +68,10 @@ namespace AppInstaller::Utility { // Computes the hash from a given file path. static HashBuffer ComputeHashFromFile(const std::filesystem::path& path); + // Computes the hash from an open file HANDLE by reading sequentially from the current position. + // The caller retains ownership of the handle. + static HashBuffer ComputeHashFromHandle(HANDLE fileHandle); + static std::string ConvertToString(const HashBuffer& hashBuffer); static std::wstring ConvertToWideString(const HashBuffer& hashBuffer); diff --git a/src/AppInstallerSharedLib/Public/winget/Certificates.h b/src/AppInstallerSharedLib/Public/winget/Certificates.h index e455b2c7d5..fb7f973bcc 100644 --- a/src/AppInstallerSharedLib/Public/winget/Certificates.h +++ b/src/AppInstallerSharedLib/Public/winget/Certificates.h @@ -6,13 +6,21 @@ #include #include +#include #include #include +#include #include namespace AppInstaller::Certificates { + // Returns the Authenticode signing subject name (e.g., "Microsoft Corporation") for the + // given file. Only returns a value when WinVerifyTrust confirms the signature is valid and + // trusted; extracts the subject from the embedded PE Authenticode signature via crypt32. + // Returns an empty string if the file is unsigned, untrusted, or on any error. + std::string GetAuthenticodeSubject(const std::filesystem::path& filePath); + // Defines the types of certificate pinning to perform. enum class PinningVerificationType : uint32_t { diff --git a/src/AppInstallerSharedLib/SHA256.cpp b/src/AppInstallerSharedLib/SHA256.cpp index e46508ea3c..ce71e0f77b 100644 --- a/src/AppInstallerSharedLib/SHA256.cpp +++ b/src/AppInstallerSharedLib/SHA256.cpp @@ -175,6 +175,21 @@ namespace AppInstaller::Utility { return targetFileHash; } + SHA256::HashBuffer SHA256::ComputeHashFromHandle(HANDLE fileHandle) + { + constexpr DWORD bufferSize = 1024 * 1024; + auto buffer = std::make_unique(bufferSize); + SHA256 hasher; + DWORD bytesRead = 0; + + while (ReadFile(fileHandle, buffer.get(), bufferSize, &bytesRead, nullptr) && bytesRead > 0) + { + hasher.Add(buffer.get(), bytesRead); + } + + return hasher.Get(); + } + void SHA256::SHA256ContextDeleter::operator()(SHA256Context* context) { delete context; diff --git a/src/AppInstallerSharedLib/pch.h b/src/AppInstallerSharedLib/pch.h index 22186ce6ba..2a7366d8aa 100644 --- a/src/AppInstallerSharedLib/pch.h +++ b/src/AppInstallerSharedLib/pch.h @@ -10,7 +10,9 @@ #include #include #include -#include +#include +#include +#include #define YAML_DECLARE_STATIC #include diff --git a/src/ConfigurationRemotingServer/Program.cs b/src/ConfigurationRemotingServer/Program.cs index 68d7170f87..19c65c8e49 100644 --- a/src/ConfigurationRemotingServer/Program.cs +++ b/src/ConfigurationRemotingServer/Program.cs @@ -178,6 +178,12 @@ private class LimitationSetMetadata [JsonPropertyName("processorPath")] public string? ProcessorPath { get; set; } = null; + + [JsonPropertyName("processorPathHash")] + public string? ProcessorPathHash { get; set; } = null; + + [JsonPropertyName("processorPathIsAlias")] + public bool? ProcessorPathIsAlias { get; set; } = null; } private static IConfigurationSetProcessorFactory CreateFactory(string processorEngine, ConfigurationSet? limitationSet, LimitationSetMetadata? limitationSetMetadata) @@ -242,6 +248,14 @@ private static IConfigurationSetProcessorFactory CreateDSCv3Factory(Configuratio { if (limitationSetMetadata.ProcessorPath != null) { + if (limitationSetMetadata.ProcessorPathHash == null) + { + throw new InvalidOperationException("A custom processor path was provided without a hash for integrity verification."); + } + + // Set hash and alias before path so they are available when the path is verified on first use. + factory.DscExecutablePathHash = limitationSetMetadata.ProcessorPathHash; + factory.DscExecutablePathIsAlias = limitationSetMetadata.ProcessorPathIsAlias ?? false; factory.DscExecutablePath = limitationSetMetadata.ProcessorPath; } else diff --git a/src/Microsoft.Management.Configuration.OutOfProc/Run-ConfigurationOOPTests.ps1 b/src/Microsoft.Management.Configuration.OutOfProc/Run-ConfigurationOOPTests.ps1 new file mode 100644 index 0000000000..12f8ec642f --- /dev/null +++ b/src/Microsoft.Management.Configuration.OutOfProc/Run-ConfigurationOOPTests.ps1 @@ -0,0 +1,69 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +[CmdletBinding()] +param( + [string]$BuildOutputPath, + + [string]$PackageLayoutPath, + + [string]$TestCaseFilter, + + [string]$Platform = "x64", + + [string]$Configuration = "Debug" +) + +# Derive BuildOutputPath from the repo root if not specified +if ([System.String]::IsNullOrEmpty($BuildOutputPath)) +{ + $Local:repoRoot = git -C $PSScriptRoot rev-parse --show-toplevel + $BuildOutputPath = Join-Path $Local:repoRoot "src" $Platform $Configuration +} + +# Step 1: Prepare the test output directory +$Local:prepareScript = Join-Path $PSScriptRoot "Prepare-ConfigurationOOPTests.ps1" +& $Local:prepareScript -BuildOutputPath $BuildOutputPath -PackageLayoutPath $PackageLayoutPath + +if (-not $?) +{ + Write-Error "Preparation script failed" + exit 1 +} + +# Step 2: Find vstest.console.exe via vswhere +$Local:vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (-not (Test-Path $Local:vswhere)) +{ + Write-Error "vswhere.exe not found at '$Local:vswhere'. Is Visual Studio installed?" + exit 1 +} + +$Local:vsInstallPath = & $Local:vswhere -latest -products * -requires Microsoft.VisualStudio.PackageGroup.TestTools.Core -property installationPath +if ([System.String]::IsNullOrEmpty($Local:vsInstallPath)) +{ + Write-Error "Could not find a Visual Studio installation with test tools via vswhere." + exit 1 +} + +$Local:vstestPath = Join-Path $Local:vsInstallPath "Common7\IDE\Extensions\TestPlatform\vstest.console.exe" +if (-not (Test-Path $Local:vstestPath)) +{ + Write-Error "vstest.console.exe not found at '$Local:vstestPath'." + exit 1 +} + +# Step 3: Run the tests with vstest +$Local:testDll = Join-Path $BuildOutputPath "Microsoft.Management.Configuration.UnitTests\net8.0-windows10.0.26100.0\Microsoft.Management.Configuration.UnitTests.dll" + +$Local:vstestArgs = @( + $Local:testDll, + "--logger:console;verbosity=detailed" +) + +if (-not [System.String]::IsNullOrEmpty($TestCaseFilter)) +{ + $Local:vstestArgs += "--TestCaseFilter:$TestCaseFilter" +} + +& $Local:vstestPath @Local:vstestArgs +exit $LASTEXITCODE diff --git a/src/Microsoft.Management.Configuration.Processor/DSCv3/Helpers/ProcessorPathIntegrity.cs b/src/Microsoft.Management.Configuration.Processor/DSCv3/Helpers/ProcessorPathIntegrity.cs new file mode 100644 index 0000000000..a0411a394b --- /dev/null +++ b/src/Microsoft.Management.Configuration.Processor/DSCv3/Helpers/ProcessorPathIntegrity.cs @@ -0,0 +1,202 @@ +// ----------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// +// ----------------------------------------------------------------------------- + +namespace Microsoft.Management.Configuration.Processor.DSCv3.Helpers +{ + using System; + using System.Runtime.InteropServices; + using System.Security.Cryptography; + using Microsoft.Management.Configuration.Processor.Exceptions; + using Microsoft.Win32.SafeHandles; + + /// + /// Provides integrity verification for the DSC processor executable path. + /// Handles both regular files and app execution alias reparse points. + /// + internal static class ProcessorPathIntegrity + { + private const uint GenericRead = 0x80000000; + private const uint FileShareRead = 0x00000001; + private const uint FileShareExecute = 0x00000004; + private const uint OpenExisting = 3; + private const uint FileAttributeNormal = 0x80; + private const uint FileFlagOpenReparsePoint = 0x00200000; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FsctlGetReparsePoint = 0x000900A8; + private const int MaximumReparseDataBufferSize = 16 * 1024; + + /// + /// Opens the processor path, verifies its hash matches the expected value, and returns + /// an open handle for TOCTOU protection. + /// + /// The path to the DSC executable or app execution alias. + /// The expected SHA256 hash (hex string, case-insensitive). + /// Whether the path is an app execution alias reparse point. + /// An open SafeFileHandle to the file; caller should hold this for the process lifetime. + public static SafeFileHandle VerifyAndOpen(string path, string expectedHash, bool isAlias) + { + SafeFileHandle handle; + byte[] hashBytes; + + if (isAlias) + { + handle = CreateFile( + path, + 0, + FileShareRead | FileShareExecute, + IntPtr.Zero, + OpenExisting, + FileFlagOpenReparsePoint | FileFlagBackupSemantics, + IntPtr.Zero); + + if (handle.IsInvalid) + { + throw new InvalidOperationException($"Failed to open processor path alias '{path}': Win32 error {Marshal.GetLastWin32Error()}"); + } + + byte[] reparseBuffer = new byte[MaximumReparseDataBufferSize]; + if (!DeviceIoControl(handle, FsctlGetReparsePoint, IntPtr.Zero, 0, reparseBuffer, (uint)reparseBuffer.Length, out uint bytesReturned, IntPtr.Zero)) + { + handle.Dispose(); + throw new InvalidOperationException($"Failed to read reparse data for '{path}': Win32 error {Marshal.GetLastWin32Error()}"); + } + + hashBytes = SHA256.HashData(reparseBuffer.AsSpan(0, (int)bytesReturned)); + } + else + { + handle = CreateFile( + path, + GenericRead, + FileShareRead | FileShareExecute, + IntPtr.Zero, + OpenExisting, + FileAttributeNormal, + IntPtr.Zero); + + if (handle.IsInvalid) + { + throw new InvalidOperationException($"Failed to open processor path '{path}': Win32 error {Marshal.GetLastWin32Error()}"); + } + + hashBytes = ComputeSHA256FromHandle(handle); + } + + string computedHash = Convert.ToHexString(hashBytes).ToLowerInvariant(); + if (!string.Equals(computedHash, expectedHash, StringComparison.OrdinalIgnoreCase)) + { + handle.Dispose(); + throw new DscProcessorHashMismatchException(); + } + + return handle; + } + + /// + /// Computes the SHA256 hash of a path, auto-detecting whether it is an app execution alias. + /// + /// The path to hash. + /// Receives true if the path is an app execution alias reparse point. + /// The SHA256 hash as a lowercase hex string. + public static string ComputeHash(string path, out bool isAlias) + { + // Attempt to open as a regular file first. + SafeFileHandle regularHandle = CreateFile( + path, + GenericRead, + FileShareRead | FileShareExecute, + IntPtr.Zero, + OpenExisting, + FileAttributeNormal, + IntPtr.Zero); + + if (!regularHandle.IsInvalid) + { + isAlias = false; + using (regularHandle) + { + return Convert.ToHexString(ComputeSHA256FromHandle(regularHandle)).ToLowerInvariant(); + } + } + + // If the regular open fails, try as an app execution alias reparse point. + SafeFileHandle aliasHandle = CreateFile( + path, + 0, + FileShareRead | FileShareExecute, + IntPtr.Zero, + OpenExisting, + FileFlagOpenReparsePoint | FileFlagBackupSemantics, + IntPtr.Zero); + + if (aliasHandle.IsInvalid) + { + throw new InvalidOperationException($"Failed to open path '{path}': Win32 error {Marshal.GetLastWin32Error()}"); + } + + using (aliasHandle) + { + byte[] reparseBuffer = new byte[MaximumReparseDataBufferSize]; + if (!DeviceIoControl(aliasHandle, FsctlGetReparsePoint, IntPtr.Zero, 0, reparseBuffer, (uint)reparseBuffer.Length, out uint bytesReturned, IntPtr.Zero)) + { + throw new InvalidOperationException($"Failed to read reparse data for '{path}': Win32 error {Marshal.GetLastWin32Error()}"); + } + + isAlias = true; + return Convert.ToHexString(SHA256.HashData(reparseBuffer.AsSpan(0, (int)bytesReturned))).ToLowerInvariant(); + } + } + + private static byte[] ComputeSHA256FromHandle(SafeFileHandle handle) + { + using var incrHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + + byte[] buffer = new byte[1024 * 1024]; + while (true) + { + if (!ReadFile(handle, buffer, (uint)buffer.Length, out uint bytesRead, IntPtr.Zero) || bytesRead == 0) + { + break; + } + + incrHash.AppendData(buffer, 0, (int)bytesRead); + } + + return incrHash.GetHashAndReset(); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeviceIoControl( + SafeFileHandle hDevice, + uint dwIoControlCode, + IntPtr lpInBuffer, + uint nInBufferSize, + byte[] lpOutBuffer, + uint nOutBufferSize, + out uint lpBytesReturned, + IntPtr lpOverlapped); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ReadFile( + SafeFileHandle hFile, + byte[] lpBuffer, + uint nNumberOfBytesToRead, + out uint lpNumberOfBytesRead, + IntPtr lpOverlapped); + } +} diff --git a/src/Microsoft.Management.Configuration.Processor/DSCv3/Helpers/ProcessorSettings.cs b/src/Microsoft.Management.Configuration.Processor/DSCv3/Helpers/ProcessorSettings.cs index 04abcf394c..79f4cff589 100644 --- a/src/Microsoft.Management.Configuration.Processor/DSCv3/Helpers/ProcessorSettings.cs +++ b/src/Microsoft.Management.Configuration.Processor/DSCv3/Helpers/ProcessorSettings.cs @@ -12,18 +12,25 @@ namespace Microsoft.Management.Configuration.Processor.DSCv3.Helpers using System.Text; using Microsoft.Management.Configuration.Processor.DSCv3.Model; using Microsoft.Management.Configuration.Processor.Helpers; + using Microsoft.Win32.SafeHandles; /// /// Contains settings for the DSC v3 processor components to share. /// - internal class ProcessorSettings + internal class ProcessorSettings : IDisposable { private readonly object dscV3Lock = new (); private readonly object defaultPathLock = new (); + private readonly object processorPathLock = new (); private FindDscPackageStateMachine dscPackageStateMachine = new (); private IDSCv3? dscV3 = null; private string? defaultPath = null; + private string? defaultPathHash = null; + private bool? defaultPathIsAlias = null; + private SafeFileHandle? processorPathHandle = null; + private bool processorPathVerified = false; + private bool disposed = false; private Dictionary resourceDetailsDictionary = new (); @@ -32,6 +39,17 @@ internal class ProcessorSettings /// public string? DscExecutablePath { get; set; } + /// + /// Gets or sets the expected SHA256 hash of the DSC v3 executable (hex string). + /// Must be set before is accessed when a custom path is used. + /// + public string? DscExecutablePathHash { get; set; } + + /// + /// Gets or sets a value indicating whether is an app execution alias. + /// + public bool? DscExecutablePathIsAlias { get; set; } + /// /// Gets the path to the DSC v3 executable. /// @@ -41,6 +59,7 @@ public string EffectiveDscExecutablePath { if (this.DscExecutablePath != null) { + this.EnsureProcessorPathVerified(); return this.DscExecutablePath; } @@ -117,7 +136,39 @@ public IDSCv3 DSCv3 /// The full path to the dsc.exe executable, or null if not found. public string? GetFoundDscExecutablePath() { - return this.dscPackageStateMachine.DscExecutablePath; + string? result = this.dscPackageStateMachine.DscExecutablePath; + + if (result != null) + { + // Ensure hash and alias are computed and cached alongside the path. + this.EnsureFoundPathHashCached(result); + } + + return result; + } + + /// + /// Gets the SHA256 hash of the auto-discovered DSC executable path, or null if not yet discovered. + /// + /// Lowercase hex hash string, or null. + public string? GetFoundDscExecutablePathHash() + { + lock (this.defaultPathLock) + { + return this.defaultPathHash; + } + } + + /// + /// Gets whether the auto-discovered DSC executable path is an app execution alias, or null if not yet discovered. + /// + /// True if alias, false if regular file, or null if not yet discovered. + public bool? GetFoundDscExecutablePathIsAlias() + { + lock (this.defaultPathLock) + { + return this.defaultPathIsAlias; + } } /// @@ -129,6 +180,13 @@ public FindDscPackageStateMachine.Transition PumpFindDscStateMachine() return this.dscPackageStateMachine.DetermineNextTransition(); } + /// + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + /// /// Create a deep copy of this settings object. /// @@ -140,7 +198,16 @@ public ProcessorSettings Clone() result.resourceDetailsDictionary = this.resourceDetailsDictionary; result.DiagnosticsSink = this.DiagnosticsSink; result.DscExecutablePath = this.DscExecutablePath; + result.DscExecutablePathHash = this.DscExecutablePathHash; + result.DscExecutablePathIsAlias = this.DscExecutablePathIsAlias; result.DiagnosticTraceEnabled = this.DiagnosticTraceEnabled; + lock (this.defaultPathLock) + { + result.defaultPath = this.defaultPath; + result.defaultPathHash = this.defaultPathHash; + result.defaultPathIsAlias = this.defaultPathIsAlias; + } + #if !AICLI_DISABLE_TEST_HOOKS result.dscV3 = this.DSCv3; #endif @@ -251,5 +318,59 @@ public List FindAllResourceDetails(FindUnitProcessorsOptions fi return result; } + + /// + /// Releases resources held by this instance, including the open handle used for TOCTOU protection. + /// + /// True if called from Dispose(); false if called from a finalizer. + protected virtual void Dispose(bool disposing) + { + if (!this.disposed) + { + if (disposing) + { + lock (this.processorPathLock) + { + this.processorPathHandle?.Dispose(); + this.processorPathHandle = null; + } + } + + this.disposed = true; + } + } + + private void EnsureFoundPathHashCached(string path) + { + lock (this.defaultPathLock) + { + if (this.defaultPathHash == null) + { + this.defaultPathHash = ProcessorPathIntegrity.ComputeHash(path, out bool isAlias); + this.defaultPathIsAlias = isAlias; + } + } + } + + private void EnsureProcessorPathVerified() + { + lock (this.processorPathLock) + { + if (!this.processorPathVerified) + { + if (this.DscExecutablePathHash == null) + { + throw new InvalidOperationException("A custom processor path was provided without a hash for integrity verification."); + } + + bool isAlias = this.DscExecutablePathIsAlias ?? false; + this.processorPathHandle = ProcessorPathIntegrity.VerifyAndOpen( + this.DscExecutablePath!, + this.DscExecutablePathHash, + isAlias); + this.processorPathVerified = true; + } + } + } } } diff --git a/src/Microsoft.Management.Configuration.Processor/Exceptions/DscProcessorHashMismatchException.cs b/src/Microsoft.Management.Configuration.Processor/Exceptions/DscProcessorHashMismatchException.cs new file mode 100644 index 0000000000..70790ff8ed --- /dev/null +++ b/src/Microsoft.Management.Configuration.Processor/Exceptions/DscProcessorHashMismatchException.cs @@ -0,0 +1,26 @@ +// ----------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// +// ----------------------------------------------------------------------------- + +namespace Microsoft.Management.Configuration.Processor.Exceptions +{ + using System; + using Microsoft.PowerShell.Commands; + + /// + /// DSC processor does not match provided hash. + /// + internal class DscProcessorHashMismatchException : Exception + { + /// + /// Initializes a new instance of the class. + /// + public DscProcessorHashMismatchException() + : base("The DSC processor hash provided does not match hash of the target file.") + { + this.HResult = ErrorCodes.WinGetConfigProcessorHashMismatch; + } + } +} diff --git a/src/Microsoft.Management.Configuration.Processor/Exceptions/ErrorCodes.cs b/src/Microsoft.Management.Configuration.Processor/Exceptions/ErrorCodes.cs index 3639d2f71c..94653913de 100644 --- a/src/Microsoft.Management.Configuration.Processor/Exceptions/ErrorCodes.cs +++ b/src/Microsoft.Management.Configuration.Processor/Exceptions/ErrorCodes.cs @@ -75,5 +75,10 @@ internal static class ErrorCodes /// The property type of a unit is not supported. /// internal const int WinGetConfigUnitUnsupportedType = unchecked((int)0x8A15C112); + + /// + /// The DSC processor hash provided does not match hash of the target file. + /// + internal const int WinGetConfigProcessorHashMismatch = unchecked((int)0x8A15C113); } } diff --git a/src/Microsoft.Management.Configuration.Processor/Public/DSCv3ConfigurationSetProcessorFactory.cs b/src/Microsoft.Management.Configuration.Processor/Public/DSCv3ConfigurationSetProcessorFactory.cs index 9d598b9ba4..55a88fece3 100644 --- a/src/Microsoft.Management.Configuration.Processor/Public/DSCv3ConfigurationSetProcessorFactory.cs +++ b/src/Microsoft.Management.Configuration.Processor/Public/DSCv3ConfigurationSetProcessorFactory.cs @@ -24,6 +24,10 @@ internal sealed partial class DSCv3ConfigurationSetProcessorFactory : Configurat private const string FoundDscExecutablePathPropertyName = "FoundDscExecutablePath"; private const string DiagnosticTraceEnabledPropertyName = "DiagnosticTraceEnabled"; private const string FindDscStateMachinePropertyName = "FindDscStateMachine"; + private const string DscExecutablePathHashPropertyName = "DscExecutablePathHash"; + private const string DscExecutablePathIsAliasPropertyName = "DscExecutablePathIsAlias"; + private const string FoundDscExecutablePathHashPropertyName = "FoundDscExecutablePathHash"; + private const string FoundDscExecutablePathIsAliasPropertyName = "FoundDscExecutablePathIsAlias"; private ProcessorSettings processorSettings = new (); @@ -56,6 +60,49 @@ public string? DscExecutablePath } } + /// + /// Gets or sets the expected SHA256 hash of (hex string). + /// Required when a custom processor path is provided. + /// + public string? DscExecutablePathHash + { + get + { + return this.processorSettings.DscExecutablePathHash; + } + + set + { + if (this.IsLimitMode()) + { + throw new InvalidOperationException("Setting DscExecutablePathHash in limit mode is invalid."); + } + + this.processorSettings.DscExecutablePathHash = value; + } + } + + /// + /// Gets or sets a value indicating whether is an app execution alias. + /// + public bool? DscExecutablePathIsAlias + { + get + { + return this.processorSettings.DscExecutablePathIsAlias; + } + + set + { + if (this.IsLimitMode()) + { + throw new InvalidOperationException("Setting DscExecutablePathIsAlias in limit mode is invalid."); + } + + this.processorSettings.DscExecutablePathIsAlias = value; + } + } + #if !AICLI_DISABLE_TEST_HOOKS /// /// Gets the processor settings; for tests only. @@ -115,6 +162,14 @@ public bool ContainsKey(string key) { case DscExecutablePathPropertyName: return this.DscExecutablePath != null; + case DscExecutablePathHashPropertyName: + return this.DscExecutablePathHash != null; + case DscExecutablePathIsAliasPropertyName: + return this.DscExecutablePathIsAlias != null; + case FoundDscExecutablePathHashPropertyName: + return this.processorSettings.GetFoundDscExecutablePathHash() != null; + case FoundDscExecutablePathIsAliasPropertyName: + return this.processorSettings.GetFoundDscExecutablePathIsAlias() != null; } return false; @@ -163,6 +218,40 @@ public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value) case FindDscStateMachinePropertyName: value = this.processorSettings.PumpFindDscStateMachine().ToString(); return true; + case DscExecutablePathHashPropertyName: + if (this.DscExecutablePathHash != null) + { + value = this.DscExecutablePathHash; + return true; + } + + return false; + case DscExecutablePathIsAliasPropertyName: + if (this.DscExecutablePathIsAlias != null) + { + value = this.DscExecutablePathIsAlias.Value.ToString(); + return true; + } + + return false; + case FoundDscExecutablePathHashPropertyName: + string? foundHash = this.processorSettings.GetFoundDscExecutablePathHash(); + if (foundHash != null) + { + value = foundHash; + return true; + } + + return false; + case FoundDscExecutablePathIsAliasPropertyName: + bool? foundIsAlias = this.processorSettings.GetFoundDscExecutablePathIsAlias(); + if (foundIsAlias != null) + { + value = foundIsAlias.Value.ToString(); + return true; + } + + return false; } return false; @@ -202,6 +291,12 @@ private void SetValue(string name, string value) case DiagnosticTraceEnabledPropertyName: this.processorSettings.DiagnosticTraceEnabled = bool.Parse(value); break; + case DscExecutablePathHashPropertyName: + this.DscExecutablePathHash = value; + break; + case DscExecutablePathIsAliasPropertyName: + this.DscExecutablePathIsAlias = bool.Parse(value); + break; default: throw new ArgumentOutOfRangeException($"Invalid property name: {name}"); } diff --git a/src/Microsoft.Management.Configuration.Processor/Set/ConfigurationSetProcessorBase.cs b/src/Microsoft.Management.Configuration.Processor/Set/ConfigurationSetProcessorBase.cs index c9aa5ca24a..e1d70a47d5 100644 --- a/src/Microsoft.Management.Configuration.Processor/Set/ConfigurationSetProcessorBase.cs +++ b/src/Microsoft.Management.Configuration.Processor/Set/ConfigurationSetProcessorBase.cs @@ -73,7 +73,7 @@ public IConfigurationUnitProcessor CreateUnitProcessor(ConfigurationUnit incomin { try { - this.OnDiagnostics(DiagnosticLevel.Informational, $"GetUnitProcessorDetails is running in limit mode: {this.IsLimitMode}."); + this.OnDiagnostics(DiagnosticLevel.Informational, $"CreateUnitProcessor is running in limit mode: {this.IsLimitMode}."); // CreateUnitProcessor can only be called once on each configuration unit in limit mode. var unit = this.GetConfigurationUnit(incomingUnit, true); @@ -172,7 +172,7 @@ protected void OnDiagnostics(DiagnosticLevel level, string message) this.SetProcessorFactory?.OnDiagnostics(level, message); } - private static bool ConfigurationUnitEquals(ConfigurationUnit first, ConfigurationUnit second) + private bool ConfigurationUnitEquals(ConfigurationUnit first, ConfigurationUnit second) { var firstIdentifier = first.Identifier; var firstIntent = first.Intent; @@ -181,10 +181,15 @@ private static bool ConfigurationUnitEquals(ConfigurationUnit first, Configurati var secondType = second.Type; var secondIntent = second.Intent; - if (firstIdentifier != secondIdentifier || - firstType != secondType || + if (firstIdentifier != secondIdentifier) + { + return false; + } + + if (firstType != secondType || firstIntent != secondIntent) { + this.OnDiagnostics(DiagnosticLevel.Error, $"Configuration unit `{firstIdentifier}` mismatch of type or intent."); return false; } @@ -194,16 +199,19 @@ private static bool ConfigurationUnitEquals(ConfigurationUnit first, Configurati firstEnvironment.ProcessorIdentifier != secondEnvironment.ProcessorIdentifier || !firstEnvironment.ProcessorProperties.ContentEquals(secondEnvironment.ProcessorProperties)) { + this.OnDiagnostics(DiagnosticLevel.Error, $"Configuration unit `{firstIdentifier}` mismatch of environment."); return false; } if (!first.Settings.ContentEquals(second.Settings)) { + this.OnDiagnostics(DiagnosticLevel.Error, $"Configuration unit `{firstIdentifier}` mismatch of settings."); return false; } if (!first.Metadata.ContentEquals(second.Metadata)) { + this.OnDiagnostics(DiagnosticLevel.Error, $"Configuration unit `{firstIdentifier}` mismatch of metadata."); return false; } @@ -226,7 +234,7 @@ private ConfigurationUnit GetConfigurationUnit(ConfigurationUnit incomingUnit, b for (int i = 0; i < unitList.Count; i++) { var unit = unitList[i]; - if (ConfigurationUnitEquals(incomingUnit, unit)) + if (this.ConfigurationUnitEquals(incomingUnit, unit)) { if (useLimitList) { @@ -239,8 +247,8 @@ private ConfigurationUnit GetConfigurationUnit(ConfigurationUnit incomingUnit, b // Note: Consider group units logic when group units are supported. } - this.OnDiagnostics(DiagnosticLevel.Error, "Configuration unit not found in limit mode."); - throw new InvalidOperationException("Configuration unit not found in limit mode."); + this.OnDiagnostics(DiagnosticLevel.Error, "Configuration unit match not found in limit mode."); + throw new InvalidOperationException("Configuration unit match not found in limit mode."); } else { diff --git a/src/Microsoft.Management.Configuration.UnitTests/Helpers/Constants.cs b/src/Microsoft.Management.Configuration.UnitTests/Helpers/Constants.cs index 3a0e4a1587..506ead01f1 100644 --- a/src/Microsoft.Management.Configuration.UnitTests/Helpers/Constants.cs +++ b/src/Microsoft.Management.Configuration.UnitTests/Helpers/Constants.cs @@ -26,6 +26,12 @@ public class Constants /// public const string DynamicRuntimeHandlerIdentifier = "{73fea39f-6f4a-41c9-ba94-6fd14d633e40}"; + /// + /// The DSCv3-specific dynamic runtime factory handler identifier. + /// Unlike DynamicRuntimeHandlerIdentifier, this pre-selects the DSCv3 processor engine. + /// + public const string DSCv3DynamicRuntimeHandlerIdentifier = "{5f83e564-ca26-41ca-89db-36f5f0517ffd}"; + /// /// Test guid for enabling test mode for the dynamic runtime factory. Forces factory to exclude 'runas' verb and sets current IL to medium. /// diff --git a/src/Microsoft.Management.Configuration.UnitTests/Helpers/Errors.cs b/src/Microsoft.Management.Configuration.UnitTests/Helpers/Errors.cs index c0cf67c7dc..be5cd26a67 100644 --- a/src/Microsoft.Management.Configuration.UnitTests/Helpers/Errors.cs +++ b/src/Microsoft.Management.Configuration.UnitTests/Helpers/Errors.cs @@ -46,6 +46,7 @@ internal static class Errors public static readonly int WINGET_CONFIG_ERROR_UNIT_SETTING_CONFIG_ROOT = unchecked((int)0x8A15C110); public static readonly int WINGET_CONFIG_ERROR_UNIT_IMPORT_MODULE_ADMIN = unchecked((int)0x8A15C111); public static readonly int WINGET_CONFIG_ERROR_NOT_SUPPORTED_BY_PROCESSOR = unchecked((int)0x8A15C112); + public static readonly int WINGET_CONFIG_ERROR_PROCESSOR_HASH_MISMATCH = unchecked((int)0x8A15C113); public static readonly int WINGET_CONFIG_ERROR_PARAMETER_INTEGRITY_BOUNDARY = unchecked((int)0x8A15C013); // Limitation Set Errors diff --git a/src/Microsoft.Management.Configuration.UnitTests/Tests/DSCv3ProcessorPathIntegrityIntegrationTests.cs b/src/Microsoft.Management.Configuration.UnitTests/Tests/DSCv3ProcessorPathIntegrityIntegrationTests.cs new file mode 100644 index 0000000000..f8c12c9e57 --- /dev/null +++ b/src/Microsoft.Management.Configuration.UnitTests/Tests/DSCv3ProcessorPathIntegrityIntegrationTests.cs @@ -0,0 +1,108 @@ +// ----------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// +// ----------------------------------------------------------------------------- + +namespace Microsoft.Management.Configuration.UnitTests.Tests +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using Microsoft.Management.Configuration.UnitTests.Fixtures; + using Microsoft.Management.Configuration.UnitTests.Helpers; + using Xunit; + using Xunit.Abstractions; + + /// + /// Integration tests for DSCv3 processor path integrity checks. + /// These tests verify that hash verification catches mismatches when a custom DSC executable + /// path is provided. Units run in-process (no elevation split) so limit mode is not involved. + /// + [Collection("UnitTestCollection")] + [OutOfProc] + public class DSCv3ProcessorPathIntegrityIntegrationTests : ConfigurationProcessorTestBase + { + private readonly UnitTestFixture fixture; + private readonly ITestOutputHelper log; + + /// + /// Initializes a new instance of the class. + /// + /// Unit test fixture. + /// Log helper. + public DSCv3ProcessorPathIntegrityIntegrationTests(UnitTestFixture fixture, ITestOutputHelper log) + : base(fixture, log) + { + this.fixture = fixture; + this.log = log; + } + + /// + /// Verifies that providing a wrong hash for a custom processor path causes the + /// apply to fail with a hash mismatch error. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task Apply_ProcessorPath_WrongHash_FailsWithHashMismatch() + { + using var tempFile = new TempFile(content: "test content for hash test"); + + IConfigurationSetProcessorFactory dynamicFactory = + await this.fixture.ConfigurationStatics.CreateConfigurationSetProcessorFactoryAsync( + Helpers.Constants.DSCv3DynamicRuntimeHandlerIdentifier); + + var factoryMap = (IDictionary)dynamicFactory; + factoryMap["DscExecutablePath"] = tempFile.FullFileName; + factoryMap["DscExecutablePathHash"] = new string('0', 64); // Wrong hash. + factoryMap["DscExecutablePathIsAlias"] = "false"; + + ConfigurationSet configurationSet = this.ConfigurationSet(); + configurationSet.SchemaVersion = "0.3"; + configurationSet.Metadata.Add(Helpers.Constants.EnableDynamicFactoryTestMode, true); + + ConfigurationUnit unit = this.ConfigurationUnit(); + unit.Identifier = "testUnit"; + unit.Type = "TestResource/TestUnit"; + unit.Intent = ConfigurationUnitIntent.Apply; + configurationSet.Units = new[] { unit }; + + ConfigurationProcessor processor = this.CreateConfigurationProcessorWithDiagnostics(dynamicFactory); + var ex = Assert.ThrowsAny(() => processor.ApplySet(configurationSet, ApplyConfigurationSetFlags.None)); + Assert.Equal(Errors.WINGET_CONFIG_ERROR_PROCESSOR_HASH_MISMATCH, ex.HResult); + } + + /// + /// Verifies that omitting the hash for a custom processor path causes the apply + /// to fail. The server requires a hash whenever a custom path is provided. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task Apply_ProcessorPath_MissingHash_ApplyFails() + { + using var tempFile = new TempFile(content: "test content"); + + IConfigurationSetProcessorFactory dynamicFactory = + await this.fixture.ConfigurationStatics.CreateConfigurationSetProcessorFactoryAsync( + Helpers.Constants.DSCv3DynamicRuntimeHandlerIdentifier); + + var factoryMap = (IDictionary)dynamicFactory; + + // DscExecutablePathHash intentionally omitted. + factoryMap["DscExecutablePath"] = tempFile.FullFileName; + + ConfigurationSet configurationSet = this.ConfigurationSet(); + configurationSet.SchemaVersion = "0.3"; + configurationSet.Metadata.Add(Helpers.Constants.EnableDynamicFactoryTestMode, true); + + ConfigurationUnit unit = this.ConfigurationUnit(); + unit.Identifier = "testUnit"; + unit.Type = "TestResource/TestUnit"; + unit.Intent = ConfigurationUnitIntent.Apply; + configurationSet.Units = new[] { unit }; + + ConfigurationProcessor processor = this.CreateConfigurationProcessorWithDiagnostics(dynamicFactory); + Assert.ThrowsAny(() => processor.ApplySet(configurationSet, ApplyConfigurationSetFlags.None)); + } + } +} diff --git a/src/Microsoft.Management.Configuration.UnitTests/Tests/DSCv3ProcessorPathIntegrityUnitTests.cs b/src/Microsoft.Management.Configuration.UnitTests/Tests/DSCv3ProcessorPathIntegrityUnitTests.cs new file mode 100644 index 0000000000..2358eb7ee1 --- /dev/null +++ b/src/Microsoft.Management.Configuration.UnitTests/Tests/DSCv3ProcessorPathIntegrityUnitTests.cs @@ -0,0 +1,160 @@ +// ----------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// +// ----------------------------------------------------------------------------- + +namespace Microsoft.Management.Configuration.UnitTests.Tests +{ + using System; + using Microsoft.Management.Configuration.Processor.DSCv3.Helpers; + using Microsoft.Management.Configuration.UnitTests.Fixtures; + using Microsoft.Management.Configuration.UnitTests.Helpers; + using Xunit; + using Xunit.Abstractions; + + /// + /// In-process unit tests for DSCv3 processor path integrity helper methods. + /// These exercise the helpers directly without going through the elevation split. + /// + [Collection("UnitTestCollection")] + [InProc] + public class DSCv3ProcessorPathIntegrityUnitTests : ConfigurationProcessorTestBase + { + private readonly UnitTestFixture fixture; + private readonly ITestOutputHelper log; + + /// + /// Initializes a new instance of the class. + /// + /// Unit test fixture. + /// Log helper. + public DSCv3ProcessorPathIntegrityUnitTests(UnitTestFixture fixture, ITestOutputHelper log) + : base(fixture, log) + { + this.fixture = fixture; + this.log = log; + } + + /// + /// Verifies that returns a 64-char + /// lowercase hex SHA256 hash for a regular file. + /// + [Fact] + public void ComputeHash_RegularFile_ReturnsLowercaseHex64() + { + using var tempFile = new TempFile(content: "test content for hashing"); + + string hash = ProcessorPathIntegrity.ComputeHash(tempFile.FullFileName, out bool isAlias); + + Assert.False(isAlias); + Assert.Equal(64, hash.Length); + Assert.Equal(hash, hash.ToLowerInvariant()); + } + + /// + /// Verifies that two calls to on the same + /// file return the same hash. + /// + [Fact] + public void ComputeHash_SameFile_ReturnsSameHash() + { + using var tempFile = new TempFile(content: "deterministic content"); + + string hash1 = ProcessorPathIntegrity.ComputeHash(tempFile.FullFileName, out _); + string hash2 = ProcessorPathIntegrity.ComputeHash(tempFile.FullFileName, out _); + + Assert.Equal(hash1, hash2); + } + + /// + /// Verifies that succeeds and returns a + /// valid handle when the correct hash is supplied. + /// + [Fact] + public void VerifyAndOpen_CorrectHash_ReturnsValidHandle() + { + using var tempFile = new TempFile(content: "test content"); + + string hash = ProcessorPathIntegrity.ComputeHash(tempFile.FullFileName, out bool isAlias); + using var handle = ProcessorPathIntegrity.VerifyAndOpen(tempFile.FullFileName, hash, isAlias); + + Assert.False(handle.IsInvalid); + } + + /// + /// Verifies that throws with the + /// hash mismatch HRESULT when the wrong hash is supplied. + /// + [Fact] + public void VerifyAndOpen_WrongHash_ThrowsHashMismatch() + { + using var tempFile = new TempFile(content: "test content"); + + Exception? ex = Record.Exception(() => + { + using var handle = ProcessorPathIntegrity.VerifyAndOpen(tempFile.FullFileName, new string('0', 64), isAlias: false); + }); + + Assert.NotNull(ex); + Assert.Equal(Errors.WINGET_CONFIG_ERROR_PROCESSOR_HASH_MISMATCH, ex.HResult); + } + + /// + /// Verifies that throws + /// when a custom path is set without a hash. + /// + [Fact] + public void ProcessorSettings_CustomPath_NoHash_Throws() + { + using var tempFile = new TempFile(content: "test content"); + + var settings = new ProcessorSettings(); + settings.DscExecutablePath = tempFile.FullFileName; + + Exception? ex = Record.Exception(() => _ = settings.EffectiveDscExecutablePath); + + Assert.NotNull(ex); + Assert.IsType(ex); + } + + /// + /// Verifies that returns the + /// path when the correct hash is provided. + /// + [Fact] + public void ProcessorSettings_CustomPath_CorrectHash_ReturnsPath() + { + using var tempFile = new TempFile(content: "test content"); + + string hash = ProcessorPathIntegrity.ComputeHash(tempFile.FullFileName, out bool isAlias); + + using var settings = new ProcessorSettings(); + settings.DscExecutablePath = tempFile.FullFileName; + settings.DscExecutablePathHash = hash; + settings.DscExecutablePathIsAlias = isAlias; + + Assert.Equal(tempFile.FullFileName, settings.EffectiveDscExecutablePath); + } + + /// + /// Verifies that throws with the + /// hash mismatch HRESULT when a wrong hash is set for a custom path. + /// + [Fact] + public void ProcessorSettings_CustomPath_WrongHash_ThrowsHashMismatch() + { + using var tempFile = new TempFile(content: "test content"); + + var settings = new ProcessorSettings(); + settings.DscExecutablePath = tempFile.FullFileName; + settings.DscExecutablePathHash = new string('0', 64); + settings.DscExecutablePathIsAlias = false; + + Exception? ex = Record.Exception(() => _ = settings.EffectiveDscExecutablePath); + + Assert.NotNull(ex); + Assert.Equal(Errors.WINGET_CONFIG_ERROR_PROCESSOR_HASH_MISMATCH, ex.HResult); + } + } +} diff --git a/src/Microsoft.Management.Configuration.UnitTests/Tests/DSCv3ProcessorTests.cs b/src/Microsoft.Management.Configuration.UnitTests/Tests/DSCv3ProcessorTests.cs index 32814f8a80..4e56765212 100644 --- a/src/Microsoft.Management.Configuration.UnitTests/Tests/DSCv3ProcessorTests.cs +++ b/src/Microsoft.Management.Configuration.UnitTests/Tests/DSCv3ProcessorTests.cs @@ -6,8 +6,12 @@ namespace Microsoft.Management.Configuration.UnitTests.Tests { + using System; using System.Collections.Generic; + using System.IO; using System.Linq; + using System.Reflection; + using System.Security.Cryptography; using Microsoft.Management.Configuration.Processor; using Microsoft.Management.Configuration.Processor.DSCv3.Model; using Microsoft.Management.Configuration.Processor.Exceptions; @@ -313,7 +317,10 @@ private static (DSCv3ConfigurationSetProcessorFactory, TestDSCv3) CreateTestFact DSCv3ConfigurationSetProcessorFactory factory = new DSCv3ConfigurationSetProcessorFactory(); TestDSCv3 dsc = new TestDSCv3(); factory.Settings.DSCv3 = dsc; - factory.Settings.DscExecutablePath = "Test-Path-Not-Used.txt"; + TempFile tempFile = new TempFile(content: "Contents", cleanup: false); + factory.Settings.DscExecutablePath = tempFile.FullFileName; + using var fileStream = File.Open(factory.Settings.DscExecutablePath, FileMode.Open); + factory.Settings.DscExecutablePathHash = Convert.ToHexString(SHA256.HashData(fileStream)).ToLowerInvariant(); return (factory, dsc); }