Skip to content

Keyring migration fails silently when Keychain entry ACL doesn't match current binary (e.g. mise installs) #1267

@AGallouin

Description

@AGallouin

Environment

  • ggshield version: 1.51.0
  • Operating system (Linux, macOS, Windows): macOS (Apple Silicon)
  • Operating system version: Darwin 25.5.0
  • Python version: 3.14 (bundled with ggshield, installed via mise as part of a toolchain)

Describe the bug

When ggshield is installed via a tool version manager like mise (or asdf, pyenv, pipx, etc.), the binary path can change between installs or reshims. On macOS, Keychain entries have an ACL tied to the creating application path. If the path changes, the new binary gets error -25244 (errSecInvalidOwnerEdit) when trying to overwrite the existing entry.

The result is that:

  • is_available() returns True (the probe with __ggshield_probe__ always writes fine as a fresh entry)
  • store_token() silently fails on the real instance URL key due to the ACL mismatch
  • The token stays in cleartext in auth_config.yaml with no visible feedback to the user

This is not specific to Homebrew upgrades — it affects any installation method where the binary path is managed externally (mise, asdf, pipx, Docker volumes, CI runners, etc.).

Steps to reproduce:

  1. Install ggshield via mise and authenticate (ggshield auth login)
  2. Reinstall or reshim ggshield via mise (e.g. mise install, toolchain update)
  3. Run any ggshield command
  4. Check ~/.config/ggshield/auth_config.yaml

Actual result:

The token is still in cleartext in auth_config.yaml, and no warning or error is shown anywhere.

Expected result:

The token is migrated to the Keychain, or — when that is impossible — the user is told the token stays in cleartext and how to fix it.

Root cause

Two issues compound each other:

  1. is_available() probes with a dummy key __ggshield_probe__, which always succeeds since it's a fresh entry with no ACL conflict. It never detects that the real instance URL key is locked to a different binary path.

  2. _persist_to_keyring() catches the exception but only logs it at logger.warning level, invisible to the end user:

except Exception:
    logger.warning(
        "Failed to store token in keyring for %s, storing in config file",
        inst.url,
        exc_info=True,
    )
    fallback_urls.add(inst.url)

Proposed fixes

  1. Surface the error visibly with an actionable fix — warn on stderr that the token stays in plaintext, including the commands to recover:
security delete-generic-password -s ggshield -a '<instance url>'
ggshield auth login
  1. Detect the conflict where it matters — the dummy-key probe cannot catch per-entry ACL conflicts; the diagnosis should exercise the real instance URL keys.

  2. Add a diagnostic command reporting the credential-store backend, whether it is reachable, and where each instance's token actually lives (credential store vs cleartext config file), with a copy-pasteable fix when a token unexpectedly stays in plaintext.

  3. Notify the user after migration attempts — confirmation on success, warning on failure.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions