bt-cli 0.4.53__tar.gz → 0.4.54__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. {bt_cli-0.4.53 → bt_cli-0.4.54}/CLAUDE.md +1 -1
  2. {bt_cli-0.4.53 → bt_cli-0.4.54}/PKG-INFO +1 -1
  3. {bt_cli-0.4.53 → bt_cli-0.4.54}/pyproject.toml +1 -1
  4. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/__init__.py +1 -1
  5. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/commands/configure.py +32 -8
  6. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/config_file.py +13 -8
  7. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/output.py +6 -5
  8. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/prompts.py +2 -1
  9. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/rest_debug.py +19 -11
  10. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/core/test_config.py +4 -2
  11. bt_cli-0.4.54/tests/core/test_config_file.py +67 -0
  12. bt_cli-0.4.54/tests/core/test_output.py +44 -0
  13. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/core/test_rest_debug.py +74 -0
  14. {bt_cli-0.4.53 → bt_cli-0.4.54}/.claude/skills/bt/SKILL.md +0 -0
  15. {bt_cli-0.4.53 → bt_cli-0.4.54}/.claude/skills/entitle/SKILL.md +0 -0
  16. {bt_cli-0.4.53 → bt_cli-0.4.54}/.claude/skills/epml/SKILL.md +0 -0
  17. {bt_cli-0.4.53 → bt_cli-0.4.54}/.claude/skills/epmw/SKILL.md +0 -0
  18. {bt_cli-0.4.53 → bt_cli-0.4.54}/.claude/skills/pra/SKILL.md +0 -0
  19. {bt_cli-0.4.53 → bt_cli-0.4.54}/.claude/skills/pws/SKILL.md +0 -0
  20. {bt_cli-0.4.53 → bt_cli-0.4.54}/.env.example +0 -0
  21. {bt_cli-0.4.53 → bt_cli-0.4.54}/.github/workflows/ci.yml +0 -0
  22. {bt_cli-0.4.53 → bt_cli-0.4.54}/.github/workflows/release.yml +0 -0
  23. {bt_cli-0.4.53 → bt_cli-0.4.54}/.gitignore +0 -0
  24. {bt_cli-0.4.53 → bt_cli-0.4.54}/README.md +0 -0
  25. {bt_cli-0.4.53 → bt_cli-0.4.54}/assets/cli-help.png +0 -0
  26. {bt_cli-0.4.53 → bt_cli-0.4.54}/assets/cli-output.png +0 -0
  27. {bt_cli-0.4.53 → bt_cli-0.4.54}/bt-cli.spec +0 -0
  28. {bt_cli-0.4.53 → bt_cli-0.4.54}/bt_entry.py +0 -0
  29. {bt_cli-0.4.53 → bt_cli-0.4.54}/epml-clients-server-side-filters-plan.md +0 -0
  30. {bt_cli-0.4.53 → bt_cli-0.4.54}/epml-implementation-plan.md +0 -0
  31. {bt_cli-0.4.53 → bt_cli-0.4.54}/pf-implementation-plan.md +0 -0
  32. {bt_cli-0.4.53 → bt_cli-0.4.54}/scripts/bt_entry.py +0 -0
  33. {bt_cli-0.4.53 → bt_cli-0.4.54}/scripts/pf_onboard.py +0 -0
  34. {bt_cli-0.4.53 → bt_cli-0.4.54}/scripts/sync-package-data.sh +0 -0
  35. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/cli.py +0 -0
  36. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/commands/__init__.py +0 -0
  37. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/commands/learn.py +0 -0
  38. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/commands/quick.py +0 -0
  39. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/__init__.py +0 -0
  40. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/auth.py +0 -0
  41. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/client.py +0 -0
  42. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/config.py +0 -0
  43. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/csv_utils.py +0 -0
  44. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/errors.py +0 -0
  45. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/CLAUDE.md +0 -0
  46. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/__init__.py +0 -0
  47. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/bt/SKILL.md +0 -0
  48. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/entitle/SKILL.md +0 -0
  49. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/epml/SKILL.md +0 -0
  50. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/epmw/SKILL.md +0 -0
  51. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/pra/SKILL.md +0 -0
  52. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/pws/SKILL.md +0 -0
  53. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/secrets/SKILL.md +0 -0
  54. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/__init__.py +0 -0
  55. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/client/__init__.py +0 -0
  56. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/client/base.py +0 -0
  57. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/__init__.py +0 -0
  58. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/accounts.py +0 -0
  59. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/applications.py +0 -0
  60. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/auth.py +0 -0
  61. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/bundles.py +0 -0
  62. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/integrations.py +0 -0
  63. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/permissions.py +0 -0
  64. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/policies.py +0 -0
  65. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/requests.py +0 -0
  66. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/resources.py +0 -0
  67. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/roles.py +0 -0
  68. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/users.py +0 -0
  69. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/workflows.py +0 -0
  70. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/__init__.py +0 -0
  71. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/bundle.py +0 -0
  72. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/common.py +0 -0
  73. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/integration.py +0 -0
  74. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/permission.py +0 -0
  75. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/policy.py +0 -0
  76. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/resource.py +0 -0
  77. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/role.py +0 -0
  78. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/user.py +0 -0
  79. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/workflow.py +0 -0
  80. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/__init__.py +0 -0
  81. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/client/__init__.py +0 -0
  82. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/client/base.py +0 -0
  83. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/__init__.py +0 -0
  84. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/audit.py +0 -0
  85. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/auth.py +0 -0
  86. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/client_pkg.py +0 -0
  87. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/clients.py +0 -0
  88. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/external_apis.py +0 -0
  89. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/hosts.py +0 -0
  90. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/iolog.py +0 -0
  91. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/license.py +0 -0
  92. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/quick.py +0 -0
  93. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_cmdgrps.py +0 -0
  94. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_entitlement.py +0 -0
  95. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_hostgrps.py +0 -0
  96. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_policy.py +0 -0
  97. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_roles.py +0 -0
  98. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_tests.py +0 -0
  99. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_tmdategrps.py +0 -0
  100. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_tx.py +0 -0
  101. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_usergrps.py +0 -0
  102. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/settings.py +0 -0
  103. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/siems.py +0 -0
  104. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/users.py +0 -0
  105. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/models/__init__.py +0 -0
  106. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/__init__.py +0 -0
  107. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/client/__init__.py +0 -0
  108. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/client/base.py +0 -0
  109. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/__init__.py +0 -0
  110. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/audits.py +0 -0
  111. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/auth.py +0 -0
  112. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/computers.py +0 -0
  113. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/events.py +0 -0
  114. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/groups.py +0 -0
  115. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/policies.py +0 -0
  116. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/quick.py +0 -0
  117. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/requests.py +0 -0
  118. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/roles.py +0 -0
  119. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/tasks.py +0 -0
  120. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/users.py +0 -0
  121. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/models/__init__.py +0 -0
  122. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/__init__.py +0 -0
  123. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/client/__init__.py +0 -0
  124. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/client/base.py +0 -0
  125. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/__init__.py +0 -0
  126. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/auth.py +0 -0
  127. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/group_policies.py +0 -0
  128. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/import_export.py +0 -0
  129. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/jump_clients.py +0 -0
  130. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/jump_groups.py +0 -0
  131. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/jump_items.py +0 -0
  132. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/jumpoints.py +0 -0
  133. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/policies.py +0 -0
  134. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/quick.py +0 -0
  135. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/teams.py +0 -0
  136. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/users.py +0 -0
  137. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/vault.py +0 -0
  138. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/__init__.py +0 -0
  139. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/common.py +0 -0
  140. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/group_policy.py +0 -0
  141. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/jump_client.py +0 -0
  142. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/jump_group.py +0 -0
  143. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/jump_item.py +0 -0
  144. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/jumpoint.py +0 -0
  145. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/team.py +0 -0
  146. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/user.py +0 -0
  147. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/vault.py +0 -0
  148. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/__init__.py +0 -0
  149. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/client/__init__.py +0 -0
  150. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/client/base.py +0 -0
  151. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/client/beyondinsight.py +0 -0
  152. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/client/passwordsafe.py +0 -0
  153. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/__init__.py +0 -0
  154. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/accounts.py +0 -0
  155. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/assets.py +0 -0
  156. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/attributes.py +0 -0
  157. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/auth.py +0 -0
  158. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/clouds.py +0 -0
  159. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/config.py +0 -0
  160. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/credentials.py +0 -0
  161. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/databases.py +0 -0
  162. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/directories.py +0 -0
  163. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/functional.py +0 -0
  164. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/import_export.py +0 -0
  165. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/platforms.py +0 -0
  166. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/quick.py +0 -0
  167. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/search.py +0 -0
  168. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/secrets.py +0 -0
  169. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/systems.py +0 -0
  170. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/users.py +0 -0
  171. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/workgroups.py +0 -0
  172. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/config.py +0 -0
  173. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/models/__init__.py +0 -0
  174. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/models/account.py +0 -0
  175. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/models/asset.py +0 -0
  176. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/models/common.py +0 -0
  177. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/models/system.py +0 -0
  178. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/__init__.py +0 -0
  179. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/client/__init__.py +0 -0
  180. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/client/base.py +0 -0
  181. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/__init__.py +0 -0
  182. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/_hints.py +0 -0
  183. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/auth.py +0 -0
  184. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/dynamic.py +0 -0
  185. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/folders.py +0 -0
  186. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/integrations.py +0 -0
  187. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/leases.py +0 -0
  188. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/static.py +0 -0
  189. {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/models/__init__.py +0 -0
  190. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/__init__.py +0 -0
  191. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/conftest.py +0 -0
  192. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/core/__init__.py +0 -0
  193. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/core/test_auth.py +0 -0
  194. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/core/test_errors.py +0 -0
  195. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/entitle/__init__.py +0 -0
  196. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/entitle/test_client.py +0 -0
  197. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/entitle/test_commands.py +0 -0
  198. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/entitle-smoke-test.sh +0 -0
  199. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epml/__init__.py +0 -0
  200. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epml/test_client.py +0 -0
  201. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epml/test_commands.py +0 -0
  202. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epmw/__init__.py +0 -0
  203. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epmw/test_client.py +0 -0
  204. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epmw/test_commands.py +0 -0
  205. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epmw-quick-test-plan.md +0 -0
  206. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/fixtures/__init__.py +0 -0
  207. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/fixtures/responses.py +0 -0
  208. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/__init__.py +0 -0
  209. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/conftest.py +0 -0
  210. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/helpers.py +0 -0
  211. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_entitle_integration.py +0 -0
  212. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_epmw_integration.py +0 -0
  213. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_epmw_lifecycle.py +0 -0
  214. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_pra_integration.py +0 -0
  215. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_pra_lifecycle.py +0 -0
  216. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_pws_integration.py +0 -0
  217. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_pws_lifecycle.py +0 -0
  218. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pra/__init__.py +0 -0
  219. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pra/test_client.py +0 -0
  220. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pra/test_commands.py +0 -0
  221. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pra-smoke-test.sh +0 -0
  222. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pra-test-plan.md +0 -0
  223. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pws/__init__.py +0 -0
  224. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pws/test_client.py +0 -0
  225. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pws/test_commands.py +0 -0
  226. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pws-quick-test-plan.md +0 -0
  227. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pws-smoke-test.sh +0 -0
  228. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/secrets/__init__.py +0 -0
  229. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/secrets/test_client.py +0 -0
  230. {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/secrets/test_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  # BT-CLI
2
2
 
3
- BeyondTrust Platform CLI for Password Safe, Entitle, PRA, EPM Windows, EPM Linux, and the BeyondTrust Secrets API. **Version: 0.4.53**
3
+ BeyondTrust Platform CLI for Password Safe, Entitle, PRA, EPM Windows, EPM Linux, and the BeyondTrust Secrets API. **Version: 0.4.54**
4
4
 
5
5
  ## Setup
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bt-cli
3
- Version: 0.4.53
3
+ Version: 0.4.54
4
4
  Summary: BeyondTrust Platform CLI (unofficial) - Password Safe, Entitle, PRA, EPM
5
5
  Author-email: Dave Grendysz <dgrendysz@beyondtrust.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bt-cli"
7
- version = "0.4.53"
7
+ version = "0.4.54"
8
8
  description = "BeyondTrust Platform CLI (unofficial) - Password Safe, Entitle, PRA, EPM"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """BeyondTrust Unified Admin CLI."""
2
2
 
3
- __version__ = "0.4.53"
3
+ __version__ = "0.4.54"
@@ -41,12 +41,26 @@ def configure_callback(
41
41
  ),
42
42
  api_url: Optional[str] = typer.Option(None, "--api-url", help="API URL"),
43
43
  client_id: Optional[str] = typer.Option(None, "--client-id", help="OAuth Client ID"),
44
- client_secret: Optional[str] = typer.Option(None, "--client-secret", help="OAuth Client Secret"),
45
- api_key: Optional[str] = typer.Option(None, "--api-key", help="API Key"),
44
+ client_secret: Optional[str] = typer.Option(
45
+ None,
46
+ "--client-secret",
47
+ help="OAuth Client Secret (visible in shell history/process list — prefer interactive `bt configure`)",
48
+ ),
49
+ api_key: Optional[str] = typer.Option(
50
+ None,
51
+ "--api-key",
52
+ help="API Key (visible in shell history/process list — prefer interactive `bt configure`)",
53
+ ),
46
54
  user_api_key: Optional[str] = typer.Option(
47
55
  None,
48
56
  "--user-api-key",
49
- help="Entitle user-context API key (only required for `bt entitle requests create`)",
57
+ help="Entitle user-context API key, only required for `bt entitle requests create` "
58
+ "(visible in shell history/process list — prefer interactive `bt configure`)",
59
+ ),
60
+ show_input: bool = typer.Option(
61
+ False,
62
+ "--show-input",
63
+ help="Show secret values while typing/pasting in interactive mode (default: hidden)",
50
64
  ),
51
65
  ) -> None:
52
66
  """Configure bt-cli interactively or via flags.
@@ -79,10 +93,14 @@ def configure_callback(
79
93
  )
80
94
  else:
81
95
  # Interactive mode
82
- _configure_interactive(product, profile)
96
+ _configure_interactive(product, profile, show_input=show_input)
83
97
 
84
98
 
85
- def _configure_interactive(product: Optional[str] = None, profile: Optional[str] = None) -> None:
99
+ def _configure_interactive(
100
+ product: Optional[str] = None,
101
+ profile: Optional[str] = None,
102
+ show_input: bool = False,
103
+ ) -> None:
86
104
  """Run interactive configuration wizard."""
87
105
  console.print()
88
106
  console.print(Panel.fit(
@@ -164,17 +182,19 @@ def _configure_interactive(product: Optional[str] = None, profile: Optional[str]
164
182
  choices=field_info["choices"],
165
183
  default=str(default) if default else None
166
184
  )
167
- # Handle secret fields - show the value (not hidden) for easier pasting verification
185
+ # Handle secret fields - input hidden by default (--show-input to reveal for
186
+ # paste verification); never echo more than the last 4 chars of an existing value
168
187
  elif field_info.get("secret"):
169
188
  if existing.get(field_name):
170
189
  existing_val = str(existing[field_name])
171
190
  if existing_val.startswith("keyring://"):
172
191
  console.print(f" [dim](current: stored in keyring)[/dim]")
173
192
  else:
174
- console.print(f" [dim](current: {existing_val[:20]}...)[/dim]" if len(existing_val) > 20 else f" [dim](current: {existing_val})[/dim]")
175
- # Don't use password=True so users can see what they paste
193
+ hint = "****" + existing_val[-4:] if len(existing_val) > 4 else "****"
194
+ console.print(f" [dim](current: {hint}, press Enter to keep)[/dim]")
176
195
  value = Prompt.ask(
177
196
  prompt_text,
197
+ password=not show_input,
178
198
  default="" if not default else None
179
199
  )
180
200
  if not value and default:
@@ -198,6 +218,10 @@ def _configure_interactive(product: Optional[str] = None, profile: Optional[str]
198
218
  console.print(f" [dim]Stored in keyring[/dim]")
199
219
  else:
200
220
  new_config[field_name] = value
221
+ print_warning(
222
+ f"Keyring storage failed for '{field_name}' — "
223
+ "value saved to config file instead (file mode 0600)"
224
+ )
201
225
  else:
202
226
  new_config[field_name] = value
203
227
 
@@ -9,6 +9,8 @@ Supports:
9
9
 
10
10
  import logging
11
11
  import os
12
+ import sys
13
+ import tempfile
12
14
  from dataclasses import dataclass, field
13
15
  from pathlib import Path
14
16
  from typing import Any, Optional
@@ -242,7 +244,14 @@ def load_config_file(path: Optional[Path] = None) -> ConfigFile:
242
244
  profiles=data.get("profiles", {}),
243
245
  )
244
246
  except (yaml.YAMLError, OSError) as e:
245
- # Return empty config on error, caller can handle
247
+ # Fall back to empty config, but tell the user — a corrupt/unreadable
248
+ # file silently ignored looks like "my profiles disappeared"
249
+ logger.warning(f"Failed to load config file {path}: {e}")
250
+ print(
251
+ f"\033[93mWarning: could not read config file {path} ({type(e).__name__}) - "
252
+ "ignoring it. Fix or delete the file to silence this warning.\033[0m",
253
+ file=sys.stderr,
254
+ )
246
255
  return ConfigFile()
247
256
 
248
257
 
@@ -267,9 +276,6 @@ def save_config_file(config: ConfigFile, path: Optional[Path] = None) -> None:
267
276
  # Security: Create file atomically with secure permissions (0o600)
268
277
  # This prevents TOCTOU race where file could be readable between
269
278
  # creation and chmod.
270
- import os
271
- import sys
272
- import tempfile
273
279
 
274
280
  # Write to temp file in same directory, then atomic rename
275
281
  dir_path = path.parent
@@ -283,10 +289,9 @@ def save_config_file(config: ConfigFile, path: Optional[Path] = None) -> None:
283
289
  os.fchmod(fd, 0o600)
284
290
  with os.fdopen(fd, "w") as f:
285
291
  yaml.dump(data, f, default_flow_style=False, sort_keys=False)
286
- # Atomic rename (on Windows, need to remove target first if exists)
287
- if sys.platform == "win32" and path.exists():
288
- os.unlink(path)
289
- os.rename(tmp_path, path)
292
+ # os.replace overwrites atomically on POSIX and Windows alike
293
+ # no delete-then-rename window where the config doesn't exist
294
+ os.replace(tmp_path, path)
290
295
  # On Windows, set permissions after the fact using chmod
291
296
  if sys.platform == "win32":
292
297
  try:
@@ -5,6 +5,7 @@ from enum import Enum
5
5
  from typing import Any, Optional
6
6
 
7
7
  from rich.console import Console
8
+ from rich.markup import escape
8
9
  from rich.panel import Panel
9
10
  from rich.table import Table
10
11
 
@@ -141,7 +142,7 @@ def print_success(message: str) -> None:
141
142
  Args:
142
143
  message: Message to display
143
144
  """
144
- console.print(f"[green]{message}[/green]")
145
+ console.print(f"[green]{escape(message)}[/green]")
145
146
 
146
147
 
147
148
  def print_error(message: str) -> None:
@@ -150,7 +151,7 @@ def print_error(message: str) -> None:
150
151
  Args:
151
152
  message: Error message to display
152
153
  """
153
- console.print(f"[red]Error:[/red] {message}")
154
+ console.print(f"[red]Error:[/red] {escape(message)}")
154
155
 
155
156
 
156
157
  def print_warning(message: str) -> None:
@@ -159,7 +160,7 @@ def print_warning(message: str) -> None:
159
160
  Args:
160
161
  message: Warning message to display
161
162
  """
162
- console.print(f"[yellow]Warning:[/yellow] {message}")
163
+ console.print(f"[yellow]Warning:[/yellow] {escape(message)}")
163
164
 
164
165
 
165
166
  def print_info(message: str) -> None:
@@ -168,7 +169,7 @@ def print_info(message: str) -> None:
168
169
  Args:
169
170
  message: Info message to display
170
171
  """
171
- console.print(f"[blue]{message}[/blue]")
172
+ console.print(f"[blue]{escape(message)}[/blue]")
172
173
 
173
174
 
174
175
  def confirm_action(message: str, default: bool = False) -> bool:
@@ -202,4 +203,4 @@ def print_api_error(error: Exception, operation: str) -> None:
202
203
  operation: Description of the operation that failed (e.g., "list systems")
203
204
  """
204
205
  message = handle_api_error(error, operation)
205
- console.print(f"[red]Error:[/red] {message}")
206
+ console.print(f"[red]Error:[/red] {escape(message)}")
@@ -64,10 +64,11 @@ def prompt_from_list(
64
64
  The selected ID
65
65
  """
66
66
  console.print(f"\n[bold]{title}:[/bold]")
67
+ id_width = max((len(str(item.get(id_key, ""))) for item in items), default=1)
67
68
  for item in items:
68
69
  item_id = item.get(id_key, "")
69
70
  item_name = item.get(name_key, "Unknown")
70
- console.print(f" {item_id}: {item_name}")
71
+ console.print(f" {str(item_id):>{id_width}}: {item_name}")
71
72
  raw = typer.prompt(prompt_text, type=value_type)
72
73
  if value_type is str:
73
74
  return _clean_str(raw) # type: ignore[return-value]
@@ -9,9 +9,12 @@ from typing import Any, Dict
9
9
 
10
10
  import httpx
11
11
  from rich.console import Console
12
+ from rich.markup import escape as rich_escape
12
13
  from rich.panel import Panel
13
14
  from rich.syntax import Syntax
14
15
 
16
+ from .errors import sanitize_error_message
17
+
15
18
  # Global flag for REST debugging
16
19
  _show_rest = False
17
20
 
@@ -120,9 +123,13 @@ def _truncate_body(body: Any, max_length: int = 500, sanitize: bool = True) -> s
120
123
 
121
124
  body_str = str(body)
122
125
 
123
- # Detect PEM private key material in string responses
124
- if sanitize and "-----BEGIN" in body_str and "PRIVATE KEY" in body_str:
125
- return "[REDACTED - private key material]"
126
+ if sanitize:
127
+ # Detect PEM private key material in string responses
128
+ if "-----BEGIN" in body_str and "PRIVATE KEY" in body_str:
129
+ return "[REDACTED - private key material]"
130
+ # String bodies (form-encoded requests, bare-string JSON, plain text)
131
+ # never pass through _sanitize_body — regex-redact them here.
132
+ body_str = sanitize_error_message(body_str)
126
133
  if len(body_str) > max_length:
127
134
  return body_str[:max_length] + f"\n... ({len(body_str) - max_length} more chars)"
128
135
  return body_str
@@ -135,7 +142,8 @@ def log_request(request: httpx.Request) -> None:
135
142
 
136
143
  # Build request info
137
144
  method = request.method
138
- url = str(request.url)
145
+ # Sanitize in case a credential ever lands in a query string or userinfo
146
+ url = sanitize_error_message(str(request.url))
139
147
  headers = _sanitize_headers(request.headers)
140
148
 
141
149
  # Get request body if present
@@ -146,21 +154,21 @@ def log_request(request: httpx.Request) -> None:
146
154
  except (json.JSONDecodeError, UnicodeDecodeError):
147
155
  body = request.content
148
156
 
149
- # Build output
157
+ # Build output (escape dynamic values so they can't inject Rich markup)
150
158
  lines = [
151
- f"[bold cyan]{method}[/bold cyan] [white]{url}[/white]",
159
+ f"[bold cyan]{method}[/bold cyan] [white]{rich_escape(url)}[/white]",
152
160
  "",
153
161
  "[dim]Headers:[/dim]",
154
162
  ]
155
163
 
156
164
  for key, value in headers.items():
157
- lines.append(f" [green]{key}:[/green] {value}")
165
+ lines.append(f" [green]{rich_escape(key)}:[/green] {rich_escape(value)}")
158
166
 
159
167
  if body:
160
168
  lines.append("")
161
169
  lines.append("[dim]Body:[/dim]")
162
170
  body_str = _truncate_body(body, max_length=300)
163
- lines.append(f" {body_str}")
171
+ lines.append(f" {rich_escape(body_str)}")
164
172
 
165
173
  _console.print(Panel(
166
174
  "\n".join(lines),
@@ -197,12 +205,12 @@ def log_response(response: httpx.Response) -> None:
197
205
  except Exception:
198
206
  body = "(could not read response)"
199
207
 
200
- # Build output
208
+ # Build output (escape server-controlled values so they can't inject Rich markup)
201
209
  lines = [
202
- f"[bold {status_color}]{status_code} {status_text}[/bold {status_color}]",
210
+ f"[bold {status_color}]{status_code} {rich_escape(status_text)}[/bold {status_color}]",
203
211
  "",
204
212
  "[dim]Response Body:[/dim]",
205
- _truncate_body(body, max_length=500),
213
+ rich_escape(_truncate_body(body, max_length=500)),
206
214
  ]
207
215
 
208
216
  _console.print(Panel(
@@ -210,7 +210,8 @@ class TestLoadPWSConfig:
210
210
  """Load PWS config from environment variables (OAuth)."""
211
211
  with patch.dict(os.environ, pws_env_vars):
212
212
  with patch("bt_cli.core.config.get_layered_config", return_value={}):
213
- config = load_pws_config()
213
+ with patch("bt_cli.core.config.load_dotenv"): # Prevent loading .env
214
+ config = load_pws_config()
214
215
 
215
216
  assert config.api_url == pws_env_vars["BT_PWS_API_URL"]
216
217
  assert config.client_id == pws_env_vars["BT_PWS_CLIENT_ID"]
@@ -223,7 +224,8 @@ class TestLoadPWSConfig:
223
224
  """Load PWS config from environment variables (API key)."""
224
225
  with patch.dict(os.environ, pws_env_vars_apikey):
225
226
  with patch("bt_cli.core.config.get_layered_config", return_value={}):
226
- config = load_pws_config()
227
+ with patch("bt_cli.core.config.load_dotenv"): # Prevent loading .env
228
+ config = load_pws_config()
227
229
 
228
230
  assert config.api_url == pws_env_vars_apikey["BT_PWS_API_URL"]
229
231
  assert config.api_key == pws_env_vars_apikey["BT_PWS_API_KEY"]
@@ -0,0 +1,67 @@
1
+ """Tests for file-based configuration (config_file.py)."""
2
+
3
+ from pathlib import Path
4
+
5
+ from bt_cli.core.config_file import (
6
+ ConfigFile,
7
+ load_config_file,
8
+ save_config_file,
9
+ )
10
+
11
+
12
+ class TestSaveLoadRoundTrip:
13
+ """Saving and loading the config file preserves content and permissions."""
14
+
15
+ def test_round_trip(self, tmp_path):
16
+ path = tmp_path / "config.yaml"
17
+ config = ConfigFile(
18
+ default_profile="prod",
19
+ profiles={"prod": {"pws": {"api_url": "https://example.com/api"}}},
20
+ )
21
+ save_config_file(config, path)
22
+
23
+ loaded = load_config_file(path)
24
+ assert loaded.default_profile == "prod"
25
+ assert loaded.profiles["prod"]["pws"]["api_url"] == "https://example.com/api"
26
+
27
+ def test_save_sets_owner_only_permissions(self, tmp_path):
28
+ path = tmp_path / "config.yaml"
29
+ save_config_file(ConfigFile(), path)
30
+
31
+ mode = path.stat().st_mode & 0o777
32
+ assert mode == 0o600
33
+
34
+ def test_save_overwrites_existing_file(self, tmp_path):
35
+ path = tmp_path / "config.yaml"
36
+ save_config_file(ConfigFile(default_profile="one"), path)
37
+ save_config_file(ConfigFile(default_profile="two"), path)
38
+
39
+ assert load_config_file(path).default_profile == "two"
40
+
41
+ def test_no_temp_files_left_behind(self, tmp_path):
42
+ path = tmp_path / "config.yaml"
43
+ save_config_file(ConfigFile(), path)
44
+
45
+ leftovers = [p for p in tmp_path.iterdir() if p.name != "config.yaml"]
46
+ assert leftovers == []
47
+
48
+
49
+ class TestLoadFailures:
50
+ """Unreadable/corrupt config files fall back to empty config with a warning."""
51
+
52
+ def test_missing_file_returns_empty_config_silently(self, tmp_path, capsys):
53
+ loaded = load_config_file(tmp_path / "does-not-exist.yaml")
54
+
55
+ assert loaded.profiles == {}
56
+ assert capsys.readouterr().err == ""
57
+
58
+ def test_corrupt_yaml_warns_on_stderr(self, tmp_path, capsys):
59
+ path = tmp_path / "config.yaml"
60
+ path.write_text("default_profile: [unclosed\n bad: : yaml")
61
+
62
+ loaded = load_config_file(path)
63
+
64
+ assert loaded.profiles == {}
65
+ err = capsys.readouterr().err
66
+ assert "could not read config file" in err
67
+ assert str(path) in err
@@ -0,0 +1,44 @@
1
+ """Tests for shared output helpers."""
2
+
3
+ from io import StringIO
4
+
5
+ from rich.console import Console
6
+
7
+ from bt_cli.core import output as output_mod
8
+ from bt_cli.core.output import print_error, print_info, print_success, print_warning
9
+
10
+
11
+ def _capture(monkeypatch, func, message):
12
+ """Run a print_* helper against a real Console and return what it rendered."""
13
+ buf = StringIO()
14
+ console = Console(file=buf, force_terminal=False, width=200)
15
+ monkeypatch.setattr(output_mod, "console", console)
16
+ func(message)
17
+ return buf.getvalue()
18
+
19
+
20
+ class TestMarkupEscaping:
21
+ """Server/user-controlled text in messages must not inject Rich markup."""
22
+
23
+ def test_print_error_escapes_markup(self, monkeypatch):
24
+ rendered = _capture(
25
+ monkeypatch, print_error, "[green]Connection successful[/green]"
26
+ )
27
+ # Literal brackets survive instead of being interpreted as markup
28
+ assert "[green]Connection successful[/green]" in rendered
29
+
30
+ def test_print_warning_escapes_markup(self, monkeypatch):
31
+ rendered = _capture(monkeypatch, print_warning, "[blink]look here[/blink]")
32
+ assert "[blink]look here[/blink]" in rendered
33
+
34
+ def test_print_success_escapes_markup(self, monkeypatch):
35
+ rendered = _capture(monkeypatch, print_success, "done [red]x[/red]")
36
+ assert "done [red]x[/red]" in rendered
37
+
38
+ def test_print_info_escapes_markup(self, monkeypatch):
39
+ rendered = _capture(monkeypatch, print_info, "info [bold]y[/bold]")
40
+ assert "info [bold]y[/bold]" in rendered
41
+
42
+ def test_plain_message_unchanged(self, monkeypatch):
43
+ rendered = _capture(monkeypatch, print_error, "plain failure text")
44
+ assert "plain failure text" in rendered
@@ -164,6 +164,27 @@ class TestTruncateBody:
164
164
  assert "binary data" in result
165
165
  assert "5 bytes" in result
166
166
 
167
+ def test_form_encoded_client_secret_redacted(self):
168
+ """Form-encoded string bodies (OAuth token exchange) are redacted."""
169
+ body = b"grant_type=client_credentials&client_id=my-id&client_secret=super-secret-value"
170
+ result = _truncate_body(body)
171
+
172
+ assert "super-secret-value" not in result
173
+ assert "[REDACTED]" in result
174
+ assert "client_id=my-id" in result # non-secret fields untouched
175
+
176
+ def test_plain_text_password_redacted(self):
177
+ """password=... in plain-text string bodies is redacted."""
178
+ result = _truncate_body("error: login failed for password=hunter2 retry")
179
+
180
+ assert "hunter2" not in result
181
+ assert "[REDACTED]" in result
182
+
183
+ def test_string_body_not_redacted_when_sanitize_off(self):
184
+ """sanitize=False leaves string bodies untouched."""
185
+ body = "client_secret=super-secret-value"
186
+ assert _truncate_body(body, sanitize=False) == body
187
+
167
188
 
168
189
  # =============================================================================
169
190
  # Request Logging Tests
@@ -196,6 +217,43 @@ class TestLogRequest:
196
217
  log_request(request)
197
218
  mock_console.print.assert_called()
198
219
 
220
+ def test_oauth_token_exchange_secret_not_logged(self):
221
+ """The form-encoded client_secret from the OAuth token exchange never
222
+ appears in --show-rest output (mirrors OAuthClientCredentials.authenticate)."""
223
+ set_show_rest(True)
224
+
225
+ request = httpx.Request(
226
+ "POST",
227
+ "https://test.com/Auth/connect/token",
228
+ data={
229
+ "grant_type": "client_credentials",
230
+ "client_id": "my-client-id",
231
+ "client_secret": "super-secret-value",
232
+ },
233
+ )
234
+
235
+ with patch("bt_cli.core.rest_debug._console") as mock_console:
236
+ log_request(request)
237
+
238
+ panel = mock_console.print.call_args[0][0]
239
+ assert "super-secret-value" not in panel.renderable
240
+ assert "[REDACTED]" in panel.renderable
241
+
242
+ def test_query_string_credential_not_logged(self):
243
+ """Credentials in query strings are redacted from the logged URL."""
244
+ set_show_rest(True)
245
+
246
+ request = httpx.Request(
247
+ "GET", "https://test.com/api/endpoint?api_key=super-secret-value"
248
+ )
249
+
250
+ with patch("bt_cli.core.rest_debug._console") as mock_console:
251
+ log_request(request)
252
+
253
+ panel = mock_console.print.call_args[0][0]
254
+ assert "super-secret-value" not in panel.renderable
255
+ assert "[REDACTED]" in panel.renderable
256
+
199
257
 
200
258
  # =============================================================================
201
259
  # Response Logging Tests
@@ -230,6 +288,22 @@ class TestLogResponse:
230
288
  log_response(response)
231
289
  mock_console.print.assert_called()
232
290
 
291
+ def test_server_markup_in_body_escaped(self):
292
+ """Rich markup in a server-controlled response body cannot inject styling."""
293
+ set_show_rest(True)
294
+
295
+ request = httpx.Request("GET", "https://test.com")
296
+ response = httpx.Response(
297
+ 200, json={"message": "[bold red]fake error[/bold red]"}, request=request
298
+ )
299
+
300
+ with patch("bt_cli.core.rest_debug._console") as mock_console:
301
+ log_response(response)
302
+
303
+ panel = mock_console.print.call_args_list[0][0][0]
304
+ # The markup must arrive escaped (backslash-prefixed), not live
305
+ assert "\\[bold red]" in panel.renderable
306
+
233
307
 
234
308
  # =============================================================================
235
309
  # Event Hooks Tests
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes