|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Net.NetworkInformation;
using System.Reflection;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.RegularExpressions;
using Microsoft.DotNet.Cli.Installer.Windows;
namespace SDDLTests
{
/// <summary>
/// Console application containing a manual test to verify security descriptors used by workloads when caching packages.
/// </summary>
[SupportedOSPlatform("windows")]
public class SDDLTests
{
/// <summary>
/// The full path of ProgramData.
/// </summary>
private static readonly string s_programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
/// <summary>
/// Directory under ProgramData to store the install state file.
/// </summary>
private static readonly string s_installStateDirectory = Path.Combine(s_programData, "SDDLTest", "workloads", "8.0.100", "InstallState");
/// <summary>
/// The filename and extension of the install state file.
/// </summary>
private static readonly string s_installStateFile = "default.json";
/// <summary>
/// The full path of the install state file.
/// </summary>
private static readonly string s_installStateFileAssetPath = Path.Combine(s_installStateDirectory, s_installStateFile);
/// <summary>
/// Directory under the user's %temp% directory where the test asset will be created.
/// </summary>
private static readonly string s_userTestDirectory = Path.Combine(Path.GetTempPath(), "SDDLTest");
/// <summary>
/// The filename and extension of the test asset.
/// </summary>
private static readonly string s_testAsset = "test.txt";
/// <summary>
/// Regular expression to capture parts of a security descriptor in SDDL format.
/// </summary>
private static readonly string s_SDDL_Pattern = @"O:(?<O_SID>.*?(?=G:))G:(?<G_SID>.*?(?=D:|S:|$))(D:(?<DACL_FLAGS>.*?(?=\())(?<DACL>\(.*?\))*)?(S:(?<SACL_FLAGS>.*?(?=\())(?<SACL>\(.*?\))*)?";
/// <summary>
/// The .NET directory under ProgramData. Typically this would be named 'dotnet'.
/// </summary>
private static readonly string s_cacheRootDirectory = Path.Combine(s_programData, "SDDLTest");
/// <summary>
/// The root directory under ProgramData where workload related files are stored.
/// </summary>
private static readonly string s_workloadsCacheDirectory = Path.Combine(s_cacheRootDirectory, "workloads");
/// <summary>
/// The path of the directory under the cache root where the test asset will be placed. For workload packs this would
/// typically include the package ID and version.
/// </summary>
private static readonly string s_workloadPackCacheDirectory = Path.Combine(s_workloadsCacheDirectory, "test.workload.pack", "1.2.3-preview5");
/// <summary>
/// The full path to the test asset under the user temp directory.
/// </summary>
private static readonly string s_userTestAssetPath = Path.Combine(s_userTestDirectory, s_testAsset);
/// <summary>
/// The full path to the test asset under the cache directory.
/// </summary>
private static readonly string s_cachedTestAssetPath = Path.Combine(s_workloadPackCacheDirectory, s_testAsset);
/// <summary>
/// Access control sections to retrieve from security descriptors: owner, group and access control lists.
/// </summary>
private static readonly AccessControlSections s_accessControlSections = AccessControlSections.Group | AccessControlSections.Owner |
AccessControlSections.Access;
/// <summary>
/// Writes a message to the console's error stream.
/// </summary>
/// <param name="message">The message to write.</param>
private static void WriteError(string message)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine(message);
Console.ResetColor();
}
/// <summary>
/// Extracts the owner, group and DACL (individual ACES) of the security descriptor.
/// </summary>
/// <param name="sddlDescriptor">The SDDL formatted security descriptor.</param>
/// <returns>The owner, group and DACL.</returns>
/// <exception cref="FormatException">If the descriptor string cannot be parsed.</exception>
private static (string ownerSID, string groupSID, IEnumerable<string> DACL_ACEs) GetDescriptorParts(string sddlDescriptor)
{
Match m = Regex.Match(sddlDescriptor, s_SDDL_Pattern);
if (m.Success)
{
string owner = m.Groups.ContainsKey("O_SID") ? m.Groups["O_SID"].Value : string.Empty;
string group = m.Groups.ContainsKey("G_SID") ? m.Groups["G_SID"].Value : string.Empty;
IEnumerable<string> aces = m.Groups.ContainsKey("DACL") ? m.Groups["DACL"].Captures.Select(c => c.Value.Trim('(', ')')) :
Enumerable.Empty<string>();
return (owner, group, aces);
}
throw new FormatException("Invalid SDDL descriptor string.");
}
/// <summary>
/// Determines whether the current user has the Administrator role.
/// </summary>
/// <returns><see langword="true"/> if the user has the Administrator role.</returns>
private static bool IsAdministrator()
{
WindowsPrincipal principal = new(WindowsIdentity.GetCurrent());
return principal.IsInRole(WindowsBuiltInRole.Administrator);
}
/// <summary>
/// Creates a directory in the user's temporary directory along with an empty file. This
/// simulates the behavior when a workload pack is downloaded and extracted.
/// </summary>
/// <returns>
/// The full path to the test file under the users temporary directory.
/// </returns>
private static string CreateTestAsset()
{
// Always remove previous directories to ensure we're running in a clean state.
if (Directory.Exists(s_userTestDirectory))
{
Directory.Delete(s_userTestDirectory, recursive: true);
}
Directory.CreateDirectory(s_userTestDirectory);
string testAssetPath = Path.Combine(s_userTestDirectory, "test.txt");
using StreamWriter sw = File.CreateText(testAssetPath);
Console.WriteLine($"Created test asset at: {testAssetPath}");
// Report the directory and file security descriptors
DirectorySecurity ds = new(s_userTestDirectory, s_accessControlSections);
FileSecurity fs = new(testAssetPath, s_accessControlSections);
Console.WriteLine($"Directory descriptor: {ds.GetSecurityDescriptorSddlForm(s_accessControlSections)}");
Console.WriteLine($" File descriptor: {fs.GetSecurityDescriptorSddlForm(s_accessControlSections)}");
return Path.GetFullPath(testAssetPath);
}
/// <summary>
/// Relocate the test asset from the user directory to ProgramData.
/// </summary>
private static void RelocateAndSecureAsset()
{
SecurityUtils.CreateSecureDirectory(s_workloadPackCacheDirectory);
SecurityUtils.MoveAndSecureFile(s_userTestAssetPath, s_cachedTestAssetPath);
}
/// <summary>
/// Verify a security descriptor against a set of expected values.
/// </summary>
/// <param name="path">The full path of the directory to verify.</param>
/// <param name="expectedOwnerSID">The expected owner SID in SDDL format.</param>
/// <param name="expectedGroupSID">The expected group SID in SDDL format.</param>
/// <param name="expectedNumberOfACEsInDACL">The number of ACEs to expect in the DACL.</param>
/// <param name="expectedACEs">The set of exapected ACEs in SDDL format (no parantheses). This does not have to be the full set.</param>
private static void VerifySecurityDescriptor(string sddlDescriptor, string expectedOwnerSID,
string expectedGroupSID, int expectedNumberOfACEsInDACL, params string[] expectedACEs)
{
Console.WriteLine($"Verifying descriptor: {sddlDescriptor}");
(string owner, string group, IEnumerable<string> ACEs) d = GetDescriptorParts(sddlDescriptor);
Assert.True(expectedOwnerSID == d.owner, $"Expected owner SID to be {expectedOwnerSID}. Actual value: {d.owner}");
Assert.True(expectedGroupSID == d.group, $"Expected group SID to be {expectedGroupSID}. Actual value: {d.group}");
Assert.True(d.ACEs.Count() == expectedNumberOfACEsInDACL, $"Expected {expectedNumberOfACEsInDACL}. Actual: {d.ACEs.Count()}");
foreach (string ace in expectedACEs)
{
Assert.True(d.ACEs.Contains(ace), $"Expected DACL to contain {ace}, but it did not.");
}
}
/// <summary>
/// Verify a directory's security descriptor against a set of expected values.
/// </summary>
/// <param name="path">The full path of the directory to verify.</param>
/// <param name="expectedOwnerSID">The expected owner SID in SDDL format.</param>
/// <param name="expectedGroupSID">The expected group SID in SDDL format.</param>
/// <param name="expectedNumberOfACEsInDACL">The number of ACEs to expect in the DACL.</param>
/// <param name="expectedACEs">The set of exapected ACEs in SDDL format (no parantheses). This does not have to be the full set.</param>
private static void VerifyDirectorySecurityDescriptor(string path, string expectedOwnerSID,
string expectedGroupSID, int expectedNumberOfACEsInDACL, params string[] expectedACEs)
{
Console.WriteLine($"Verifying directory expectations for {path}");
DirectorySecurity ds = new(path, s_accessControlSections);
string descriptor = ds.GetSecurityDescriptorSddlForm(s_accessControlSections);
VerifySecurityDescriptor(descriptor, expectedOwnerSID, expectedGroupSID, expectedNumberOfACEsInDACL, expectedACEs);
}
/// <summary>
/// Verify a files's security descriptor against a set of expected values.
/// </summary>
/// <param name="path">The full path of the directory to verify.</param>
/// <param name="expectedOwnerSID">The expected owner SID in SDDL format.</param>
/// <param name="expectedGroupSID">The expected group SID in SDDL format.</param>
/// <param name="expectedNumberOfACEsInDACL">The number of ACEs to expect in the DACL.</param>
/// <param name="expectedACEs">The set of exapected ACEs in SDDL format (no parantheses). This does not have to be the full set.</param>
private static void VerifyFileSecurityDescriptor(string path, string expectedOwnerSID,
string expectedGroupSID, int expectedNumberOfACEsInDACL, params string[] expectedACEs)
{
Console.WriteLine($"Verifying file expectations for {path}");
FileSecurity ds = new(path, s_accessControlSections);
string descriptor = ds.GetSecurityDescriptorSddlForm(s_accessControlSections);
VerifySecurityDescriptor(descriptor, expectedOwnerSID, expectedGroupSID, expectedNumberOfACEsInDACL, expectedACEs);
}
/// <summary>
/// Verify file and directory security descriptors against expected values.
/// </summary>
private static void VerifyDescriptors()
{
// Dump the descriptor of ProgramData since it's useful for analyzing.
DirectorySecurity ds = new(s_programData, s_accessControlSections);
string descriptor = ds.GetSecurityDescriptorSddlForm(s_accessControlSections);
Console.WriteLine($" Directory: {s_programData}");
Console.WriteLine($"Descriptor: {descriptor}");
VerifyDirectorySecurityDescriptor(s_cacheRootDirectory, "BA", "BA", 4, "A;OICI;0x1200a9;;;WD", "A;OICI;FA;;;SY", "A;OICI;FA;;;BA", "A;OICI;0x1200a9;;;BU");
VerifyDirectorySecurityDescriptor(s_workloadsCacheDirectory, "BA", "BA", 4, "A;OICIID;0x1200a9;;;WD", "A;OICIID;FA;;;SY", "A;OICIID;FA;;;BA", "A;OICIID;0x1200a9;;;BU");
VerifyDirectorySecurityDescriptor(s_workloadPackCacheDirectory, "BA", "BA", 4, "A;OICIID;0x1200a9;;;WD", "A;OICIID;FA;;;SY", "A;OICIID;FA;;;BA", "A;OICIID;0x1200a9;;;BU");
VerifyFileSecurityDescriptor(s_cachedTestAssetPath, "BA", "BA", 4, "A;ID;0x1200a9;;;WD", "A;ID;FA;;;SY", "A;ID;FA;;;BA", "A;ID;0x1200a9;;;BU");
}
private static void CreateInstallStateAsset()
{
SecurityUtils.CreateSecureDirectory(s_installStateDirectory);
File.WriteAllLines(s_installStateFileAssetPath, new[] { "line1", "line2" });
SecurityUtils.SecureFile(s_installStateFileAssetPath);
}
private static void VerifyInstallStateDescriptors()
{
// Dump the descriptor of ProgramData since it's useful for analyzing.
DirectorySecurity ds = new DirectorySecurity(s_programData, s_accessControlSections);
string descriptor = ds.GetSecurityDescriptorSddlForm(s_accessControlSections);
Console.WriteLine($" Directory: {s_programData}");
Console.WriteLine($"Descriptor: {descriptor}");
VerifyDirectorySecurityDescriptor(s_installStateDirectory, "BA", "BA", 4, "A;OICIID;0x1200a9;;;WD", "A;OICIID;FA;;;SY", "A;OICIID;FA;;;BA", "A;OICIID;0x1200a9;;;BU");
VerifyFileSecurityDescriptor(s_installStateFileAssetPath, "BA", "BA", 4, "A;ID;0x1200a9;;;WD", "A;ID;FA;;;SY", "A;ID;FA;;;BA", "A;ID;0x1200a9;;;BU");
}
static void Main(string[] args)
{
if (!OperatingSystem.IsWindows())
{
Console.Error.WriteLine("This test is only applicable to Windows.");
Environment.Exit(-1);
}
WindowsIdentity identity = WindowsIdentity.GetCurrent();
Console.WriteLine($"Running tests as {identity.Name}, admin: {IsAdministrator()}, system: {identity.IsSystem}");
if (IsAdministrator())
{
if (args.Length > 0 && args[0] == "elevate")
{
// SCENARIO 1B: The installer packages from the user's temp directory are being moved to
// the package cache under ProgramData through an elevated process.
try
{
RelocateAndSecureAsset();
CreateInstallStateAsset();
}
catch
{
// Return an error if we couldn't move and secure the assets.
Environment.Exit(-2);
}
}
else if (args.Length == 0)
{
try
{
// SCENARIO 2: Full test is running as administrator. This is similar to user running
// a dotnet workload command from an administrator prompt, local SYSTEM or running inside
// Windows Sandbox.
CreateTestAsset();
RelocateAndSecureAsset();
VerifyDescriptors();
CreateInstallStateAsset();
VerifyInstallStateDescriptors();
}
catch (Exception e)
{
WriteError(e.Message);
Environment.Exit(-1);
}
}
else
{
// Invalid scenario
WriteError("Invalid scenario!");
Environment.Exit(-1);
}
}
else
{
// SCENARIO 1A: The user is running with normal privileges. Workload packages are downloaded to
// the user's temp directory before the CLI elevates and moves the installers into a secured cache
// under ProgramData.
try
{
CreateTestAsset();
// Launch the elevated portion of the test.
ProcessStartInfo startInfo = new($@"""{Environment.ProcessPath}""",
$@"""{Assembly.GetExecutingAssembly().Location}"" elevate")
{
Verb = "runas",
UseShellExecute = true,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden
};
Process p = new()
{
StartInfo = startInfo,
};
if (p.Start())
{
p.WaitForExit();
if (p.ExitCode != 0)
{
WriteError($"Elevated process exited with {p.ExitCode}");
Environment.Exit(-1);
}
VerifyDescriptors();
VerifyInstallStateDescriptors();
}
else
{
WriteError("Failed to start elevated process.");
Environment.Exit(-1);
}
}
catch (Exception e)
{
WriteError(e.Message);
Environment.Exit(-1);
}
}
}
}
}
|