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.
- {bt_cli-0.4.53 → bt_cli-0.4.54}/CLAUDE.md +1 -1
- {bt_cli-0.4.53 → bt_cli-0.4.54}/PKG-INFO +1 -1
- {bt_cli-0.4.53 → bt_cli-0.4.54}/pyproject.toml +1 -1
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/__init__.py +1 -1
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/commands/configure.py +32 -8
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/config_file.py +13 -8
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/output.py +6 -5
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/prompts.py +2 -1
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/rest_debug.py +19 -11
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/core/test_config.py +4 -2
- bt_cli-0.4.54/tests/core/test_config_file.py +67 -0
- bt_cli-0.4.54/tests/core/test_output.py +44 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/core/test_rest_debug.py +74 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/.claude/skills/bt/SKILL.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/.claude/skills/entitle/SKILL.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/.claude/skills/epml/SKILL.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/.claude/skills/epmw/SKILL.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/.claude/skills/pra/SKILL.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/.claude/skills/pws/SKILL.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/.env.example +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/.github/workflows/ci.yml +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/.github/workflows/release.yml +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/.gitignore +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/README.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/assets/cli-help.png +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/assets/cli-output.png +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/bt-cli.spec +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/bt_entry.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/epml-clients-server-side-filters-plan.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/epml-implementation-plan.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/pf-implementation-plan.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/scripts/bt_entry.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/scripts/pf_onboard.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/scripts/sync-package-data.sh +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/cli.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/commands/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/commands/learn.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/commands/quick.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/auth.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/client.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/config.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/csv_utils.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/core/errors.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/CLAUDE.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/bt/SKILL.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/entitle/SKILL.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/epml/SKILL.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/epmw/SKILL.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/pra/SKILL.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/pws/SKILL.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/data/skills/secrets/SKILL.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/client/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/client/base.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/accounts.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/applications.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/auth.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/bundles.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/integrations.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/permissions.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/policies.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/requests.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/resources.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/roles.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/users.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/commands/workflows.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/bundle.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/common.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/integration.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/permission.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/policy.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/resource.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/role.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/user.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/entitle/models/workflow.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/client/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/client/base.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/audit.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/auth.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/client_pkg.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/clients.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/external_apis.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/hosts.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/iolog.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/license.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/quick.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_cmdgrps.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_entitlement.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_hostgrps.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_policy.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_roles.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_tests.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_tmdategrps.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_tx.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/rbp_usergrps.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/settings.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/siems.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/commands/users.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epml/models/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/client/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/client/base.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/audits.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/auth.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/computers.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/events.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/groups.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/policies.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/quick.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/requests.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/roles.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/tasks.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/commands/users.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/epmw/models/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/client/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/client/base.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/auth.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/group_policies.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/import_export.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/jump_clients.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/jump_groups.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/jump_items.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/jumpoints.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/policies.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/quick.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/teams.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/users.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/commands/vault.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/common.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/group_policy.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/jump_client.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/jump_group.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/jump_item.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/jumpoint.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/team.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/user.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pra/models/vault.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/client/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/client/base.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/client/beyondinsight.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/client/passwordsafe.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/accounts.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/assets.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/attributes.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/auth.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/clouds.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/config.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/credentials.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/databases.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/directories.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/functional.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/import_export.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/platforms.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/quick.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/search.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/secrets.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/systems.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/users.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/commands/workgroups.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/config.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/models/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/models/account.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/models/asset.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/models/common.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/pws/models/system.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/client/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/client/base.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/_hints.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/auth.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/dynamic.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/folders.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/integrations.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/leases.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/commands/static.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/src/bt_cli/secrets/models/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/conftest.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/core/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/core/test_auth.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/core/test_errors.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/entitle/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/entitle/test_client.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/entitle/test_commands.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/entitle-smoke-test.sh +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epml/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epml/test_client.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epml/test_commands.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epmw/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epmw/test_client.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epmw/test_commands.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/epmw-quick-test-plan.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/fixtures/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/fixtures/responses.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/conftest.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/helpers.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_entitle_integration.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_epmw_integration.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_epmw_lifecycle.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_pra_integration.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_pra_lifecycle.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_pws_integration.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/integration/test_pws_lifecycle.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pra/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pra/test_client.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pra/test_commands.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pra-smoke-test.sh +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pra-test-plan.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pws/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pws/test_client.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pws/test_commands.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pws-quick-test-plan.md +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/pws-smoke-test.sh +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/secrets/__init__.py +0 -0
- {bt_cli-0.4.53 → bt_cli-0.4.54}/tests/secrets/test_client.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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(
|
|
45
|
-
|
|
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
|
|
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(
|
|
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 -
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|