Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1ea71ee
ci: add Homebrew install validation workflow
Copilot Jul 1, 2026
3e51772
Merge pull request #1 from Pjrich1313/copilot/fix-issue
Pjrich1313 Jul 1, 2026
843c013
Replace deprecated macos-13 runner with macos-14 in workflow matrix
Copilot Jul 1, 2026
b84cfa7
Update Homebrew workflow to macos-14 runner
Copilot Jul 1, 2026
8bc3da7
Align matrix architecture metadata for macos-14
Copilot Jul 1, 2026
9ddfa1d
Merge pull request #3 from Pjrich1313/copilot/add-error-handling
Pjrich1313 Jul 1, 2026
935581c
Replace project name with pamela
Pjrich1313 Jul 1, 2026
baf96fd
Add initial devcontainer configuration
Pjrich1313 Jul 2, 2026
4d9f897
Add "Created by Pamela Richardson" attribution to README
Pjrich1313 Jul 2, 2026
277f87e
Initial plan
Copilot Jul 2, 2026
22c4bc9
fix: accept 429 responses in link checker to avoid GitLab rate limit …
Copilot Jul 2, 2026
f3eb863
Merge pull request #10 from Pjrich1313/copilot/remove-14-line-error
Pjrich1313 Jul 2, 2026
7a81a48
fix: accept 411 responses in link checker to handle Length Required e…
Copilot Jul 2, 2026
41752b2
Merge pull request #11 from Pjrich1313/copilot/add-readme-and-remove-…
Pjrich1313 Jul 2, 2026
2951d0c
Merge pull request #9 from Pjrich1313/copilot/update-readme-attribution
Pjrich1313 Jul 2, 2026
c76baad
fix: prevent path traversal in PlaintextCredentialStore and argument …
Copilot Jul 2, 2026
04be29a
refactor: simplify safeAccount logic and improve test naming
Copilot Jul 2, 2026
1d610de
fix: harden GetSafeAccountFileName against null bytes and reserved pa…
Copilot Jul 2, 2026
d8730fa
fix: throw on null/empty account, escape quotes in Gpg.cs NETFRAMEWOR…
Copilot Jul 2, 2026
af37506
Merge pull request #2 from Pjrich1313/copilot/84544821111-fix-issue-i…
Pjrich1313 Jul 2, 2026
39bd15c
Merge pull request #12 from Pjrich1313/copilot/fix-credential-manager…
Pjrich1313 Jul 3, 2026
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
4 changes: 4 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"image": "mcr.microsoft.com/devcontainers/universal:2",
"features": {}
}
Comment on lines +1 to +4
1 change: 1 addition & 0 deletions .github/workflows/lint-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/validate-install-homebrew.yml
Original file line number Diff line number Diff line change
@@ -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"
34 changes: 19 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +7 to 8
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]
Expand All @@ -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].

Expand All @@ -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].

Expand All @@ -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.
Expand Down
115 changes: 115 additions & 0 deletions src/shared/Core.Tests/PlaintextCredentialStoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

}
}
21 changes: 19 additions & 2 deletions src/shared/Core/Gpg.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,21 @@ 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,
// Suppress verbose decryption messages
// 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);

Expand Down Expand Up @@ -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);

Expand Down
32 changes: 30 additions & 2 deletions src/shared/Core/PlaintextCredentialStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -143,7 +145,8 @@ private IEnumerable<FileCredential> 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) &&
Expand Down Expand Up @@ -186,6 +189,31 @@ private void EnsureStoreRoot()
Interop.Posix.Native.Stat.chmod(StoreRoot, mode);
}

/// <summary>
/// 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.
/// </summary>
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();
Expand Down