diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9dfab2e..058ce22 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,6 +17,17 @@ env: AWS_REGION: eu-west-1 jobs: + windows-private-files: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Verify Windows credential file ACLs + run: uv run --with pytest pytest --noconftest -q tests/test_private_files.py + test: runs-on: ubuntu-latest steps: diff --git a/src/dapier/private_files.py b/src/dapier/private_files.py index 022ede6..128cabd 100644 --- a/src/dapier/private_files.py +++ b/src/dapier/private_files.py @@ -5,9 +5,18 @@ def _windows_acl(path, script): env = dict(os.environ, DAPIER_PRIVATE_PATH=os.fspath(path)) + # A Python child of PowerShell 7 inherits its module paths. Windows + # PowerShell must load its own Security module, not the incompatible + # version from the parent shell's PSModulePath. + bootstrap = ( + "$ErrorActionPreference = 'Stop'; " + "Import-Module (Join-Path $PSHOME " + "'Modules\\Microsoft.PowerShell.Security\\Microsoft.PowerShell.Security.psd1') " + "-ErrorAction Stop; " + ) return subprocess.run( ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", - "$ErrorActionPreference = 'Stop'; " + script], + bootstrap + script], env=env, capture_output=True, text=True, check=True, ).stdout.strip() diff --git a/tests/test_private_files.py b/tests/test_private_files.py index 2a12e41..9b49e43 100644 --- a/tests/test_private_files.py +++ b/tests/test_private_files.py @@ -20,13 +20,38 @@ def test_shared_token_is_rejected(tmp_path): protect(path) if os.name == "nt": _windows_acl(path, """ - $acl = Get-Acl -LiteralPath $env:DAPIER_PRIVATE_PATH + $acl = [System.IO.File]::GetAccessControl($env:DAPIER_PRIVATE_PATH, [System.Security.AccessControl.AccessControlSections]::Access) $sid = New-Object System.Security.Principal.SecurityIdentifier('S-1-1-0') $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'Read', 'Allow') $acl.AddAccessRule($rule) - Set-Acl -LiteralPath $env:DAPIER_PRIVATE_PATH -AclObject $acl + [System.IO.File]::SetAccessControl($env:DAPIER_PRIVATE_PATH, $acl) """) else: os.chmod(path, 0o644) with pytest.raises(ValueError, match="owner-only"): require_private(path) + + +@pytest.mark.skipif(os.name != "nt", reason="Windows PowerShell module resolution") +def test_acl_ignores_incompatible_inherited_security_module(tmp_path, monkeypatch): + """A parent shell's module directory must not break credential privacy.""" + modules = tmp_path / "parent-shell-modules" + security = modules / "Microsoft.PowerShell.Security" + security.mkdir(parents=True) + (security / "Microsoft.PowerShell.Security.psd1").write_text( + "@{ ModuleVersion = '99.0.0'; RootModule = 'incompatible.psm1'; " + "GUID = 'ee0c3554-2ffd-4474-b7eb-ad6cef54dce8' }" + ) + (security / "incompatible.psm1").write_text( + "throw 'The parent shell Security module cannot run in this process'" + ) + monkeypatch.setenv( + "PSModulePath", str(modules) + os.pathsep + os.environ.get("PSModulePath", "") + ) + path = tmp_path / "token with spaces and 'quotes'.txt" + path.write_text("synthetic credential") + + protect(path) + require_private(path) + + assert path.read_text() == "synthetic credential"