Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,14 @@ urlencoding = { version = "2.1" }
which = { version = "8.0.4" }
# dsc-lib
ipnetwork = { version = "0.21" }
# WindowsUpdate, windows_service
# WindowsUpdate, windows_service, dsc-lib (ACL checks)
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_NetworkManagement_WindowsFirewall",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_System_Com",
"Win32_System_Memory",
"Win32_System_Ole",
"Win32_System_Services",
"Win32_System_Variant",
Expand Down
88 changes: 88 additions & 0 deletions dsc/tests/dsc_settings_policy_security.tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

Describe 'tests for policy folder security validation' {
BeforeDiscovery {
$isElevated = if ($IsWindows) {
([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
} else {
(id -u) -eq 0
}
}

Context 'Windows policy folder with insecure ACL emits warning' -Skip:(!$IsWindows -or !$isElevated) {
BeforeAll {
$script:policyDirPath = Join-Path $env:ProgramData "dsc"
$script:policyFilePath = Join-Path $script:policyDirPath "dsc.settings.json"
$script:existedBefore = Test-Path $script:policyDirPath

if (-not $script:existedBefore) {
New-Item -ItemType Directory -Path $script:policyDirPath -Force | Out-Null
}
Comment thread
SteveL-MSFT marked this conversation as resolved.

# Write a simple policy settings file
@{ tracing = @{ level = "TRACE" } } | ConvertTo-Json -Depth 5 | Set-Content -Path $script:policyFilePath

# Grant Everyone write access to make the folder insecure
icacls $script:policyDirPath /grant "Everyone:(OI)(CI)(W)" /T | Out-Null
}
Comment thread
SteveL-MSFT marked this conversation as resolved.

AfterAll {
# Remove the Everyone ACE to restore security
icacls $script:policyDirPath /remove "Everyone" /T | Out-Null

Comment thread
SteveL-MSFT marked this conversation as resolved.
Outdated
Remove-Item -Path $script:policyFilePath -ErrorAction SilentlyContinue
if (-not $script:existedBefore) {
Remove-Item -Recurse -Force -Path $script:policyDirPath -ErrorAction SilentlyContinue
}
}

It 'Should emit a warning about insecure policy folder' {
dsc -l warn resource list 2> $TestDrive/tracing.txt
"$TestDrive/tracing.txt" | Should -FileContentMatch "is not secure, settings file will not be used"
}

It 'Should not exit with an error code' {
dsc -l warn resource list 2> $TestDrive/tracing.txt
$LASTEXITCODE | Should -Be 0
}
}

Context 'Linux policy folder with insecure permissions emits warning' -Skip:($IsWindows -or !$isElevated) {
Comment thread
Copilot marked this conversation as resolved.
Outdated
BeforeAll {
$script:policyDirPath = "/etc/dsc"
$script:policyFilePath = Join-Path $script:policyDirPath "dsc.settings.json"
$script:existedBefore = Test-Path $script:policyDirPath

if (-not $script:existedBefore) {
New-Item -ItemType Directory -Path $script:policyDirPath -Force | Out-Null
}
Comment thread
SteveL-MSFT marked this conversation as resolved.

# Write a simple policy settings file
@{ tracing = @{ level = "TRACE" } } | ConvertTo-Json -Depth 5 | Set-Content -Path $script:policyFilePath

# Make the folder world-writable (insecure)
chmod 777 $script:policyDirPath
}

AfterAll {
chmod 755 $script:policyDirPath

Comment thread
SteveL-MSFT marked this conversation as resolved.
Remove-Item -Path $script:policyFilePath -ErrorAction SilentlyContinue
if (-not $script:existedBefore) {
Remove-Item -Recurse -Force -Path $script:policyDirPath -ErrorAction SilentlyContinue
}
}

It 'Should emit a warning about insecure policy folder' {
dsc -l warn resource list 2> $TestDrive/tracing.txt
"$TestDrive/tracing.txt" | Should -FileContentMatch "is not secure, settings file will not be used"
}

It 'Should not exit with an error code' {
dsc -l warn resource list 2> $TestDrive/tracing.txt
$LASTEXITCODE | Should -Be 0
}
}
}
1 change: 1 addition & 0 deletions lib/dsc-lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ tree-sitter-dscexpression = { workspace = true }

[target.'cfg(windows)'.dependencies]
registry = { workspace = true }
windows = { workspace = true }

[dev-dependencies]
serde_yaml = { workspace = true }
Expand Down
3 changes: 3 additions & 0 deletions lib/dsc-lib/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,9 @@ settingNotFound = "Setting '%{name}' not found"
failedToAbsolutizePath = "Failed to absolutize path '%{path}'"
executableNotFoundInWorkingDirectory = "Executable '%{executable}' not found with working directory '%{cwd}'"
executableNotFound = "Executable '%{executable}' not found"
policyFolderNotSecure = "Policy folder '%{path}' is not secure, settings file will not be used. Required permissions: %{required}"
policyFolderNotSecureLinux = "Only root should have write access (no group/other write bits)"
policyFolderNotSecureWindows = "Only SYSTEM and Administrators should have write access"

[types.date_version]
invalidDay = "day `%{day}` for month `%{month}` is invalid - must be between `01` and `%{max_days}`"
Expand Down
136 changes: 131 additions & 5 deletions lib/dsc-lib/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::{
path::{Path, PathBuf},
env,
};
use tracing::debug;
use tracing::{debug, warn};
use which::which;

pub struct DscSettingValue {
Expand Down Expand Up @@ -202,17 +202,143 @@ pub fn get_exe_path() -> Result<PathBuf, DscError> {
fn get_settings_policy_file_path() -> String
{
// $env:ProgramData+"\dsc\dsc.settings.json"
// This location is writable only by admins, but readable by all users
let Ok(local_program_data_path) = std::env::var("ProgramData") else { return String::new(); };
Path::new(&local_program_data_path).join("dsc").join("dsc.settings.json").display().to_string()
let dsc_folder = Path::new(&local_program_data_path).join("dsc");
let settings_path = dsc_folder.join("dsc.settings.json").display().to_string();

if !dsc_folder.exists() {
return settings_path;
}

if !verify_windows_acl(&dsc_folder) {
let required = t!("util.policyFolderNotSecureWindows", path = dsc_folder.display());
warn!("{}", t!("util.policyFolderNotSecure", path = dsc_folder.display(), required = required));
return String::new();
}
Comment thread
SteveL-MSFT marked this conversation as resolved.

settings_path
}

/// Returns `true` if only SYSTEM and Administrators have write access; `false` otherwise.
#[cfg(target_os = "windows")]
fn verify_windows_acl(dsc_folder: &Path) -> bool {
use windows::Win32::Security::{
Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT},
IsWellKnownSid, GetAce,
WinBuiltinAdministratorsSid, WinLocalSystemSid,
ACL, ACCESS_ALLOWED_ACE, PSECURITY_DESCRIPTOR,
};
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::core::PCWSTR;
use std::ptr;

const FILE_WRITE_DATA: u32 = 0x0002;
const FILE_APPEND_DATA: u32 = 0x0004;
const WRITE_DAC: u32 = 0x0004_0000;
const WRITE_OWNER: u32 = 0x0008_0000;
const WRITE_MASK: u32 = FILE_WRITE_DATA | FILE_APPEND_DATA | WRITE_DAC | WRITE_OWNER;
Comment thread
SteveL-MSFT marked this conversation as resolved.
Outdated

let folder_wide: Vec<u16> = dsc_folder
.to_string_lossy()
.encode_utf16()
.chain(std::iter::once(0))
.collect();

let mut p_sd: PSECURITY_DESCRIPTOR = PSECURITY_DESCRIPTOR(ptr::null_mut());
let mut p_dacl: *mut ACL = ptr::null_mut();

let result = unsafe {
GetNamedSecurityInfoW(
PCWSTR(folder_wide.as_ptr()),
SE_FILE_OBJECT,
windows::Win32::Security::DACL_SECURITY_INFORMATION,
None,
None,
Some(&mut p_dacl),
None,
&mut p_sd,
)
};

if result.is_err() {
return true;
}
Comment thread
SteveL-MSFT marked this conversation as resolved.

if p_dacl.is_null() {
unsafe { let _ = LocalFree(Some(HLOCAL(p_sd.0.cast()))); }
return true;
}
Comment thread
Copilot marked this conversation as resolved.

let ace_count = unsafe { (*p_dacl).AceCount };

for i in 0..ace_count {
let mut ace_ptr: *mut core::ffi::c_void = ptr::null_mut();
let ok = unsafe { GetAce(p_dacl, i as u32, &mut ace_ptr) };
if ok.is_err() {
continue;
}
Comment thread
SteveL-MSFT marked this conversation as resolved.

let header = unsafe { &*(ace_ptr as *const windows::Win32::Security::ACE_HEADER) };
// Only check ACCESS_ALLOWED_ACE_TYPE (0)
if header.AceType != 0 {
continue;
}
Comment thread
SteveL-MSFT marked this conversation as resolved.
Comment thread
SteveL-MSFT marked this conversation as resolved.
Outdated

let ace = unsafe { &*(ace_ptr as *const ACCESS_ALLOWED_ACE) };
if (ace.Mask & WRITE_MASK) == 0 {
continue;
}

// Get pointer to SID within the ACE (SidStart field)
let sid_ptr = &(*ace).SidStart as *const u32 as *const core::ffi::c_void;
let psid = windows::Win32::Security::PSID(sid_ptr as *mut core::ffi::c_void);

let is_system = unsafe { IsWellKnownSid(psid, WinLocalSystemSid).as_bool() };
let is_admin = unsafe { IsWellKnownSid(psid, WinBuiltinAdministratorsSid).as_bool() };

if !is_system && !is_admin {
unsafe { let _ = LocalFree(Some(HLOCAL(p_sd.0.cast()))); }
return false;
}
}

unsafe { let _ = LocalFree(Some(HLOCAL(p_sd.0.cast()))); }
true
}

#[cfg(not(target_os = "windows"))]
fn get_settings_policy_file_path() -> String
{
use std::os::unix::fs::MetadataExt;

// "/etc/dsc/dsc.settings.json"
// This location is writable only by admins, but readable by all users
Path::new("/etc").join("dsc").join("dsc.settings.json").display().to_string()
// This location is writable only by root, but readable by all users
let dsc_folder = Path::new("/etc").join("dsc");
let settings_path = dsc_folder.join("dsc.settings.json").display().to_string();

if !dsc_folder.exists() {
return settings_path;
}

let Ok(metadata) = fs::metadata(&dsc_folder) else {
return settings_path;
};
Comment thread
SteveL-MSFT marked this conversation as resolved.
Outdated

let mode = metadata.mode();
let uid = metadata.uid();

// Verify owner is root
// Verify no group or other write bits are set
let group_write = mode & 0o020;
let other_write = mode & 0o002;

if uid != 0 || group_write != 0 || other_write != 0 {
let required = t!("util.policyFolderNotSecureLinux");
warn!("{}", t!("util.policyFolderNotSecure", path = dsc_folder.display(), required = required));
return String::new();
}

settings_path
}

/// Generates a resource ID from the specified type and name.
Expand Down
Loading