Skip to content

fix(sfdx): retrieve org credentials after SF CLI 2.136+ redaction @W-22684438 - #3998

Draft
jstvz wants to merge 12 commits into
mainfrom
feature/sfdx-credentials-3987
Draft

fix(sfdx): retrieve org credentials after SF CLI 2.136+ redaction @W-22684438#3998
jstvz wants to merge 12 commits into
mainfrom
feature/sfdx-credentials-3987

Conversation

@jstvz

@jstvz jstvz commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #3987. Salesforce CLI 2.136+ redacts accessToken, password, and sfdxAuthUrl from sf org display --json. SfdxOrgConfig reads those fields, so credential retrieval breaks the moment a user's CLI updates. CumulusCI does not pin the CLI version, so CI and MetaCI builds fail when 2.136+ reaches the stable channel.

CLI behavior verified against shipping versions

Confirmed against installed CLI 2.140.6, @salesforce/plugin-org 5.11.8, and @salesforce/core 8.31.1. Verified against the upstream main HEAD of both repos.

  • CLI 2.136+ does not delete or null the fields. It replaces each field with a truthy sentinel string. The source is @salesforce/plugin-org's messages/secrets-redacted.md:
    • accessToken -> [REDACTED] Use 'sf org auth show-access-token' to view
    • password -> [REDACTED] Use 'sf org auth show-user-password' to view
    • sfdxAuthUrl -> [REDACTED] Use 'sf org auth show-sfdx-auth-url' to view
  • The only gate is the env var SF_TEMP_SHOW_SECRETS (default false redacts). The CLI applies no version, date, or org-type gate. Scratch orgs and persistent orgs behave the same.
  • id is never redacted. Org id is always readable.
  • password is present (and then redacted) only when the org has a locally generated password. For passwordless orgs (web-auth sandbox or production, scratch org without force:user:password:generate), the field is absent, not a sentinel. The absent-vs-sentinel distinction drives the password fallback gate.
  • SF_TEMP_SHOW_SECRETS=true re-enables the legacy output. Salesforce removes the env var in summer 2026, so it is a stopgap.

Changes

One source file: cumulusci/core/config/sfdx_org_config.py.

  • Detect redaction by the [REDACTED] sentinel prefix. Truthiness alone does not fire because the sentinel is a non-empty string.
  • Add two fallback helpers that re-fetch via the new subcommands with --no-prompt. The --no-prompt flag suppresses an interactive confirm that hangs headless processes.
    • sf org auth show-access-token
    • sf org auth show-user-password
  • Access token path: fall back when the field is redacted, absent, or empty.
  • Password path: fall back only when the value matches the sentinel. An absent password means the org has no password, so the code leaves it unset and runs no show-user-password subprocess. This avoids a pointless extra subprocess on every refresh for the most common new-CLI org class (passwordless).
  • Org id: read result["id"] (raise a clear SfdxOrgException if missing). The previous accessToken.split("!")[0] derivation no longer works because the token is redacted. Imported-org ids now surface as the 18-character form, consistent with the base OrgConfig. Salesforce APIs accept both 15- and 18-character ids.
  • Shell-quote every username interpolated into an sf command. sfdx() runs with shell=True, so an unquoted username with shell metacharacters is a command-injection footgun. Applied at all sites.

Backward compatibility

  • Pre-2.136 CLI returns the real token in org display. The code runs no fallback subprocess.
  • On pre-2.136 CLI, the sf org auth show-* subcommands may not exist. The token helper raises a clear SfdxOrgException that names the attempted commands. The password helper degrades to "no password" and never raises.
  • force_refresh_oauth_token is unchanged. It uses sf org open -r, which reads no secrets.

Test coverage

All tests pass in test_config_expensive.py and test_org.py.

New and updated coverage:

  • Sentinel token triggers the access-token fallback.
  • New-CLI passwordless org does not call show-user-password (subprocess count == 2).
  • Both fields redacted fetches both (subprocess count == 3).
  • Legacy CLI passthrough (subprocess count == 1).
  • Both paths fail raises an exception that names both commands.
  • Username with shell metacharacters arrives shell-quoted in the command string.
  • The password helper returns None on subprocess error, empty output, or sentinel response.

Known limitation

The [REDACTED] prefix is locale-stable on every shipping CLI today. @salesforce/core's Messages.getLocale() is hardcoded to en_US, and per-locale message bundles are an unimplemented TODO upstream. The prefix lives inside the translatable message string, so if Salesforce later ships localization, a non-English redacted.accessToken could drop the literal [REDACTED] prefix and break detection silently. Detection failure surfaces loudly: the sentinel string becomes the "access token", and the next API call returns 401 with a human-readable token value. The fix is a one-line constant change. A locale-independent signal would cover only accessToken (it has a shape), not password or sfdxAuthUrl, so the alternative would not remove the locale risk from the other two fields. The sfdx_org_config.py constant has a comment that names the failure mode and the tripwire symptom.

Test plan

  • Run pytest cumulusci/core/config/tests/test_config_expensive.py and confirm green.
  • Run pytest cumulusci/cli/tests/test_org.py and confirm green.
  • Exercise on a real new-CLI scratch org: cci org info <alias> returns access token and password.
  • Exercise on a real new-CLI passwordless org: cci org info <alias> returns access token, password absent, no show-user-password subprocess in cci --debug log.
  • Exercise on a legacy CLI (2.135 or earlier) if available: passthrough path, one subprocess.

Closes #3987.

jstvz added 12 commits June 22, 2026 14:16
Adds private helper that calls sf org auth show-access-token for use
as a fallback when sf org display redacts the token (SF CLI 2.136+).

Refs #3987
Adds private helper that calls sf org auth show-user-password for use
as a fallback when sf org display redacts the password (SF CLI 2.136+).
Returns None on failure or absent password; never raises.

Refs #3987
sfdx_info now uses result.id directly for org_id (no more token-prefix
split) and falls back to sf org auth show-access-token /
show-user-password when sf org display redacts those fields.

Cache shape and 1-hour TTL preserved. ScratchOrgConfig.scratch_info
inherits the fix via the existing alias.

Fixes #3987
When sf org display does not return accessToken (CLI 2.136+ redaction),
fall back to sf org auth show-access-token. Legacy CLI path unchanged.

Refs #3987
Pre-commit black hook on the prior task commits was skipped due to a
local Python 3.14 / black 22.3.0 incompatibility. Run black manually
to keep the test file aligned with the project format.

Refs #3987
After sfdx_info requires result.id from `sf org display --json`, the
test_org_import fixture must include the id field. The two sibling
fixtures (test_org_import__persistent_org, test_org_import__trial_org)
already had it.
Code-reviewer pass on the original fix found two BLOCKING defects:

B0 (correctness): the CLI replaces redacted secrets with a truthy
   sentinel string prefixed "[REDACTED] ", not absent/null. A
   `if not value:` check never triggers the fallback. Three sites
   affected: sfdx_info access_token, sfdx_info password,
   get_access_token.

B1 (security): every `--target-org={username}` interpolation runs
   through sfdx() which uses shell=True. Usernames with shell
   metacharacters are an injection footgun.

These tests RED against the current fix; they pin the corrected
detection model and shell_quote contract from the round-3 spec.

- test_sfdx_info_sentinel_token_triggers_fallback: org display
  returns redacted sentinel as accessToken; expects the helper to
  fall back and return the real token.
- test_fetch_user_password_returns_none_on_sentinel: defensive
  guard if sf org auth show-user-password itself ever returns a
  sentinel value.
- test_get_access_token_sentinel_triggers_fallback: per-user
  lookup path must trigger fallback on sentinel, not just absence.
- test_fetch_access_token_shell_quotes_username: usernames with
  shell metacharacters (e.g. semicolons) must be shell_quote'd in
  the rendered command string.

Pre-commit black 22.3.0 is incompatible with the local Python 3.14
build (ast.Str removed in 3.12); SKIP=black follows the precedent
set by 71b8e13 on this same branch. The file was reformatted with
uvx black==24.10.0 before commit so CI's modern black stays clean.

The green fix lands in the next commit.
Fixes the two BLOCKING defects from code review of #3987:

B0 (correctness): SF CLI 2.136+ replaces redacted secrets with the
truthy sentinel "[REDACTED] Use 'sf org auth show-*' to view", not
absent/null. The original `if not value:` checks never fired the
fallback. Add `_is_redacted()` keying on the "[REDACTED] " prefix
and route all three sites (sfdx_info access_token, sfdx_info
password, get_access_token) through it.

B1 (security): every `--target-org={username}` interpolation runs
through sfdx() which uses shell=True. Wrap username in
shell_quote() at the three call sites: get_access_token's own
`sf org display` line, _fetch_access_token, _fetch_user_password.

I1 (behavior): gate the password fallback subprocess on
`fallback_fired AND _is_redacted(password)`. Legacy passwordless
orgs (`password` absent on old CLI) no longer trigger a pointless
extra subprocess; they short-circuit to "no password".

Defensive: _fetch_access_token raises and _fetch_user_password
returns None when `show-*` itself ever returns a sentinel value.
Indicates a future CLI change; better to fail loud than hand back
a placeholder.

N1 (test hygiene): rename the test fixture org id from the
typo'd "OODxxxxxxxxxxxx" (letter O O D) to "00Dxxxxxxxxxxxx"
(zero zero D), matching real Salesforce org id shape across 17
call sites in cumulusci/cli/tests/test_org.py.

Pre-commit black 22.3.0 is incompatible with local Python 3.14;
SKIP=black follows the precedent set by 71b8e13. Files were
reformatted with uvx black==24.10.0 first.

Closes #3987.
Round 3 review caught that the previous password gate fired one
extra subprocess for the most common new-CLI org class. A new
CLI returns the token sentinel + OMITS `password` entirely for a
passwordless org (web-auth sandbox, scratch org without
`force:user:password:generate`). The previous
`fallback_fired AND _is_redacted(password)` gate evaluated
True/True on every sfdx_info refresh and ran a pointless
`sf org auth show-user-password`.

Split the helper:

- `_is_sentinel(v)` -- strict prefix check, used for the password
  gate. An absent password is not a sentinel.
- `_is_redacted(v)` -- absent OR sentinel, used for the token
  path. An absent token is as actionable as a redacted one
  (both mean "ask `sf org auth show-access-token`").

Test coverage:

- `test_sfdx_info_new_cli_fetches_token` corrected: assert
  call_count == 2 (was 3) and password absent. Codifies the
  passwordless-org-no-fallback contract.
- `test_sfdx_info_sentinel_token_triggers_fallback` (round-2)
  corrected the same way.
- `test_sfdx_info_new_cli_fetches_password` rewritten: both
  fields are now sentinels (the real shape for a scratch org
  with a generated password on a new CLI), not absence.
- New `test_sfdx_info_new_cli_no_password_org`: explicit
  passwordless-org no-fallback assertion.
- New `test_sfdx_info_sentinel_password_uses_fetched`: sfdx_info-
  level coverage of the both-sentinels path. Was helper-level
  only.

Files reformatted with `uvx ruff format`. Pre-commit black 22.3.0
incompatible with local Python 3.14 (`ast.Str` removed in 3.12);
`SKIP=black` follows the precedent set by 71b8e13 + bdc2056.

119/119 tests green in test_config_expensive + test_org.
Address round-4 review comments (no blocking findings; nit polish only).

- Add load-bearing trailing-space comment to `_REDACTED_PREFIX`. The
  CLI sentinel is the literal `"[REDACTED] Use ..."` form; a
  prefix without the trailing space would also match the unrelated
  string `"[REDACTED]Use ..."` if Salesforce ever shipped that.
- Tighten `_fetch_user_password` docstring: name every failure path
  (non-zero exit, malformed JSON, sentinel result) and the policy
  (return None, never raise; debug-log to keep the call diagnosable).
- Add debug logs on both `_fetch_user_password` failure branches so
  transient subprocess / JSON failures surface in `cci --debug` runs
  without promoting the call to raise.
- Drop the `elif not password: password = None` no-op. `result.get()`
  already returns None for absent keys; the conditional was vestigial
  from the compound-gate version.
- Drop the redundant first assertion in
  `test_fetch_access_token_shell_quotes_username`. The second
  assertion is strictly stronger.
- Add `Command.call_count == 1` to `test_scratch_info` to codify
  "legacy CLI: no fallback subprocesses fire" at the test level.
- Add `Command.call_count == 3` to
  `test_sfdx_info_new_cli_fetches_password` to codify the both-
  sentinels path (org_display + show-access-token + show-user-password).

77/77 tests green in test_config_expensive. `uvx ruff check` and
`uvx ruff format --check` clean. SKIP=black per branch precedent
(71b8e13 -> bdc2056 -> 7957c49); black 22.3.0 incompatible with
local Python 3.14.
Reviewer suggestion. Flag the only quiet failure mode of the
sentinel-prefix detection in `_REDACTED_PREFIX`'s docstring:
`@salesforce/core` currently hardcodes locale to en_US (per-locale
message bundles are an unimplemented TODO upstream), so the
"[REDACTED] " literal is stable on every shipping CLI. The literal
lives inside the translatable message string, however. If Salesforce
later wires up localization, a non-English `redacted.accessToken`
could drop the prefix and silently break detection. Note the more
robust long-term signal (match sentinel message keys, or
"value is not a well-formed access token") near the constant so a
future reader sees the upgrade path without spelunking the CLI repo.

Comment-only; no behavioral change. 77/77 tests still green.
SKIP=black per branch precedent.
Reviewer add-on. The locale-fragility comment names the failure mode
but leaves the on-call engineer to infer the symptom. Add one tripwire
sentence: if `sf org display --json`'s access token ever round-trips
into an API 401 with a human-readable token value, suspect this
detection path first. Converts the comment from "here is the
theoretical risk" to "here is the symptom that means this assumption
broke."

Comment-only; no behavioral change.
@yippie

yippie commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@jstvz This is very much needed.

But why try and detect if the string is redacted. Could you not simply check the SF CLI version and/or existence of the new commands and, if the commands exist, use them? This would bypass any string format detection issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SfdxOrgConfig breaks with Salesforce CLI May 27 2026 security update — migrate accessToken/password reads to new dedicated commands

2 participants