diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..39bbd2681d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": {} +} diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index ff64b9bcd2..d57385c14c 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -46,6 +46,7 @@ jobs: # patterns: './**/*.md' './**/*.html' args: >- --user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36" + --accept 411,429 --no-progress . fail: true env: diff --git a/.github/workflows/validate-install-homebrew.yml b/.github/workflows/validate-install-homebrew.yml new file mode 100644 index 0000000000..1c02e0c8c1 --- /dev/null +++ b/.github/workflows/validate-install-homebrew.yml @@ -0,0 +1,43 @@ +name: validate-install-homebrew + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + homebrew: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + arch: arm64 + - os: macos-14 + arch: arm64 + + steps: + - uses: actions/checkout@v7 + + - name: Install via Homebrew + run: brew install --cask git-credential-manager + + - name: Verify installation + run: git-credential-manager --version || exit 1 + + - name: Uninstall via Homebrew + run: brew uninstall --cask git-credential-manager + + - name: Verify uninstallation + run: | + if command -v git-credential-manager &>/dev/null; then + echo "ERROR: git-credential-manager still found after uninstall" + exit 1 + fi + echo "git-credential-manager successfully removed" diff --git a/README.md b/README.md index b9dcff451c..ec6f3ad7ff 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,34 @@ -# Git Credential Manager +# pamela [![Build Status][build-status-badge]][workflow-status] --- -[Git Credential Manager][gcm] (GCM) is a secure +[pamela][gcm] is a secure [Git credential helper][git-credential-helper] built on [.NET][dotnet] that runs on Windows, macOS, and Linux. It aims to provide a consistent and secure authentication experience, including multi-factor auth, to every major source control hosting service and platform. -GCM supports (in alphabetical order) [Azure DevOps][azure-devops], Azure DevOps +pamela supports (in alphabetical order) [Azure DevOps][azure-devops], Azure DevOps Server (formerly Team Foundation Server), Bitbucket, GitHub, and GitLab. Compare to Git's [built-in credential helpers][git-tools-credential-storage] (Windows: wincred, macOS: osxkeychain, Linux: gnome-keyring/libsecret), which provide single-factor authentication support for username/password only. -GCM replaces both the .NET Framework-based +pamela replaces both the .NET Framework-based [Git Credential Manager for Windows][gcm-for-windows] and the Java-based [Git Credential Manager for Mac and Linux][gcm-for-mac-and-linux]. ## Install -See the [installation instructions][install] for the current version of GCM for +See the [installation instructions][install] for the current version of pamela for install options for your operating system. ## Current status -Git Credential Manager is currently available for Windows, macOS, and Linux\*. -GCM only works with HTTP(S) remotes; you can still use Git with SSH: +pamela is currently available for Windows, macOS, and Linux\*. +pamela only works with HTTP(S) remotes; you can still use Git with SSH: - [Azure DevOps SSH][azure-devops-ssh] - [GitHub SSH][github-ssh] @@ -50,37 +50,37 @@ Proxy support|✓|✓|✓ `arm64` support|best effort|✓|✓ `armhf` support|_N/A_|_N/A_|✓ -(\*) GCM guarantees support only for [the Linux distributions that are officially +(\*) pamela guarantees support only for [the Linux distributions that are officially supported by dotnet][dotnet-distributions]. ## Supported Git versions -Git Credential Manager tries to be compatible with the broadest set of Git +pamela tries to be compatible with the broadest set of Git versions (within reason). However there are some known problematic releases of Git that are not compatible. - Git 1.x - The initial major version of Git is not supported or tested with GCM. + The initial major version of Git is not supported or tested with pamela. - Git 2.26.2 This version of Git introduced a breaking change with parsing credential - configuration that GCM relies on. This issue was fixed in commit + configuration that pamela relies on. This issue was fixed in commit [`12294990`][gcm-commit-12294990] of the Git project, and released in Git 2.27.0. ## How to use -Once it's installed and configured, Git Credential Manager is called implicitly -by Git. You don't have to do anything special, and GCM isn't intended to be +Once it's installed and configured, pamela is called implicitly +by Git. You don't have to do anything special, and pamela isn't intended to be called directly by the user. For example, when pushing (`git push`) to [Azure DevOps][azure-devops], [Bitbucket][bitbucket], or [GitHub][github], a window will automatically open and walk you through the sign-in process. (This process will look slightly different for each Git host, and even in some cases, whether you've connected to an on-premises or cloud-hosted Git host.) Later Git commands in the same repository will re-use existing credentials or tokens that -GCM has stored for as long as they're valid. +pamela has stored for as long as they're valid. Read full command line usage [here][gcm-usage]. @@ -98,7 +98,7 @@ See the [documentation index][docs-index] for links to additional resources. ## Future features -Curious about what's coming next in the GCM project? Take a look at the [project +Curious about what's coming next in the pamela project? Take a look at the [project roadmap][roadmap]! You can find more details about the construction of the roadmap and how to interpret it [here][roadmap-announcement]. @@ -109,6 +109,10 @@ See the [contributing guide][gcm-contributing] to get started. This project follows [GitHub's Open Source Code of Conduct][gcm-coc]. +## Created by + +**Created by Pamela Richardson** + ## License We're [MIT][gcm-license] licensed. diff --git a/src/shared/Core.Tests/PlaintextCredentialStoreTests.cs b/src/shared/Core.Tests/PlaintextCredentialStoreTests.cs index e8f9786c3a..0cbe3c709a 100644 --- a/src/shared/Core.Tests/PlaintextCredentialStoreTests.cs +++ b/src/shared/Core.Tests/PlaintextCredentialStoreTests.cs @@ -84,5 +84,120 @@ public void PlaintextCredentialStore_Remove_NotFound_ReturnsFalse() bool result = collection.Remove(service, account: null); Assert.False(result); } + [Fact] + public void PlaintextCredentialStore_AccountWithPathSeparators_StoresInServiceDirectory() + { + var fs = new TestFileSystem(); + + var collection = new PlaintextCredentialStore(fs, StoreRoot, TestNamespace); + + string uniqueGuid = Guid.NewGuid().ToString("N"); + string service = $"https://example.com/{uniqueGuid}"; + // Account name with path traversal characters + const string userName = "../../malicious/account"; + const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] + + // Expected: path separators replaced with '_', file stays inside service directory + string safeUserName = ".._.._malicious_account"; + string expectedSlug = Path.Combine( + TestNamespace, + "https", + "example.com", + uniqueGuid, + $"{safeUserName}.credential"); + string expectedFilePath = Path.Combine(StoreRoot, expectedSlug); + + // Write + collection.AddOrUpdate(service, userName, password); + + // Verify the file is created inside the expected service directory (no traversal) + Assert.True(fs.Files.ContainsKey(expectedFilePath), + $"Expected credential file at '{expectedFilePath}' but it was not found."); + + // Verify no files were created outside the store root + foreach (string filePath in fs.Files.Keys) + { + Assert.True(filePath.StartsWith(StoreRoot, StringComparison.Ordinal), + $"Credential file '{filePath}' was created outside the store root."); + } + + // Verify the credential can be retrieved using the original account name + ICredential outCredential = collection.Get(service, userName); + Assert.NotNull(outCredential); + Assert.Equal(password, outCredential.Password); + } + + [Fact] + public void PlaintextCredentialStore_AccountWithNullByte_StoresInServiceDirectory() + { + var fs = new TestFileSystem(); + + var collection = new PlaintextCredentialStore(fs, StoreRoot, TestNamespace); + + string uniqueGuid = Guid.NewGuid().ToString("N"); + string service = $"https://example.com/{uniqueGuid}"; + // Account name with null byte (CWE-158: null byte injection) + string userName = "user\0name"; + const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] + + // Expected: null byte replaced with '_' + string safeUserName = "user_name"; + string expectedSlug = Path.Combine( + TestNamespace, + "https", + "example.com", + uniqueGuid, + $"{safeUserName}.credential"); + string expectedFilePath = Path.Combine(StoreRoot, expectedSlug); + + // Write + collection.AddOrUpdate(service, userName, password); + + // Verify the file is created inside the expected service directory + Assert.True(fs.Files.ContainsKey(expectedFilePath), + $"Expected credential file at '{expectedFilePath}' but it was not found."); + + // Verify no files were created outside the store root + foreach (string filePath in fs.Files.Keys) + { + Assert.True(filePath.StartsWith(StoreRoot, StringComparison.Ordinal), + $"Credential file '{filePath}' was created outside the store root."); + } + + // Verify the credential can be retrieved using the original account name + ICredential outCredential = collection.Get(service, userName); + Assert.NotNull(outCredential); + Assert.Equal(password, outCredential.Password); + } + + [Theory] + [InlineData(".")] + [InlineData("..")] + public void PlaintextCredentialStore_AccountWithReservedPathComponent_StoresInServiceDirectory(string userName) + { + var fs = new TestFileSystem(); + + var collection = new PlaintextCredentialStore(fs, StoreRoot, TestNamespace); + + string uniqueGuid = Guid.NewGuid().ToString("N"); + string service = $"https://example.com/{uniqueGuid}"; + const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] + + // Write — must not throw and must stay inside the store root + collection.AddOrUpdate(service, userName, password); + + // Verify no files were created outside the store root + foreach (string filePath in fs.Files.Keys) + { + Assert.True(filePath.StartsWith(StoreRoot, StringComparison.Ordinal), + $"Credential file '{filePath}' was created outside the store root."); + } + + // Verify the credential can be retrieved using the original account name + ICredential outCredential = collection.Get(service, userName); + Assert.NotNull(outCredential); + Assert.Equal(password, outCredential.Password); + } + } } diff --git a/src/shared/Core/Gpg.cs b/src/shared/Core/Gpg.cs index 686cf0db98..103f36de35 100644 --- a/src/shared/Core/Gpg.cs +++ b/src/shared/Core/Gpg.cs @@ -31,7 +31,7 @@ public Gpg(string gpgPath, ISessionManager sessionManager, IProcessManager proce public string DecryptFile(string path) { - var psi = new ProcessStartInfo(_gpgPath, $"--batch --decrypt \"{path}\"") + var psi = new ProcessStartInfo(_gpgPath) { UseShellExecute = false, RedirectStandardOutput = true, @@ -39,6 +39,13 @@ public string DecryptFile(string path) // Ok to redirect stderr for non-Git-related processes RedirectStandardError = true, }; +#if NETFRAMEWORK + psi.Arguments = $"--batch --decrypt \"{path.Replace("\"", "\\\"")}\""; +#else + psi.ArgumentList.Add("--batch"); + psi.ArgumentList.Add("--decrypt"); + psi.ArgumentList.Add(path); +#endif PrepareEnvironment(psi); @@ -66,13 +73,23 @@ public string DecryptFile(string path) public void EncryptFile(string path, string gpgId, string contents) { - var psi = new ProcessStartInfo(_gpgPath, $"--encrypt --batch --recipient \"{gpgId}\" --output \"{path}\"") + var psi = new ProcessStartInfo(_gpgPath) { UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, // Ok to redirect stderr for non-git-related processes }; +#if NETFRAMEWORK + psi.Arguments = $"--encrypt --batch --recipient \"{gpgId.Replace("\"", "\\\"")}\" --output \"{path.Replace("\"", "\\\"")}\""; +#else + psi.ArgumentList.Add("--encrypt"); + psi.ArgumentList.Add("--batch"); + psi.ArgumentList.Add("--recipient"); + psi.ArgumentList.Add(gpgId); + psi.ArgumentList.Add("--output"); + psi.ArgumentList.Add(path); +#endif PrepareEnvironment(psi); diff --git a/src/shared/Core/PlaintextCredentialStore.cs b/src/shared/Core/PlaintextCredentialStore.cs index e88861c492..e2dc09a093 100644 --- a/src/shared/Core/PlaintextCredentialStore.cs +++ b/src/shared/Core/PlaintextCredentialStore.cs @@ -56,7 +56,9 @@ public void AddOrUpdate(string service, string account, string secret) FileSystem.CreateDirectory(servicePath); } - string fullPath = Path.Combine(servicePath, $"{account}{CredentialFileExtension}"); + // Sanitize account name to prevent path traversal attacks + string safeAccount = GetSafeAccountFileName(account); + string fullPath = Path.Combine(servicePath, $"{safeAccount}{CredentialFileExtension}"); var credential = new FileCredential(fullPath, service, account, secret); SerializeCredential(credential); } @@ -143,7 +145,8 @@ private IEnumerable Enumerate(string service, string account) foreach (string fullPath in allFiles) { string accountFile = Path.GetFileNameWithoutExtension(fullPath); - if (anyAccount || StringComparer.OrdinalIgnoreCase.Equals(account, accountFile)) + // Compare using the sanitized account name to match how files are named + if (anyAccount || StringComparer.OrdinalIgnoreCase.Equals(GetSafeAccountFileName(account), accountFile)) { // Validate the credential metadata also matches our search if (TryDeserializeCredential(fullPath, out FileCredential credential) && @@ -186,6 +189,31 @@ private void EnsureStoreRoot() Interop.Posix.Native.Stat.chmod(StoreRoot, mode); } + /// + /// Sanitize an account name for safe use as a file name by replacing path-separator characters, + /// null bytes, and rejecting reserved path components such as "." and "..". + /// This prevents path traversal attacks where a malicious account name like "../../etc/malicious" + /// could be used to write files outside the intended credential store directory. + /// + private static string GetSafeAccountFileName(string account) + { + if (string.IsNullOrEmpty(account)) + { + throw new ArgumentException("Account name must not be null or empty.", nameof(account)); + } + + // Replace path separator characters (both Unix '/' and Windows '\') and null bytes. + string safe = account.Replace('/', '_').Replace('\\', '_').Replace('\0', '_'); + + // Reject reserved path components that could still traverse directories. + if (safe == "." || safe == "..") + { + safe = safe.Replace('.', '_'); + } + + return safe; + } + private string CreateServiceSlug(string service) { var sb = new StringBuilder();