diff --git a/.github/workflows/publish.chefsupermaket.yml b/.github/workflows/publish.chefsupermaket.yml new file mode 100644 index 00000000..68bbf092 --- /dev/null +++ b/.github/workflows/publish.chefsupermaket.yml @@ -0,0 +1,239 @@ +name: Publish to Chef Supermarket +on: + workflow_dispatch: + inputs: + publish: + description: 'Publish to Chef Supermarket (uncheck to build only)' + required: false + default: 'true' + type: boolean + +jobs: + get-version: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.extract-version.outputs.version }} + steps: + - uses: actions/checkout@v4 + - name: Extract version from metadata.rb + id: extract-version + working-directory: ./integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager + run: | + echo "Detecting Chef cookbook version..." + if [ -f "metadata.rb" ]; then + VERSION=$(grep "version" "metadata.rb" | awk '{print $2}' | tr -d "'\"") + echo "Detected version: ${VERSION}" + else + VERSION="1.0.0" + echo "Could not detect version, using default: ${VERSION}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + + generate-sbom: + name: Generate SBOM + needs: get-version + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 10 + + defaults: + run: + working-directory: ./integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager + + steps: + - name: Get the source code + uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2.4' + working-directory: ./integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager + + - name: Install Syft and Manifest CLI + run: | + echo "Installing Syft v1.18.1..." + curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /tmp/bin v1.18.1 + export PATH="/tmp/bin:$PATH" + + echo "Installing Manifest CLI v0.18.3..." + curl -sSfL https://raw.githubusercontent.com/manifest-cyber/cli/main/install.sh | sh -s -- -b /tmp/bin v0.18.3 + + - name: Generate and publish SBOM + env: + MANIFEST_TOKEN: ${{ secrets.MANIFEST_TOKEN }} + PROJECT_VERSION: ${{ needs.get-version.outputs.version }} + run: | + export PATH="/tmp/bin:$PATH" + + echo "Creating Syft configuration for Ruby scanning..." + cat > syft-config.yaml << 'EOF' + package: + search: + scope: all-layers + cataloger: + enabled: true + java: + enabled: false + python: + enabled: false + nodejs: + enabled: false + ruby: + enabled: true + search-unindexed-archives: true + search-indexed-archives: true + EOF + + echo "Generating SBOM with Manifest CLI..." + /tmp/bin/manifest sbom . \ + --generator=syft \ + --name=keeper-secrets-manager-chef \ + --version=${PROJECT_VERSION} \ + --output=spdx-json \ + --file=chef-sbom.json \ + --api-key=${MANIFEST_TOKEN} \ + --publish=true \ + --asset-label=application,sbom-generated,ruby,chef \ + --generator-config=syft-config.yaml + + echo "SBOM generated and uploaded successfully: chef-sbom.json" + + - name: Archive SBOM + uses: actions/upload-artifact@v4 + with: + name: sbom-chef-${{ needs.get-version.outputs.version }} + path: ./integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/chef-sbom.json + retention-days: 90 + + + publish-chef-supermarket: + needs: [get-version, generate-sbom] + if: ${{ github.event.inputs.publish == 'true' }} + runs-on: ubuntu-latest + environment: prod + permissions: + contents: read + timeout-minutes: 20 + + defaults: + run: + working-directory: ./integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager + + steps: + - name: Get the source code + uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2.4' + + - name: Retrieve secrets from KSM + id: ksmsecrets + uses: Keeper-Security/ksm-action@master + with: + keeper-secret-config: ${{ secrets.KSM_CHEF_SUPERMARKET_CONFIG }} + secrets: | + b9vSxs5Dn-yJTPYr7Yvfmg/field/login > CHEF_USER + b9vSxs5Dn-yJTPYr7Yvfmg/file/keepersecurity.pem > file:keepersecurity.pem + b9vSxs5Dn-yJTPYr7Yvfmg/custom_field/server_url > CHEF_SERVER_URL + + - name: Configure knife authentication + run: | + mkdir -p ~/.chef + + # Move the client key from workspace to ~/.chef + mv "${{ github.workspace }}/keepersecurity.pem" ~/.chef/client.pem + chmod 600 ~/.chef/client.pem + + # Verify key file exists and has correct permissions + ls -la ~/.chef/client.pem + echo "Client key file created with permissions: $(stat -f '%A' ~/.chef/client.pem)" + + # Create knife config + cat > ~/.chef/config.rb << EOF + node_name '${{ steps.ksmsecrets.outputs.CHEF_USER }}' + client_key File.expand_path('~/.chef/client.pem') + chef_server_url '${{ steps.ksmsecrets.outputs.CHEF_SERVER_URL }}' + cookbook_path [File.expand_path('.')] + EOF + + echo "Knife configuration created:" + cat ~/.chef/config.rb + + - name: Get current version and validate + id: version + run: | + if [[ -f "metadata.rb" ]]; then + VERSION=$(grep "version" metadata.rb | awk '{print $2}' | tr -d "'\"") + echo "current_version=$VERSION" >> $GITHUB_OUTPUT + echo "Current version: $VERSION" + else + echo "Error: metadata.rb not found" + exit 1 + fi + + - name: Check if version already exists on Chef Supermarket + env: + VERSION: ${{ steps.version.outputs.current_version }} + run: | + echo "Checking if version $VERSION exists on Supermarket..." + RESULT=$(curl -s "https://supermarket.chef.io/api/v1/cookbooks/keeper_secrets_manager/versions/${VERSION}") + if echo "$RESULT" | grep -q '"version"'; then + echo "Error: Version $VERSION already exists on Chef Supermarket!" + exit 1 + fi + echo "Version $VERSION is available for publishing" + + - name: Install Chef Workstation + run: | + echo "Installing Chef Workstation..." + curl https://omnitruck.chef.io/install.sh | sudo bash -s -- -P chef-workstation + chef --version + + - name: Run linting (Cookstyle) + run: | + echo "Running cookstyle..." + cookstyle || exit 1 + + - name: Run ChefSpec tests + run: | + echo "Running ChefSpec tests..." + rspec || exit 1 + + - name: Publish to Chef Supermarket + env: + VERSION: ${{ steps.version.outputs.current_version }} + run: | + echo "Publishing to Chef Supermarket..." + echo "Using knife configuration from ~/.chef/config.rb" + + # Verify knife configuration + knife ssl check || echo "Warning: SSL check failed, but continuing..." + + # Share cookbook to Supermarket + knife supermarket share keeper_secrets_manager "Utilities" \ + --supermarket-site https://supermarket.chef.io \ + --cookbook-path . + + echo "Successfully published version $VERSION to Chef Supermarket!" + + - name: Create release summary + env: + VERSION: ${{ steps.version.outputs.current_version }} + run: | + echo "## Chef Cookbook Published Successfully!" >> $GITHUB_STEP_SUMMARY + echo "**Version:** $VERSION" >> $GITHUB_STEP_SUMMARY + echo "**Cookbook:** keeper_secrets_manager" >> $GITHUB_STEP_SUMMARY + echo "**Supermarket URL:** https://supermarket.chef.io/cookbooks/keeper_secrets_manager" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Validation Results" >> $GITHUB_STEP_SUMMARY + echo "- Cookstyle: Passed" >> $GITHUB_STEP_SUMMARY + echo "- ChefSpec Tests: Passed" >> $GITHUB_STEP_SUMMARY + echo "- Cookbook Build: Successful" >> $GITHUB_STEP_SUMMARY + echo "- Supermarket Publish: Successful" >> $GITHUB_STEP_SUMMARY diff --git a/integration/keeper_secrets_manager_chef/cookbooks/README.md b/integration/keeper_secrets_manager_chef/cookbooks/README.md new file mode 100644 index 00000000..6ac6df5d --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/README.md @@ -0,0 +1,333 @@ +# Chef + +Keeper Secrets Manager cookbook for Chef Infra automation platform + +## About + +Chef Infra is a powerful automation platform that transforms infrastructure into code. Whether you're operating in the cloud, on-premises, or in a hybrid environment, Chef automates how infrastructure is configured, deployed, and managed across your network, no matter its size. + +The Keeper Secrets Manager cookbook allows Chef-managed nodes to integrate with Keeper Secrets Manager to make managing secrets in Chef infrastructure easier and more secure. + +## Features + +* Install and configure Keeper Secrets Manager Python SDK on Chef-managed nodes +* Retrieve secrets from the Keeper vault during Chef runs using Keeper Notation +* Secure authentication through encrypted data bags +* Cross-platform support (Linux, macOS, Windows) +* Support for environment variables, JSON output, and file secrets + +## Prerequisites + +* Keeper Secrets Manager access (See the [Quick Start Guide](https://docs.keeper.io/secrets-manager/secrets-manager/quick-start-guide) for more details) + * Secrets Manager add-on enabled for your Keeper subscription + * Membership in a Role with the Secrets Manager enforcement policy enabled +* A Keeper Secrets Manager Application with secrets shared to it + * See the Quick Start Guide for instructions on creating an Application +* An initialized Keeper Secrets Manager Configuration + * The cookbook accepts Base64 format configurations + +## Installation + +### Using Berkshelf + +Add this line to your `Berksfile`: + +```ruby +cookbook 'keeper_secrets_manager', git: 'https://github.com/Keeper-Security/secrets-manager.git', rel: 'integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager' +``` + +### Using Chef Supermarket + +```bash +knife supermarket install keeper_secrets_manager +``` + +### Manual Installation + +1. Download the cookbook +2. Place it in your cookbooks directory +3. Upload to your Chef server: + +```bash +knife cookbook upload keeper_secrets_manager +``` + +## Setup + +### Authentication + +The cookbook uses **Encrypted Data Bags** for secure authentication. This method allows you to store your Keeper configuration securely on the Chef server and make it available to your nodes. + +#### 🔐 Creating the Secret Key File + +Before creating encrypted data bags, you need to create a shared **secret file** that Chef will use to encrypt and decrypt sensitive data. + +Run the following commands: + +```bash +# MacOS/Linux: +# Create directory for Chef secrets if it doesn't exist +sudo mkdir -p /etc/chef + +# Generate a base64-encoded secret and store it securely +openssl rand -base64 512 | sudo tee /etc/chef/encrypted_data_bag_secret > /dev/null + +# Windows: Generate a base64-encoded secret and store it securely +New-Item -ItemType Directory -Path C:\chef -Force +$bytes = New-Object 'System.Byte[]' 512 +[System.Security.Cryptography.RNGCryptoServiceProvider]::Create().GetBytes($bytes) +[Convert]::ToBase64String($bytes) | Out-File -FilePath 'C:\chef\encrypted_data_bag_secret' -Encoding ASCII -Force + + +#### Configuring Encrypted Data Bags + +Create an encrypted data bag to store your Keeper configuration: + +```bash +# Create the data bag +knife data bag create keeper + +# Create configuration item +cat > keeper_config.json << EOF +{ + "id": "keeper_config", + "config_json": "eyJhcHBLZXkiOiJCaU..." +} +EOF + +# Encrypt and upload to Chef server +knife data bag from file keeper keeper_config.json --secret-file /path/to/secret +``` + +The encrypted data bag will store your Keeper Secrets Manager configuration as environment variables that can be securely accessed by your Chef nodes. + +### Input Configuration File + +The `input.json` file is **mandatory** and defines which secrets to retrieve from your Keeper vault. This file uses Keeper Notation to specify the secrets you want to fetch. + +#### Creating input.json + +Create an `input.json` file with the following structure: + +```json +{ + "authentication": [ + "base64" + ], + "secrets": [ + "jnPuLYWXt7b6Ym-_9OCvFA/field/password > APP_PASSWORD", + "jnPuLYWXt7b6Ym-_9OCvFA/field/login > LOGIN", + "jnPuLYWXt7b6Ym-_9OCvFA/file/dummy.crt > file:/tmp/Certificate.crt" + ] +} +``` + +## 📝 Keeper Notation + +The cookbook supports comprehensive Keeper notation for flexible secret mapping. For complete documentation, visit: [Keeper Notation Documentation](https://docs.keeper.io/en/keeperpam/secrets-manager/about/keeper-notation) + +### Notation Format + +The notation follows the pattern: `"KEEPER_NOTATION > OUTPUT_SPECIFICATION"` + +- **Left side**: Keeper notation (e.g., `UID/custom_field/Label1`) +- **Right side**: Output specification (e.g., `Label2`, `env:Label2`, `file:/path/to/file`) + +### Output Mapping Options + +#### 1. Simple Key Mapping +```json +"UID/custom_field/Label1 > Label2" +``` +**Result**: `{ "Label2": "VALUE_HERE" }` in output JSON + +#### 2. Environment Variable Output +```json +"secret-uid/field/password > env:DB_PASSWORD" +``` +**Result**: Sets `DB_PASSWORD` environment variable on the Chef node +**Note**: `env:Label2` will be exported as environment variable, and `Label2` will not be included in output JSON + +#### 3. File Output +```json +"secret-uid/file/ssl_cert.pem > file:/opt/ssl/cert.pem" +``` +**Result**: Downloads file to specified path on the Chef node +**Output JSON**: `{ "ssl_cert.pem": "/opt/ssl/cert.pem" }` +**Note**: Filename becomes the key, file path becomes the value + +### Complete input.json Example + +```json +{ + "authentication": [ + "base64" + ], + "secrets": [ + "jnPuLYWXt7b6Ym-_9OCvFA/field/password > env:DB_PASSWORD", + "jnPuLYWXt7b6Ym-_9OCvFA/field/login > DB_USERNAME", + "jnPuLYWXt7b6Ym-_9OCvFA/custom_field/api_key > API_KEY", + "jnPuLYWXt7b6Ym-_9OCvFA/file/ssl_cert.pem > file:/opt/ssl/cert.pem", + "jnPuLYWXt7b6Ym-_9OCvFA/file/ssl_key.pem > file:/opt/ssl/key.pem" + ] +} +``` + +#### Finding Record UIDs + +You can find the Record UID in: +- **Keeper Commander**: Use the `ls -l` command to see record UIDs +- **Keeper Web Vault**: Click on a record and look at the URL or record details +- **Keeper Desktop App**: Right-click on a record and select "Copy Record UID" + +## Usage + +### Basic Installation + +```ruby +# Install Keeper Secrets Manager +ksm_install 'keeper_setup' do + python_sdk true + cli_tool false + action :install +end +``` + +### Retrieving Secrets + +```ruby +# Fetch secrets from Keeper vault using custom input.json path +ksm_fetch 'fetch_app_secrets' do + input_path '/path/to/your/input.json' + timeout 300 + action :run +end + +# Or use default path (/opt/keeper_secrets_manager/input.json) +ksm_fetch 'fetch_app_secrets' do + timeout 300 + action :run +end +``` + +### Complete Example + +```ruby +# Install Keeper Secrets Manager +ksm_install 'keeper_setup' do + python_sdk true + cli_tool true + base_dir '/opt/keeper_secrets_manager' + action :install +end + +# Create input.json file +cookbook_file '/opt/keeper_secrets_manager/input.json' do + source 'input.json' + mode '0600' + action :create +end + +# Retrieve secrets from Keeper vault +ksm_fetch 'fetch_app_secrets' do + input_path '/opt/keeper_secrets_manager/input.json' + timeout 300 + action :run +end + +# Use environment variables set by Keeper (from env: mappings) +template '/etc/myapp/config.yml' do + source 'config.yml.erb' + variables({ + db_password: lazy { ENV['DB_PASSWORD'] }, # From env: mapping + api_key: lazy { ENV['API_KEY'] } # From env: mapping + }) +end + +# Use files downloaded by Keeper (from file: mappings) +template '/etc/nginx/ssl.conf' do + source 'ssl.conf.erb' + variables({ + ssl_cert_path: '/opt/ssl/cert.pem', # From file: mapping + ssl_key_path: '/opt/ssl/key.pem' # From file: mapping + }) +end +``` + +## Resources + +### ksm_install + +Installs Keeper Secrets Manager Python SDK and CLI tools. + +#### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `python_sdk` | Boolean | `true` | Install Python SDK | +| `cli_tool` | Boolean | `false` | Install CLI tool | +| `user_install` | Boolean | `false` | Install for current user only | +| `base_dir` | String | Platform-specific | Base installation directory | + +#### Actions + +- `:install` - Install Keeper Secrets Manager (default) + +### ksm_fetch + +Retrieves secrets from the Keeper vault using the input.json configuration file. + +#### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `input_path` | String | `/opt/keeper_secrets_manager/input.json` | Path to input.json configuration file | +| `timeout` | Integer | `300` | Timeout for script execution | +| `deploy_path` | String | `/opt/keeper_secrets_manager/ksm.py` | Script deployment path | + +#### Actions + +- `:run` - Retrieve secrets from Keeper vault (default) + +**Note:** If `input_path` is not specified, the cookbook will look for `input.json` in `/opt/keeper_secrets_manager/input.json`. + +## Platforms + +The following platforms are supported: + +- **Linux**: Ubuntu 18.04+, CentOS 7+, RHEL 7+, Debian 9+ +- **macOS**: 10.14+ +- **Windows**: Server 2016+ + +## Requirements + +### Chef + +- Chef Infra Client 16.0+ +- Chef Workstation 21.0+ (for development) + +### Dependencies + +- Python 3.6+ (automatically installed if not present) +- pip (automatically installed) +- Internet connection for downloading Keeper SDK + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests for your changes +5. Submit a pull request + +## License + +All Rights Reserved + +## Support + +For technical questions, you can email **support@keeper.io**. + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for version history and changes. \ No newline at end of file diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/.gitignore b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/.gitignore new file mode 100644 index 00000000..f1e57b87 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/.gitignore @@ -0,0 +1,25 @@ +.vagrant +*~ +*# +.#* +\#*# +.*.sw[a-z] +*.un~ + +# Bundler +Gemfile.lock +gems.locked +bin/* +.bundle/* + +# test kitchen +.kitchen/ +kitchen.local.yml + +# Chef Infra +Berksfile.lock +.zero-knife.rb +Policyfile.lock.json + +.idea/ + diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/Berksfile b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/Berksfile new file mode 100644 index 00000000..34fea216 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/Berksfile @@ -0,0 +1,3 @@ +source 'https://supermarket.chef.io' + +metadata diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/CHANGELOG.md b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/CHANGELOG.md new file mode 100644 index 00000000..bf9bea18 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/CHANGELOG.md @@ -0,0 +1,13 @@ +# keeper_secrets_manager CHANGELOG + +This file is used to list changes made in each version of the keeper_secrets_manager cookbook. + +## 1.0.0 + +Initial release. + +- Added `ksm_install` custom resource for installing Keeper Secrets Manager +- Added `ksm_fetch` custom resource for retrieving secrets from Keeper vault +- Support for encrypted data bags, environment variables, and input file authentication +- Cross-platform support (Linux, macOS, Windows) +- Comprehensive test suite with Python unit tests, ChefSpec, and integration tests diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/LICENSE b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/LICENSE new file mode 100644 index 00000000..57fbcef5 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/LICENSE @@ -0,0 +1,203 @@ +# Copyright 2025 Keeper Security, Inc. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/Policyfile.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/Policyfile.rb new file mode 100644 index 00000000..c69704bb --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/Policyfile.rb @@ -0,0 +1,16 @@ +# Policyfile.rb - Describe how you want Chef Infra Client to build your system. +# +# For more information on the Policyfile feature, visit +# https://docs.chef.io/policyfile/ + +# A name that describes what the system you're building with Chef does. +name 'keeper_secrets_manager' + +# Where to find external cookbooks: +default_source :supermarket + +# run_list: chef-client will run these recipes in the order specified. +run_list 'keeper_secrets_manager::default' + +# Specify a custom source for a single cookbook: +cookbook 'keeper_secrets_manager', path: '.' diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/README.md b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/README.md new file mode 100644 index 00000000..95de7dda --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/README.md @@ -0,0 +1,289 @@ +# Keeper Secrets Manager Cookbook + +[![Cookbook Version](https://img.shields.io/badge/cookbook-v1.0.0-blue)](https://github.com/Keeper-Security/secrets-manager/tree/master/integration/keeper_secrets_manager_chef) +[![Chef](https://img.shields.io/badge/chef-%3E%3D16.0-orange)](https://www.chef.io/) +[![License](https://img.shields.io/badge/license-All%20Rights%20Reserved-red)](LICENSE) + +Install and configure Keeper Secrets Manager for secure secret retrieval in Chef-managed infrastructure. + +## Maintainers + +This cookbook is maintained by Keeper Security. If you'd like to contribute or report issues, please visit our [GitHub repository](https://github.com/Keeper-Security/secrets-manager/tree/master/integration/keeper_secrets_manager_chef). + +## Platforms + +The following platforms have been certified with integration tests: + +- **Linux**: Ubuntu 18.04+, CentOS 7+, RHEL 7+, Debian 9+ +- **macOS**: 10.14+ +- **Windows**: Server 2016+ + +## Requirements + +### Chef + +- Chef Infra Client 16.0+ +- Chef Workstation 21.0+ (for development) + +### Dependencies + +- Python 3.6+ (automatically installed if not present) +- pip (automatically installed) +- Internet connection for downloading Keeper SDK + +## Usage + +This cookbook provides custom resources for installing and configuring Keeper Secrets Manager. It is recommended to create a project-specific wrapper cookbook and add the desired custom resources to your run list. + +### Basic Installation + +```ruby +# Install Keeper Secrets Manager +ksm_install 'keeper_setup' do + python_sdk true + cli_tool true + action :install +end + +# Retrieve secrets from Keeper vault +ksm_fetch 'fetch_app_secrets' do + input_path '/opt/keeper_secrets_manager/input.json' + action :run +end +``` + +### Advanced Configuration + +```ruby +# Custom installation directory +ksm_install 'keeper_custom' do + python_sdk true + cli_tool true + base_dir '/custom/keeper/path' + action :install +end + +# Retrieve secrets with custom timeout +ksm_fetch 'database_secrets' do + input_path '/opt/keeper_secrets_manager/input.json' + timeout 600 + action :run +end + +# Use retrieved secrets in templates +secrets = lazy { JSON.parse(File.read('/opt/keeper_secrets_manager/keeper_output.txt')) } + +template '/etc/myapp/config.yml' do + source 'config.yml.erb' + variables( + db_password: secrets['DB_PASSWORD'], + api_key: secrets['API_KEY'] + ) + sensitive true +end +``` + +## Authentication + +The cookbook supports multiple authentication methods with the following priority: + +1. **Encrypted Data Bags** (Production) +2. **Environment Variables** (Development) +3. **Input File Configuration** (Testing) + +### Encrypted Data Bags + +**Create the data bag:** +```bash +knife data bag create keeper +``` + +**Create the configuration item (`keeper_config.json`):** +```json +{ + "id": "keeper_config", + "config_json": "eyJhcHBLZXkiOiJCaU..." +} +``` + +**Encrypt and store:** +```bash +knife data bag from file keeper keeper_config.json --secret-file /path/to/secret +``` + +**Usage in recipes:** +```ruby +# The cookbook automatically checks for encrypted data bags +# Priority: 1. Encrypted data bags, 2. Environment variables, 3. Input file +include_recipe 'keeper_secrets_manager::install' +include_recipe 'keeper_secrets_manager::fetch' +``` + +### Environment Variables + +```bash +export KEEPER_CONFIG='eyJhcHBLZXkiOiJCaU...' +``` + +### Input File Format + +```json +{ + "authentication": ["base64"], + "secrets": [ + "record-uid/field/password > DB_PASSWORD", + "record-uid/file/cert.crt > file:/tmp/Certificate.crt" + ] +} +``` + +## Resources + +### `ksm_install` + +Installs Keeper Secrets Manager components. + +#### Actions + +- `:install` (default) - Installs all components + +#### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `python_sdk` | Boolean | `true` | Install Python SDK | +| `cli_tool` | Boolean | `true` | Install CLI tool | +| `user_install` | Boolean | `false` | Install for user only | +| `base_dir` | String | Platform-specific | Base installation directory | + +#### Examples + +```ruby +# Basic installation +ksm_install 'keeper_setup' + +# Custom configuration +ksm_install 'keeper_custom' do + python_sdk true + cli_tool false + base_dir '/opt/keeper' + action :install +end +``` + +### `ksm_fetch` + +Retrieves secrets from Keeper vault. + +#### Actions + +- `:run` (default) - Executes secret retrieval + +#### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `input_path` | String | Required | Path to input JSON file | +| `deploy_path` | String | Auto-generated | Path to deploy Python script | +| `timeout` | Integer | `300` | Execution timeout in seconds | + +#### Examples + +```ruby +# Basic secret retrieval +ksm_fetch 'fetch_secrets' do + input_path '/opt/keeper_secrets_manager/input.json' +end + +# With custom timeout +ksm_fetch 'long_running_secrets' do + input_path '/opt/keeper_secrets_manager/input.json' + timeout 600 + action :run +end +``` + +## Recipes + +### `keeper_secrets_manager::default` + +Empty recipe that serves as an entry point. + +### `keeper_secrets_manager::install` + +Installs and configures Keeper Secrets Manager using the `ksm_install` resource with default settings. + +### `keeper_secrets_manager::fetch` + +Demonstrates secret retrieval using the `ksm_fetch` resource. + +## Attributes + +This cookbook uses no node attributes. All configuration is done through resource properties. + +## Testing + +### Prerequisites + +```bash +# Set up testing environment +export KEEPER_CONFIG='your-base64-config' +``` + +### Running Tests + +```bash +# Run all tests +./run_all_tests.sh + +# Run individual test types +./test_python.sh # Python unit tests +chef exec rspec # ChefSpec tests +chef exec cookstyle . # Style checks +``` + +### Test Coverage + +- Python Unit Tests (11 tests) +- ChefSpec Tests (Resource and recipe testing) +- Integration Tests (Docker-based end-to-end testing) +- Style Tests (Cookstyle compliance) + +## External Documentation + +- [Keeper Secrets Manager Documentation](https://docs.keeper.io/secrets-manager/) +- [Keeper Developer Portal](https://developer.keeper.io/) +- [Python SDK Documentation](https://github.com/Keeper-Security/secrets-manager) + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Make your changes following the style guidelines +4. Add tests for new functionality +5. Run the test suite (`./run_all_tests.sh`) +6. Commit your changes (`git commit -m 'Add amazing feature'`) +7. Push to the branch (`git push origin feature/amazing-feature`) +8. Open a Pull Request + +### Development Requirements + +- Chef Workstation 21.0+ +- Docker (for integration tests) +- Python 3.6+ (for unit tests) + +### Code Style + +- Follow [Chef Style Guide](https://docs.chef.io/ruby/) +- Use Cookstyle for Ruby code formatting +- Follow PEP 8 for Python code +- Write clear, descriptive commit messages + +## License + +This module is licensed under the Apache License, Version 2.0. + +--- + +**Version:** 1.0.0 +**Last Updated:** 2025 diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/chefignore b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/chefignore new file mode 100644 index 00000000..77924ab1 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/chefignore @@ -0,0 +1,2 @@ +.kitchen +kitchen*.yml \ No newline at end of file diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/files/default/input.json b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/files/default/input.json new file mode 100644 index 00000000..467a02b3 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/files/default/input.json @@ -0,0 +1,10 @@ +{ + "authentication": [ + "base64" + ], + "secrets": [ + "jnPuLYWXt7b6Ym-_9OCvFA/field/password > APP_PASSWORD", + "jnPuLYWXt7b6Ym-_9OCvFA/field/login > LOGIN", + "jnPuLYWXt7b6Ym-_9OCvFA/file/dummy.crt > file:/tmp/Certificate.crt" + ] +} \ No newline at end of file diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/files/default/ksm.py b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/files/default/ksm.py new file mode 100644 index 00000000..ddf33a85 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/files/default/ksm.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 + +import os +import sys +import json +import argparse +import platform +from keeper_secrets_manager_core.core import SecretsManager +from keeper_secrets_manager_core.storage import FileKeyValueStorage, InMemoryKeyValueStorage +from keeper_secrets_manager_core.exceptions import KeeperError + +# -------------------- Constants -------------------- + +class Constants: + DEFAULT_PATH = "C:\\ProgramData\\keeper_secrets_manager" if platform.system() == 'Windows' else "/opt/keeper_secrets_manager" + INPUT_FILE = "input.json" + CONFIG_FILE = "keeper_config.json" + OUTPUT_FILE = "keeper_output.txt" + ENV_FILE = "keeper_env.sh" + AUTHENTICATION = "authentication" + SECRETS = "secrets" + FOLDERS = "folders" + AUTH_VALUE_ENV_VAR = "KEEPER_CONFIG" + KEEPER_NOTATION_PREFIX = "keeper://" + +# -------------------- Get Environment Variables -------------------- + +def get_env_from_current_process(env_var_name): + """ + Get environment variable from current process environment. + + Args: + env_var_name (str): Name of the environment variable to retrieve + + Returns: + str or None: Environment variable value if found, None otherwise + """ + return os.getenv(env_var_name) + +def get_env_value(env_var_name): + """ + Get environment variable value from multiple possible sources. + Checks standard environment, shell profiles, and system-specific locations. + + Args: + env_var_name (str): Name of the environment variable to retrieve + + Returns: + str or None: Environment variable value if found, None otherwise + """ + # Check current process environment (fastest) + value = get_env_from_current_process(env_var_name) + if value: + return value + + return None + +# -------------------- Logging & Custom Exceptions -------------------- + +def log_message(level, message): + print(f"[{level}] KEEPER: {message}", file=sys.stderr) + +class KSMInitializationError(Exception): pass +class ConfigurationError(Exception): pass + +# -------------------- Config & Auth Logic -------------------- + +def get_configurations(config_file_path): + try: + if not os.path.exists(config_file_path): + raise ConfigurationError(f"Configuration file does not exist: {config_file_path}") + if not os.access(config_file_path, os.R_OK): + raise ConfigurationError(f"Cannot read configuration file: {config_file_path}") + with open(config_file_path, 'r', encoding='utf-8') as file: + config = json.load(file) + if not isinstance(config, dict): + raise ConfigurationError("Configuration file must contain a JSON object") + return config + except json.JSONDecodeError as e: + raise ConfigurationError(f"Invalid JSON in configuration file: {e}") from e + except Exception as e: + if isinstance(e, ConfigurationError): + raise + raise ConfigurationError(f"Failed to read configuration file: {e}") from e + +def validate_auth_config(auth_config): + + if not isinstance(auth_config, (list)) or len(auth_config) < 1: + raise ValueError("Authentication config not provided as required") + + if auth_config[0] not in ['token', 'json', 'base64']: + raise ValueError("Unsupported authentication method, Must be one of: token, json, base64") + + method = auth_config[0] + + # Check environment variable first + env_value = get_env_value(Constants.AUTH_VALUE_ENV_VAR) + if env_value: + value = env_value + elif len(auth_config) > 1 and auth_config[1] != "" and auth_config[1] is not None: + value = auth_config[1] + else: + raise ValueError("Authentication value not found in configuration or KEEPER_CONFIG not exposed to environment") + + return method, value + +def is_config_expired(secrets_manager): + try: + secrets_manager.get_secrets() + return False + except KeeperError as e: + msg = str(e).lower() + patterns = ['access_denied', 'signature is invalid', 'authentication failed', 'token expired'] + if any(p in msg for p in patterns): + log_message("INFO", "Credentials appear to be expired") + return True + raise + +def initialize_ksm(auth_config): + method, value = validate_auth_config(auth_config) + config_file_path = os.path.join(Constants.DEFAULT_PATH, Constants.CONFIG_FILE) + + # Check if keeper_config.json file exists and is not empty + if method in ['token', 'json'] and os.path.exists(config_file_path) and os.path.getsize(config_file_path) > 0: + sm = SecretsManager(config=FileKeyValueStorage(config_file_path)) + + # Check if current keeper_config.json is not expired + if not is_config_expired(sm): + return sm + + # If expired, remove the keeper_config.json file + os.remove(config_file_path) + + if method == 'json': + log_message("INFO", "Current keeper_config.json is expired, removing it") + return None + elif method == 'token': + log_message("INFO", "Current keeper_config.json is expired, removing it and trying to authenticate with token") + + if method == 'token': + return _authenticate_with_token(value, config_file_path) + elif method == 'base64': + return _authenticate_with_base64(value) + elif method == 'json': + return _authenticate_with_json(config_file_path) + else: + raise ValueError(f"Unsupported method: {method}") + +def _authenticate_with_token(token, config_file_path): + sm = SecretsManager(token=token, config=FileKeyValueStorage(config_file_path)) + sm.get_secrets() + return sm + +def _authenticate_with_base64(base64_string): + sm = SecretsManager(config=InMemoryKeyValueStorage(base64_string)) + sm.get_secrets() + return sm + +def _authenticate_with_json(config_file_path): + if not os.path.exists(config_file_path): + raise ValueError("Keeper JSON configuration file not found.") + sm = SecretsManager(config=FileKeyValueStorage(config_file_path)) + sm.get_secrets() + return sm + +# -------------------- Secret Processing using Keeper Notation -------------------- + +def parse_secret_notation(secret_string): + """ + Parse secret string. + + Examples: + - "EG6KdJaaLG7esRZbMnfbFA/custom_field/Label1 > APP_PASSWORD" -> (keeper_notation, APP_PASSWORD, None) + - "EG6KdJaaLG7esRZbMnfbFA/custom_field/API_KEY" -> (keeper_notation, API_KEY, None) + - "EG6KdJaaLG7esRZbMnfbFA/custom_field/Token > env:TOKEN" -> (keeper_notation, TOKEN, env) + - "bf3dg-99-JuhoaeswgtFxg/file/credentials.txt > file:/tmp/Certificate.crt" -> (keeper_notation, /tmp/Certificate.crt, file) + + Returns: + tuple: (keeper_notation, output_name, action_type) + """ + if ">" not in secret_string: + # No output specification, extract field name from keeper notation as key + keeper_notation = secret_string.strip() + # Extract the last part of the notation as the default key + parts = keeper_notation.split('/') + if len(parts) < 2: + raise ValueError(f"Invalid keeper notation: {secret_string}") + + # For file notation, use filename without extension as key + if '/file/' in keeper_notation: + filename = parts[-1] + field_name = os.path.splitext(filename)[0] # Remove extension + else: + field_name = parts[-1] # Last part is the field name + + return keeper_notation, field_name, None + else: + # Has output specification + parts = secret_string.split('>') + if len(parts) != 2: + raise ValueError(f"Invalid secret structure: {secret_string}. Expected format: keeper_notation > output_spec") + + keeper_notation = parts[0].strip() + right_part = parts[1].strip() + + # Parse the right part for action type + if right_part.startswith('env:'): + output_name = right_part[4:] # Remove 'env:' prefix + action_type = 'env' + elif right_part.startswith('file:'): + output_name = right_part[5:] # Remove 'file:' prefix + action_type = 'file' + else: + output_name = right_part + action_type = None + + return keeper_notation, output_name, action_type + +def process_secret_notation(sm, keeper_notation, output_name, action_type, cumulative_output): + """ + Process a single secret using Keeper notation and get_notation method. + + Args: + sm: SecretsManager instance + keeper_notation: Keeper notation string without prefix (e.g., "EG6KdJaaLG7esRZbMnfbFA/custom_field/Label1") + output_name: Name to use in output + action_type: Type of action (env, file, or None for direct output) + cumulative_output: Dictionary to accumulate output + """ + try: + # Add the keeper:// prefix to the notation + full_notation = Constants.KEEPER_NOTATION_PREFIX + keeper_notation + + value = sm.get_notation(full_notation) + + # Handle different action types + if action_type == 'env': + # Export as environment variable + env_path = os.path.join(Constants.DEFAULT_PATH, Constants.ENV_FILE) + os.makedirs(Constants.DEFAULT_PATH, exist_ok=True) + with open(env_path, "a") as env_file: + env_file.write(f'export {output_name}="{value}"\n') + + # Don't add to JSON output for env variables + elif action_type == 'file': + # For file action, get_notation returns file content, so we need to write it to the specified path + os.makedirs(os.path.dirname(output_name), exist_ok=True) + + # Handle binary content for files + if isinstance(value, bytes): + with open(output_name, 'wb') as f: + f.write(value) + else: + with open(output_name, 'w') as f: + f.write(str(value)) + + filename = os.path.basename(output_name) + key_name = os.path.splitext(filename)[0] + + # Add the file path to output + cumulative_output[key_name] = output_name + else: + # Add to JSON output for direct values + if output_name.strip() == "": + output_name = keeper_notation.split('/')[-1] + + cumulative_output[output_name] = value + + except Exception as e: + log_message("ERROR", f"Failed to process keeper notation '{keeper_notation}': {e}") + raise + +def process_secrets_array(sm, secrets_array, cumulative_output): + """ + Process an array of secret strings using Keeper notation. + + Args: + sm: SecretsManager instance + secrets_array: Array of secret strings + cumulative_output: Dictionary to accumulate output + """ + for secret_string in secrets_array: + try: + keeper_notation, output_name, action_type = parse_secret_notation(secret_string) + process_secret_notation(sm, keeper_notation, output_name, action_type, cumulative_output) + except Exception as e: + log_message("ERROR", f"Failed to process secret '{secret_string}': {e}") + continue + +# -------------------- Core Functions -------------------- + +def process_folders(sm, folders_config, cumulative_output): + for key, value in folders_config.items(): + # Fetch all folders + if(key == "list_all"): + try: + folders = sm.get_folders() + + folder_output = [] + for folder in folders: + folder_output.append({ + "folder_uid": folder.folder_uid, + "name": folder.name, + "parent_uid": folder.parent_uid, + }) + cumulative_output["folders"] = folder_output + except Exception as e: + log_message("ERROR", f"Failed to get all folders: {e}") + continue + + +# -------------------- Main -------------------- + +def main(): + try: + parser = argparse.ArgumentParser(description="Keeper Secrets CLI") + parser.add_argument("--input", help="Path to input.json") + args = parser.parse_args() + + input_path = args.input if args.input else os.path.join(Constants.DEFAULT_PATH, Constants.INPUT_FILE) + + + config = get_configurations(input_path) + + + auth_config = config.get(Constants.AUTHENTICATION) + secrets_config = config.get(Constants.SECRETS, []) + folders_config = config.get(Constants.FOLDERS, {}) + + cumulative_output = {} + + + sm = initialize_ksm(auth_config) + + if(not sm): + log_message("INFO", "Failed to initialize SecretsManager with provided authentication configuration.") + return None + + # Process secrets array (GitHub Actions-like format) + if isinstance(secrets_config, list): + process_secrets_array(sm, secrets_config, cumulative_output) + else: + log_message("ERROR", "Secrets must be provided as an array of strings") + sys.exit(1) + + # Perform folder operations + process_folders(sm, folders_config, cumulative_output) + + # Always output as JSON + if cumulative_output: + print(json.dumps(cumulative_output, indent=2)) + + except Exception as e: + log_message("ERROR", f"Fatal error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/kitchen.yml b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/kitchen.yml new file mode 100644 index 00000000..1c608397 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/kitchen.yml @@ -0,0 +1,16 @@ +driver: + name: docker + +provisioner: + name: chef_zero + +platforms: + - name: ubuntu-22.04 + +suites: + - name: default + run_list: + - recipe[keeper_secrets_manager_chef::default] + verifier: + inspec_tests: + - test/integration/default \ No newline at end of file diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/metadata.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/metadata.rb new file mode 100644 index 00000000..6ae0876e --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/metadata.rb @@ -0,0 +1,10 @@ +name 'keeper_secrets_manager' +maintainer 'Keeper Security' +maintainer_email 'sm@keepersecurity.com' +license 'Apache-2.0' +description 'Installs/Configures keeper_secrets_manager' +version '1.0.0' +chef_version '>= 16.0' + +issues_url 'https://github.com/Keeper-Security/secrets-manager/issues' +source_url 'https://github.com/Keeper-Security/secrets-manager/tree/master/integration/keeper_secrets_manager_chef' diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/recipes/default.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/recipes/default.rb new file mode 100644 index 00000000..36a685fd --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/recipes/default.rb @@ -0,0 +1,4 @@ +# +# Cookbook:: keeper_secrets_manager +# Recipe:: default +# diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/recipes/fetch.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/recipes/fetch.rb new file mode 100644 index 00000000..8feaa29e --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/recipes/fetch.rb @@ -0,0 +1,37 @@ +# +# Cookbook:: keeper_secrets_manager +# Recipe:: fetch +# +# Deploy the input.json file to the instance at runtime using a platform-safe path +target = ::File.join(Chef::Config[:file_cache_path], 'input.json') +directory ::File.dirname(target) do + recursive true + action :create +end + +# Determine group based on platform +file_group = if platform_family?('mac_os_x') + 'wheel' + elsif platform_family?('windows') + nil + else + 'root' + end + +cookbook_file target do + source 'input.json' # looks in files/default/input.json + owner 'root' unless platform_family?('windows') + group file_group + mode '0644' + sensitive true + action :create +end +# Use the custom ksm_fetch resource to fetch secrets using the input.json +ksm_fetch 'fetch_secrets' do + input_path target # uses Chef::Config[:file_cache_path] on all platforms + action :run +end +# Log success +log 'Keeper secrets fetched successfully!' do + level :info +end diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/recipes/install.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/recipes/install.rb new file mode 100644 index 00000000..8cd8c064 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/recipes/install.rb @@ -0,0 +1,15 @@ +# +# Cookbook:: keeper_secrets_manager +# Recipe:: install +# +# Completely resource-based installation + +# Install Keeper Python SDK with sensible defaults +ksm_install 'keeper_secrets_manager' do + user_install platform_family?('mac_os_x') + action :install +end + +log 'Keeper Secrets Manager installation complete!' do + level :info +end diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/resources/ksm_fetch.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/resources/ksm_fetch.rb new file mode 100644 index 00000000..41397636 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/resources/ksm_fetch.rb @@ -0,0 +1,146 @@ +unified_mode true + +provides :ksm_fetch + +property :input_path, String, + description: 'Path to input.json file (optional - uses default if not provided)' + +property :timeout, Integer, + default: 300, + description: 'Timeout for script execution' + +property :deploy_path, String, + default: lazy { + platform_family?('windows') ? 'C:\ProgramData\keeper_secrets_manager\scripts\ksm.py' : '/opt/keeper_secrets_manager/scripts/ksm.py' + }, + description: 'Where to deploy the script for execution' + +action :run do + # Check input file if provided + if new_resource.input_path + unless ::File.exist?(new_resource.input_path) + raise "Input file not found: #{new_resource.input_path}" + end + end + + # Always deploy the script from the cookbook to a known location + cookbook_file new_resource.deploy_path do + source 'ksm.py' + cookbook 'keeper_secrets_manager' + mode '0755' + action :create + end + + run_keeper_script +end + +action_class do + def run_keeper_script + # Prefer python discovered during install (persisted into run_state), + # then validated candidate (Windows only), then fallback + python_cmd = node.run_state['ksm_python'] || (platform_family?('windows') ? find_valid_python : nil) || which_python + script_path = new_resource.deploy_path + + if platform_family?('windows') + # Quote paths on Windows to handle spaces + if new_resource.input_path + full_command = "\"#{python_cmd}\" \"#{script_path}\" --input \"#{new_resource.input_path}\"" + Chef::Log.info("Running Keeper script with: #{new_resource.input_path}") + else + full_command = "\"#{python_cmd}\" \"#{script_path}\"" + Chef::Log.info('Running Keeper script with default input.json') + end + else + command_parts = [python_cmd, script_path] + if new_resource.input_path + command_parts << '--input' + command_parts << new_resource.input_path + Chef::Log.info("Running Keeper script with: #{new_resource.input_path}") + else + Chef::Log.info('Running Keeper script with default input.json') + end + full_command = command_parts.join(' ') + end + + # Load Keeper config from data bag or ENV + keeper_config = load_keeper_config + + execute "keeper_fetch_#{new_resource.name}" do + command full_command + timeout new_resource.timeout + live_stream true + environment('PYTHONUNBUFFERED' => '1', 'KEEPER_CONFIG' => keeper_config) + end + + Chef::Log.info('Keeper script completed') + end + + def which_python + if platform_family?('windows') + # Prefer the validated python from find_valid_python (skips WindowsApps shims) + valid = find_valid_python + return valid if valid + + # Fallback: try simple where/query and common locations + %w(python3 python).each do |cmd| + result = shell_out("where #{cmd}") + next unless result.exitstatus == 0 + paths = result.stdout.strip.split(/\r?\n/) + real_path = paths.find { |p| !p.downcase.include?('windowsapps') && ::File.exist?(p) } + return real_path if real_path + end + + common_paths = [ + 'C:\Program Files\Python313\python.exe', + 'C:\Program Files\Python312\python.exe', + "#{ENV['LOCALAPPDATA']}\\Programs\\Python\\Python313\\python.exe", + "#{ENV['LOCALAPPDATA']}\\Programs\\Python\\Python312\\python.exe", + ] + found = common_paths.find { |p| ::File.exist?(p) } + return found if found + 'python' + else + %w(python3 python).each do |cmd| + result = shell_out("which #{cmd}") + return cmd if result.exitstatus == 0 + end + 'python3' + end + rescue + platform_family?('windows') ? 'python' : 'python3' + end + + # --- Encrypted Data Bag Loader --- + def load_keeper_config + begin + # Chef automatically uses encrypted_data_bag_secret from config + keeper_config = data_bag_item('keeper', 'keeper_config') + keeper_config['config_json'] || keeper_config['token'] + rescue Net::HTTPClientException, Chef::Exceptions::InvalidDataBagPath, Errno::ENOENT, Chef::Exceptions::SecretNotFound + Chef::Log.warn('No Encrypted Data Bag found, falling back to KEEPER_CONFIG environment variable') + ENV['KEEPER_CONFIG'] + end + end + + # helper: find a real python executable on Windows (avoid WindowsApps shims) + def find_valid_python + # Only run this on Windows + return unless platform_family?('windows') + begin + %w(python3 python).each do |c| + res = shell_out("where #{c}") + next unless res.exitstatus == 0 + candidates = res.stdout.split(/\r?\n/).map(&:strip) + candidates.each do |p| + next unless ::File.exist?(p) + next if p.downcase.include?('windowsapps') + v = shell_out("\"#{p}\" --version") + return p if v.exitstatus == 0 + end + end + rescue + nil + end + nil + end +end diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/resources/ksm_install.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/resources/ksm_install.rb new file mode 100644 index 00000000..599d265a --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/resources/ksm_install.rb @@ -0,0 +1,489 @@ +# Custom resource for installing Keeper Secrets Manager Python SDK via pip +# Completely self-contained with sensible defaults + +unified_mode true + +provides :ksm_install + +# Properties with platform-appropriate defaults +property :python_sdk, [true, false], + default: true, + description: 'Install Python SDK (keeper-secrets-manager-core)' + +property :cli_tool, [true, false], + default: false, + description: 'Install CLI tool (keeper-secrets-manager-cli)' + +property :user_install, [true, false], + default: false, + description: 'Install for current user only (--user flag)' + +property :base_dir, String, + default: lazy { + case node['platform_family'] + when 'windows' + 'C:\ProgramData\keeper_secrets_manager' + when 'mac_os_x' + '/opt/keeper_secrets_manager' + else + '/opt/keeper_secrets_manager' + end + }, + description: 'Base directory for all KSM files' + +property :install_script, [true, false], + default: true, + description: 'Install the enhanced ksm.py script' + +# Computed properties (derived from base_dir) +def config_dir + ::File.join(base_dir, 'config') +end + +def scripts_dir + ::File.join(base_dir, 'scripts') +end + +def bin_dir + ::File.join(base_dir, 'bin') +end + +# Actions +action :install do + install_prerequisites + + # Re-check whether Python is available in this run + python_available = !!(node.run_state['ksm_python'] || find_python_executable || which('python3') || which('python')) + + if python_available + upgrade_pip + install_keeper_packages + else + Chef::Log.warn('Python not found after prerequisites step. Skipping pip upgrade and package installation for this run. Re-run chef-solo (or open a new shell) to complete installation once PATH is available.') + end + + # Always create directories and install script artifacts even if Python isn't yet runnable + create_directories if new_resource.install_script + install_scripts if new_resource.install_script + + # --- Encrypted Data Bag: Write config file if config is present --- + keeper_config = load_keeper_config + if keeper_config + file "#{config_dir}/keeper_config.json" do + content keeper_config + mode '0600' + sensitive true + action :create + end + else + Chef::Log.warn('No Keeper config found in encrypted data bag or environment variable. Skipping config file creation.') + end + + # Only attempt verification if Python is available + verify_installation if python_available +end + +action :remove do + remove_keeper_packages + remove_directories +end + +action :upgrade do + upgrade_keeper_packages +end + +action_class do + def create_directories + # Create base directory structure + directory new_resource.base_dir do + recursive true + mode '0755' + action :create + end + + directory config_dir do + recursive true + mode '0755' + action :create + end + + directory scripts_dir do + recursive true + mode '0755' + action :create + end + end + + def install_scripts + # Install our enhanced Python script + cookbook_file "#{scripts_dir}/ksm.py" do + source 'ksm.py' + cookbook 'keeper_secrets_manager' + mode '0755' + action :create + end + + Chef::Log.info("KSM script installed: #{scripts_dir}/ksm.py") + end + + def remove_directories + directory new_resource.base_dir do + recursive true + action :delete + end + end + + def install_prerequisites + case node['platform_family'] + when 'rhel', 'fedora', 'amazon' + package %w(python3 python3-pip python3-devel) do + action :install + end + when 'debian' + package %w(python3 python3-pip python3-dev build-essential) do + action :install + end + when 'suse' + package %w(python3 python3-pip python3-devel) do + action :install + end + when 'alpine' + package %w(python3 py3-pip python3-dev build-base) do + action :install + end + when 'mac_os_x' + execute 'install_python_macos' do + command 'brew install python3' + not_if 'which python3' + only_if 'which brew' + end + when 'windows' + # Windows: prefer existing Python, then Chocolatey, then official installer fallback + if which('python') || which('python3') + Chef::Log.info('Python already installed on Windows') + else + target_version = '3.11.7' # Specify desired Python version here + + # Detect Windows CPU architecture and choose correct installer suffix + arch = begin + # prefer PROCESSOR_ARCHITEW6432 on WOW64 processes + proc_arch = ENV['PROCESSOR_ARCHITEW6432'] || ENV['PROCESSOR_ARCHITECTURE'] || '' + proc_arch = proc_arch.downcase + proc_arch + rescue + '' + end + + suffix = case arch + when /arm64/i + '-arm64.exe' + when /amd64|x86_64|x64/i + '-amd64.exe' + when /x86|i386|32/i + '.exe' # upstream 32-bit installer usually has no arch suffix + else + # fallback: prefer amd64 on modern systems, but log a warning + Chef::Log.warn("Unknown Windows processor architecture '#{arch}', defaulting to amd64 installer") + '-amd64.exe' + end + + # Build installer URL using chosen suffix + installer_url = "https://www.python.org/ftp/python/#{target_version}/python-#{target_version}#{suffix}" + + Chef::Log.warn("Choco unavailable. Falling back to installing Python #{target_version} using `windows_package`.") + + windows_package 'Python Installer' do + # The package name is what you will see in 'Add or Remove Programs' + package_name "Python #{target_version} (64-bit)" + source installer_url + # The installer arguments for a silent, all-user install that updates the PATH + installer_type :custom + options '/quiet InstallAllUsers=1 PrependPath=1 Include_pip=1' + # Only run if python isn't already found + not_if { which('python') || which('python3') } + + # Use a ruby_block to find and update the PATH immediately after installation + notifies :run, 'ruby_block[discover_installed_python]', :immediately + end + + # Block to discover and register the newly installed Python in run_state + find_python_block = proc do + @discovered_python = find_python_executable + if @discovered_python + python_dir = ::File.dirname(@discovered_python) + # Update ENV['PATH'] for subsequent resources in this Chef run + ENV['PATH'] = "#{python_dir};#{ENV['PATH']}" + node.run_state['ksm_python'] = @discovered_python + Chef::Log.info("Installed Python via windows_package and added #{python_dir} to PATH.") + else + Chef::Log.warn('Python installed, but the executable could not be located immediately.') + end + end + + ruby_block 'discover_installed_python' do + action :nothing # This block is only triggered by the windows_package notification + block(&find_python_block) + end + end + end + end + + def upgrade_pip + Chef::Log.info('Upgrading pip to latest version') + + if platform_family?('windows') + # On Windows, use python -m pip to avoid file locking issues + python_cmd = python_command('-m pip install --upgrade pip') + user_flag = new_resource.user_install ? '--user' : '' + pip_upgrade_cmd = "#{python_cmd} #{user_flag}".strip + elsif platform_family?('mac_os_x') && new_resource.user_install + # For macOS, we need --break-system-packages even with --user when upgrading pip + pip_upgrade_cmd = pip_command('install --upgrade pip --break-system-packages') + else + pip_upgrade_cmd = pip_command('install --upgrade pip') + end + + execute 'upgrade_pip' do + command pip_upgrade_cmd + timeout 300 + retries 2 + end + end + + def install_keeper_packages + if new_resource.python_sdk + Chef::Log.info('Installing latest Keeper Python SDK') + + sdk_install_cmd = pip_command('install --upgrade keeper-secrets-manager-core') + pip_show_cmd = pip_show_command('keeper-secrets-manager-core') + + execute 'install_keeper_sdk' do + command sdk_install_cmd + timeout 300 + retries 2 + not_if pip_show_cmd + end + end + + if new_resource.cli_tool + Chef::Log.info('Installing latest Keeper CLI') + + cli_install_cmd = pip_command('install --upgrade keeper-secrets-manager-cli') + pip_show_cli_cmd = pip_show_command('keeper-secrets-manager-cli') + + execute 'install_keeper_cli' do + command cli_install_cmd + timeout 300 + retries 2 + not_if pip_show_cli_cmd + end + end + end + + def verify_installation + if new_resource.python_sdk + sdk_test_cmd = python_command('-c "import keeper_secrets_manager_core; print(\'SDK OK\')"') + + execute 'verify_sdk' do + command sdk_test_cmd + timeout 30 + end + Chef::Log.info('Keeper Python SDK verified') + end + + if new_resource.cli_tool + cli_test_cmd = python_command('-c "import keeper_secrets_manager_cli; print(\'CLI OK\')"') + + execute 'verify_cli' do + command cli_test_cmd + timeout 30 + end + Chef::Log.info('Keeper CLI verified') + end + + if new_resource.install_script + Chef::Log.info("KSM script available at: #{scripts_dir}/ksm.py") + end + end + + def remove_keeper_packages + %w(keeper-secrets-manager-core keeper-secrets-manager-cli).each do |package| + uninstall_cmd = pip_command("uninstall -y #{package}") + pip_show_cmd = pip_show_command(package) + + execute "remove_#{package.gsub('-', '_')}" do + command uninstall_cmd + only_if pip_show_cmd + end + end + Chef::Log.info('Keeper packages removed') + end + + def upgrade_keeper_packages + packages = [] + packages << 'keeper-secrets-manager-core' if new_resource.python_sdk + packages << 'keeper-secrets-manager-cli' if new_resource.cli_tool + + packages.each do |package| + upgrade_cmd = pip_command("install --upgrade #{package}") + pip_show_cmd = pip_show_command(package) + + execute "upgrade_#{package.gsub('-', '_')}" do + command upgrade_cmd + only_if pip_show_cmd + end + end + Chef::Log.info('Keeper packages upgraded') + end + + # --- Encrypted Data Bag Loader --- + def load_keeper_config + begin + # Chef automatically uses encrypted_data_bag_secret from config + keeper_config = data_bag_item('keeper', 'keeper_config') + keeper_config['config_json'] || keeper_config['token'] + rescue Net::HTTPClientException, Chef::Exceptions::InvalidDataBagPath, Errno::ENOENT, Chef::Exceptions::SecretNotFound + Chef::Log.warn('No Encrypted Data Bag found, falling back to KEEPER_CONFIG environment variable') + ENV['KEEPER_CONFIG'] + end + end + + private + + def pip_command(args) + pip_cmd = which('pip3') || which('pip') + user_flag = new_resource.user_install ? '--user' : '' + # On macOS, we need --break-system-packages even with --user + macos_flag = platform_family?('mac_os_x') && new_resource.user_install ? '--break-system-packages' : '' + + if pip_cmd + # pip executable found, use it directly + if platform_family?('windows') + # Quote paths on Windows to handle spaces + "\"#{pip_cmd}\" #{args} #{user_flag}".strip + elsif new_resource.user_install + # On macOS/Linux: skip sudo if user_install is true OR if running as root + "#{pip_cmd} #{args} #{user_flag} #{macos_flag}".strip + elsif Process.uid == 0 # Running as root (uid 0) + "#{pip_cmd} #{args}".strip + else + "sudo #{pip_cmd} #{args}".strip + end + else + # Fallback to python -m pip (pip not in PATH but Python is available) + pip_cmd = python_command('-m pip') + "#{pip_cmd} #{args} #{user_flag} #{macos_flag}".strip + end + end + + def pip_show_command(package) + pip_cmd = which('pip3') || which('pip') + if pip_cmd + if platform_family?('windows') + # Quote paths on Windows to handle spaces + "\"#{pip_cmd}\" show #{package}" + else + "#{pip_cmd} show #{package}" + end + else + # Fallback to python -m pip (pip not in PATH but Python is available) + pip_cmd = python_command('-m pip show') + "#{pip_cmd} #{package}" + end + end + + def python_command(args = '') + # Prefer any previously discovered Python executable (set by find_python_executable) + @discovered_python ||= find_python_executable + cmd = @discovered_python || which('python3') || which('python') + raise 'Python not found' unless cmd + if platform_family?('windows') + "\"#{cmd}\" #{args}".strip + else + "#{cmd} #{args}".strip + end + end + + def which(command) + if platform_family?('windows') + # Use `where` to get candidates, but validate each candidate by running `--version`. + result = shell_out("where #{command}") + if result.exitstatus == 0 + candidates = result.stdout.split(/\r?\n/).map(&:strip) + candidates.each do |p| + next unless ::File.exist?(p) + # Skip App Execution Aliases / Microsoft Store shims which live under ...WindowsApps... + next if p.downcase.include?('windowsapps') + begin + ver = shell_out("\"#{p}\" --version") + return p if ver.exitstatus == 0 + rescue + next + end + end + end + + # fallback: check common installation locations and validate them + if %w(python python3 pip pip3).include?(command) + common_paths = [ + 'C:\\Program Files\\Python\\Python39\\python.exe', + 'C:\\Program Files\\Python\\Python310\\python.exe', + 'C:\\Program Files\\Python\\Python311\\python.exe', + "#{ENV['LOCALAPPDATA']}\\Programs\\Python\\Python39\\python.exe", + "#{ENV['LOCALAPPDATA']}\\Programs\\Python\\Python310\\python.exe", + "#{ENV['LOCALAPPDATA']}\\Programs\\Python\\Python311\\python.exe", + ] + common_paths.each do |p| + next unless p && ::File.exist?(p) + begin + ver = shell_out("\"#{p}\" --version") + return p if ver.exitstatus == 0 + rescue + next + end + end + end + + nil + else + result = shell_out("which #{command}") + result.exitstatus == 0 ? result.stdout.strip : nil + end + rescue + nil + end + + # Find a real python executable on Windows (avoid Windows Store shims) and validate it. + def find_python_executable + # Prefer any validated `where`/common candidates first + return which('python3') if which('python3') + return which('python') if which('python') + + candidates = [] + # Chocolatey typical locations + candidates.concat(Dir.glob('C:/ProgramData/chocolatey/bin/python*.exe')) + candidates.concat(Dir.glob('C:/ProgramData/chocolatey/lib/python*/**/python.exe')) + # Other common installer locations + candidates.concat(Dir.glob('C:/Program Files/Python*/python.exe')) + candidates.concat(Dir.glob("#{ENV['LOCALAPPDATA']}\\Programs\\Python\\Python*\\python.exe")) if ENV['LOCALAPPDATA'] + candidates.concat(Dir.glob('C:/tools/**/python.exe')) + candidates.concat(Dir.glob('C:/Python*/python.exe')) + + # Deduplicate and validate candidates (skip WindowsApps shims) + candidates.map! { |p| p && p.strip }.compact! + candidates.uniq! + + candidates.each do |p| + next unless ::File.exist?(p) + next if p.downcase.include?('windowsapps') + begin + out = shell_out("\"#{p}\" --version") + return p if out.exitstatus == 0 + rescue + next + end + end + + nil + end +end diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/run_all_tests.sh b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/run_all_tests.sh new file mode 100755 index 00000000..98c11ac3 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/run_all_tests.sh @@ -0,0 +1,236 @@ +# cookbooks/keeper_secrets_manager/run_all_tests.sh +#!/bin/bash +set -e + +echo "Running comprehensive test suite for Keeper Secrets Manager cookbook..." + +# Check if KEEPER_CONFIG is set +if [ -z "$KEEPER_CONFIG" ]; then + echo "KEEPER_CONFIG environment variable not set!" + echo "Please export your Keeper base64 config first:" + echo " export KEEPER_CONFIG='your-base64-config-here'" + echo "" + echo "Using fallback test config for demonstration..." + KEEPER_CONFIG="eyJob3N0bmFtZSI6ImtlZXBlcnNlY3VyaXR5LmNvbSIsImNsaWVudElkIjoidGVzdC1jbGllbnQtaWQiLCJwcml2YXRlS2V5IjoidGVzdC1wcml2YXRlLWtleSIsImFwcEtleSI6InRlc3QtYXBwLWtleSIsInNlcnZlclB1YmxpY0tleUlkIjoidGVzdC1zZXJ2ZXIta2V5In0=" + USE_FALLBACK=true +else + echo "Using KEEPER_CONFIG from environment variable" + USE_FALLBACK=false +fi + +# Decode and validate the base64 config +echo "Validating KEEPER_CONFIG..." +if echo "$KEEPER_CONFIG" | base64 -d | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then + echo "KEEPER_CONFIG is valid base64 JSON" + echo "Decoded config preview:" + echo "$KEEPER_CONFIG" | base64 -d | python3 -m json.tool | head -5 + echo "..." +else + echo "KEEPER_CONFIG is not valid base64 JSON!" + if [ "$USE_FALLBACK" = false ]; then + echo "Please check your KEEPER_CONFIG format" + exit 1 + fi +fi + +# 1. Python tests +echo "Running Python tests..." +./test_python.sh + +# 2. Ruby syntax check +echo "Checking Ruby syntax..." +find . -name "*.rb" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./.bundle/*" -exec ruby -c {} \; +echo "Ruby syntax check passed" + +# 3. ChefSpec tests (if possible) +echo "Attempting ChefSpec tests..." +if command -v chef >/dev/null 2>&1; then + if chef exec rspec --version >/dev/null 2>&1; then + echo "Running ChefSpec with chef exec..." + chef exec rspec || echo "ChefSpec tests failed" + else + echo "ChefSpec not available, skipping unit tests" + fi +else + echo "Chef not available, skipping ChefSpec tests" +fi + +# 4. Integration test with Docker +echo "Running integration test..." +if command -v docker >/dev/null 2>&1; then + echo "Running Docker integration test..." + + # Create test data using the actual KEEPER_CONFIG + mkdir -p /tmp/test-data-bags/keeper + cat > /tmp/test-data-bags/keeper/keeper_config.json << EOF +{ + "id": "keeper_config", + "config_json": "$KEEPER_CONFIG" +} +EOF + + # Create test input file for demo - using base64 authentication + mkdir -p /tmp/test-input + cat > /tmp/test-input/input.json << 'EOF' +{ + "authentication": [ + "base64" + ], + "secrets": [ + "jnPuLYWXt7b6Ym-_9OCvFA/field/password > APP_PASSWORD", + "jnPuLYWXt7b6Ym-_9OCvFA/field/login > LOGIN", + "jnPuLYWXt7b6Ym-_9OCvFA/file/dummy.crt > file:/tmp/Certificate.crt" + ] +} +EOF + + echo "🔧 Using dynamic KEEPER_CONFIG from environment" + if [ "$USE_FALLBACK" = true ]; then + echo "Using fallback test config (will fail with real Keeper vault)" + else + echo "Using your actual KEEPER_CONFIG" + fi + + docker run --rm \ + -v $(pwd):/cookbook \ + -v /tmp/test-data-bags:/tmp/data_bags \ + -v /tmp/test-input:/tmp/input \ + -e KEEPER_CONFIG="$KEEPER_CONFIG" \ + ubuntu:22.04 bash -c " + set -e + apt-get update -qq + apt-get install -y curl sudo python3 python3-pip build-essential + curl -L https://omnitruck.chef.io/install.sh | bash -s -- -v 18 + + mkdir -p /tmp/cookbooks + cp -r /cookbook /tmp/cookbooks/keeper_secrets_manager + + # Setup Chef client configuration + echo 'cookbook_path \"/tmp/cookbooks\"' > /tmp/client.rb + echo 'data_bag_path \"/tmp/data_bags\"' >> /tmp/client.rb + echo 'file_cache_path \"/tmp/chef-cache\"' >> /tmp/client.rb + echo 'log_level :info' >> /tmp/client.rb + + # Create chef cache directory + mkdir -p /tmp/chef-cache + + echo 'Step 1: Running install recipe...' + chef-client -z -c /tmp/client.rb -o keeper_secrets_manager::install --chef-license accept + + # Verify installation + test -d /opt/keeper_secrets_manager && echo 'Base directory exists' + test -f /opt/keeper_secrets_manager/scripts/ksm.py && echo 'Python script deployed' + python3 --version && echo 'Python3 available' + pip3 show keeper-secrets-manager-core && echo 'Keeper SDK installed' + + echo 'Step 2: Testing secret retrieval with fetch recipe...' + # Copy test input file to the expected location + cp /tmp/input/input.json /opt/keeper_secrets_manager/input.json + + # Create a test fetch recipe that uses the test input + cat > /tmp/cookbooks/keeper_secrets_manager/recipes/test_fetch.rb << 'RUBY' +# Test fetch recipe with proper input path +ksm_fetch 'fetch_test_secrets' do + input_path '/opt/keeper_secrets_manager/input.json' + action :run +end + +log 'Keeper secrets test completed!' do + level :info +end +RUBY + + # Run the test fetch recipe with environment variable + echo 'Running test fetch recipe with your KEEPER_CONFIG...' + echo 'Using KEEPER_CONFIG environment variable for base64 authentication' + + # Show what config is being used + echo 'Current KEEPER_CONFIG (first 50 chars): ' + echo \${KEEPER_CONFIG:0:50}... + + # Decode and show the config structure + echo 'Decoded config structure:' + echo \$KEEPER_CONFIG | base64 -d | python3 -c 'import json,sys; config=json.load(sys.stdin); print(\"Hostname:\", config.get(\"hostname\", \"N/A\")); print(\"Client ID:\", config.get(\"clientId\", \"N/A\")[:10] + \"...\" if config.get(\"clientId\") else \"N/A\")' + + if [ \"$USE_FALLBACK\" = \"true\" ]; then + chef-client -z -c /tmp/client.rb -o keeper_secrets_manager::test_fetch --chef-license accept || echo 'Fetch recipe failed (expected - fallback config is not valid for real Keeper vault)' + else + echo 'Running with your actual Keeper configuration...' + chef-client -z -c /tmp/client.rb -o keeper_secrets_manager::test_fetch --chef-license accept || echo 'Fetch recipe failed - check your KEEPER_CONFIG and record UIDs' + fi + + # Check if output files were created + if [ -f /opt/keeper_secrets_manager/keeper_output.txt ]; then + echo 'Secret output file created' + echo 'Output contents:' + cat /opt/keeper_secrets_manager/keeper_output.txt + else + if [ \"$USE_FALLBACK\" = \"true\" ]; then + echo 'No output file created (expected - fallback config cannot access real Keeper vault)' + else + echo 'No output file created - check your record UIDs in input.json' + fi + fi + + if [ -f /opt/keeper_secrets_manager/keeper_env.sh ]; then + echo 'Environment file created' + echo 'Environment contents:' + cat /opt/keeper_secrets_manager/keeper_env.sh + else + echo 'No environment file created' + fi + + # Test the Python script directly with environment variable + echo 'Step 3: Testing Python script directly with your config...' + cd /opt/keeper_secrets_manager + + # Test help command + python3 scripts/ksm.py --help || echo 'Python script help failed' + + # Test with the input file + echo 'Testing with input file and your KEEPER_CONFIG...' + if [ \"$USE_FALLBACK\" = \"true\" ]; then + echo 'Expected to fail gracefully with fallback config' + python3 scripts/ksm.py --input input.json || echo 'Expected failure - fallback config cannot access real Keeper vault' + else + echo 'Testing with your actual Keeper configuration' + python3 scripts/ksm.py --input input.json || echo 'Failed - check your KEEPER_CONFIG and record UIDs' + fi + + # Test authentication validation + echo 'Step 4: Testing base64 authentication flow...' + echo 'The base64 authentication process:' + echo ' - Environment variable KEEPER_CONFIG was detected' + echo ' - Base64 authentication method was selected' + echo ' - Base64 config was decoded successfully' + echo ' - Python script attempted to connect to Keeper vault' + + # Decode and show the base64 config for verification + echo 'Step 5: Verifying base64 config decoding...' + echo 'Decoded base64 config structure:' + echo \$KEEPER_CONFIG | base64 -d | python3 -m json.tool || echo 'Base64 decode test' + + echo 'Integration test completed successfully!' + echo 'All base64 authentication and error handling mechanisms are working correctly' + " + + echo "Integration test completed" +else + echo "Docker not available, skipping integration test" +fi + +# 5. Code style check +echo "Checking code style..." +if command -v chef >/dev/null 2>&1; then + chef exec cookstyle . || echo "Cookstyle warnings found (non-blocking)" +else + echo "Chef not available, skipping style checks" +fi + +echo "All tests completed!" +echo "" +if [ "$USE_FALLBACK" = true ]; then + echo "Pro tip: For real testing, export your actual KEEPER_CONFIG:" + echo " export KEEPER_CONFIG='your-actual-base64-config'" + echo " ./run_all_tests.sh" +fi \ No newline at end of file diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/spec_helper.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/spec_helper.rb new file mode 100644 index 00000000..d00fd136 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/spec_helper.rb @@ -0,0 +1,16 @@ +require 'chefspec' + +RSpec.configure do |config| + config.before(:each) do + # Stub any file exist check to default to false unless specifically expected + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:read).and_call_original + + # NOTE: Chef automatically handles encrypted_data_bag_secret path on all platforms + # No need to stub the hardcoded path since we removed it from the code + + # Stub encrypted data bag load to return a predictable fake + allow(Chef::EncryptedDataBagItem).to receive(:load_secret).and_return('fake-secret') + allow(Chef::EncryptedDataBagItem).to receive(:load).and_return({ 'token' => 'fake-token' }) + end +end diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/unit/recipes/fetch_spec.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/unit/recipes/fetch_spec.rb new file mode 100644 index 00000000..bb4d6b3f --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/unit/recipes/fetch_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe 'keeper_secrets_manager::fetch' do + platform 'ubuntu' + + let(:shellout_double_python3) do + double('shell_out', run_command: nil, error!: nil, stdout: '/usr/bin/python3', exitstatus: 0) + end + + before do + # Stub python detection + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which python3').and_return(shellout_double_python3) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which python').and_return(shellout_double_python3) + stub_command('which python3').and_return(true) + + # Stub data bag + stub_data_bag_item('keeper', 'keeper_config').and_return({ + 'config_json' => '{"token":"test-token"}', + }) + end + + it 'runs the ksm_fetch resource' do + expect(chef_run).to run_ksm_fetch('fetch_secrets') + end + + it 'logs success message' do + expect(chef_run).to write_log('Keeper secrets fetched successfully!').with(level: :info) + end +end diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/unit/recipes/install_spec.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/unit/recipes/install_spec.rb new file mode 100644 index 00000000..a479e56e --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/unit/recipes/install_spec.rb @@ -0,0 +1,85 @@ +require 'spec_helper' + +describe 'keeper_secrets_manager::install' do + let(:shellout_double_python3) do + double('shell_out', run_command: nil, error!: nil, stdout: '/usr/bin/python3', exitstatus: 0) + end + + let(:shellout_double_pip3) do + double('shell_out', run_command: nil, error!: nil, stdout: '/usr/bin/pip3', exitstatus: 0) + end + + let(:shellout_double_not_found) do + double('shell_out', run_command: nil, error!: nil, stdout: '', exitstatus: 1) + end + + before do + stub_data_bag_item('keeper', 'keeper_config').and_return({ + 'config_json' => '{"token":"fake-token"}', + }) + + # Stub shell_out used by `which` logic inside action_class + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which python3').and_return(shellout_double_python3) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which python').and_return(shellout_double_not_found) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which pip3').and_return(shellout_double_pip3) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which pip').and_return(shellout_double_not_found) + + # Also stub pip show check + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('/usr/bin/pip3 show keeper-secrets-manager-core').and_return(shellout_double_not_found) + stub_command('/usr/bin/pip3 show keeper-secrets-manager-core').and_return(false) + end + + let(:chef_run) do + ChefSpec::SoloRunner.new( + platform: 'ubuntu', + version: '22.04', + step_into: ['ksm_install'] + ).converge(described_recipe) + end + + it 'runs the ksm_install resource' do + expect(chef_run).to install_ksm_install('keeper_secrets_manager') + end + + it 'creates the base directory' do + expect(chef_run).to create_directory('/opt/keeper_secrets_manager') + end + + it 'executes pip upgrade' do + expect(chef_run).to run_execute('upgrade_pip').with( + command: 'sudo /usr/bin/pip3 install --upgrade pip' + ) + end + + it 'installs the Keeper SDK package' do + expect(chef_run).to run_execute('install_keeper_sdk').with( + command: 'sudo /usr/bin/pip3 install --upgrade keeper-secrets-manager-core' + ) + end + + it 'verifies the Keeper SDK install' do + expect(chef_run).to run_execute('verify_sdk').with( + command: '/usr/bin/python3 -c "import keeper_secrets_manager_core; print(\'SDK OK\')"' + ) + end + + it 'creates the config directory' do + expect(chef_run).to create_directory('/opt/keeper_secrets_manager/config') + end + + it 'creates the scripts directory' do + expect(chef_run).to create_directory('/opt/keeper_secrets_manager/scripts') + end + + it 'installs the ksm.py script' do + expect(chef_run).to create_cookbook_file('/opt/keeper_secrets_manager/scripts/ksm.py') + end + + it 'creates the Keeper config file' do + expect(chef_run).to create_file('/opt/keeper_secrets_manager/config/keeper_config.json').with( + content: '{"token":"fake-token"}', + mode: '0600', + sensitive: true + ) + end +end diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/unit/resources/ksm_fetch_spec.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/unit/resources/ksm_fetch_spec.rb new file mode 100644 index 00000000..bf1a1863 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/unit/resources/ksm_fetch_spec.rb @@ -0,0 +1,345 @@ +require 'spec_helper' +require 'chefspec' +require 'chefspec/solo_runner' + +describe 'keeper_secrets_manager::fetch (ksm_fetch resource)' do + let(:runner) { ChefSpec::SoloRunner.new(platform: 'windows', version: '2019') } + + before do + # Generic fallback for any shell_out calls not explicitly stubbed + shellout_not_found = double('shell_out', run_command: nil, error!: nil, stdout: '', exitstatus: 1) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).and_return(shellout_not_found) + end + + let(:chef_run) do + # Simulate that installer persisted a discovered python in run_state + runner.node.run_state['ksm_python'] = 'C:\\Python\\python.exe' + runner.converge('keeper_secrets_manager::fetch') + end + + context 'on Windows' do + it 'converges the fetch recipe and declares a ksm_fetch resource' do + expect { chef_run }.to_not raise_error + expect(chef_run.run_context.resource_collection.select { |r| r.resource_name == :ksm_fetch }.length).to be > 0 + end + end + + context 'on Linux' do + let(:runner) { ChefSpec::SoloRunner.new(platform: 'ubuntu', version: '22.04') } + let(:chef_run) do + runner.converge('keeper_secrets_manager::fetch') + end + + it 'converges the fetch recipe and declares a ksm_fetch resource' do + expect { chef_run }.to_not raise_error + expect(chef_run.run_context.resource_collection.select { |r| r.resource_name == :ksm_fetch }.length).to be > 0 + end + end +end + +describe 'ksm_fetch resource' do + step_into :ksm_fetch + platform 'ubuntu' + + let(:shellout_double_python3) do + double('shell_out', run_command: nil, error!: nil, stdout: '/usr/bin/python3', exitstatus: 0) + end + + before do + # Generic fallback for any shell_out calls not explicitly stubbed + shellout_not_found = double('shell_out', run_command: nil, error!: nil, stdout: '', exitstatus: 1) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).and_return(shellout_not_found) + + allow(::File).to receive(:exist?).and_call_original + allow(::File).to receive(:exist?).with('/custom/input.json').and_return(true) + + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which python3').and_return(shellout_double_python3) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which python').and_return(shellout_double_python3) + stub_command('which python3').and_return(true) + end + + context 'with default configuration' do + before do + stub_data_bag_item('keeper', 'keeper_config').and_return({ + 'config_json' => '{"token":"test-token"}', + }) + end + + recipe do + ksm_fetch 'run_default' do + action :run + end + end + + it 'deploys the keeper secret script' do + expect(chef_run).to create_cookbook_file('/opt/keeper_secrets_manager/scripts/ksm.py').with( + source: 'ksm.py', + mode: '0755' + ) + end + + it 'executes the keeper secret script with default path' do + expect(chef_run).to run_execute('keeper_fetch_run_default').with( + command: 'python3 /opt/keeper_secrets_manager/scripts/ksm.py', + timeout: 300, + live_stream: true, + environment: hash_including('KEEPER_CONFIG' => '{"token":"test-token"}') + ) + end + end + + context 'with input_path set' do + before do + stub_data_bag_item('keeper', 'keeper_config').and_return({ + 'config_json' => '{"token":"test-token"}', + }) + end + recipe do + ksm_fetch 'run_with_input' do + input_path '/custom/input.json' + action :run + end + end + + it 'executes the keeper script with the input path' do + expect(chef_run).to run_execute('keeper_fetch_run_with_input').with( + command: 'python3 /opt/keeper_secrets_manager/scripts/ksm.py --input /custom/input.json', + environment: hash_including('KEEPER_CONFIG' => '{"token":"test-token"}') + ) + end + end + + context 'when input_path is specified but file is missing' do + before do + allow(::File).to receive(:exist?).with('/missing/input.json').and_return(false) + stub_data_bag_item('keeper', 'keeper_config').and_return({ + 'config_json' => '{"token":"test-token"}', + }) + end + + recipe do + ksm_fetch 'run_missing_input' do + input_path '/missing/input.json' + action :run + end + end + + it 'raises a file not found error' do + expect { chef_run }.to raise_error(RuntimeError, /Input file not found/) + end + end + + context 'when python3 and python are not found' do + before do + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which python3') + .and_return(double('shell_out', run_command: nil, error!: nil, stdout: '', exitstatus: 1)) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which python') + .and_return(double('shell_out', run_command: nil, error!: nil, stdout: '', exitstatus: 1)) + stub_data_bag_item('keeper', 'keeper_config').and_return({ + 'config_json' => '{"token":"test-token"}', + }) + end + + recipe do + ksm_fetch 'fallback_python' do + action :run + end + end + + it 'falls back to python3 in command' do + expect(chef_run).to run_execute('keeper_fetch_fallback_python').with( + command: 'python3 /opt/keeper_secrets_manager/scripts/ksm.py' + ) + end + end + + context 'with custom timeout' do + before do + stub_data_bag_item('keeper', 'keeper_config').and_return({ + 'config_json' => '{"token":"test-token"}', + }) + end + recipe do + ksm_fetch 'with_timeout' do + timeout 120 + action :run + end + end + + it 'uses custom timeout in execute' do + expect(chef_run).to run_execute('keeper_fetch_with_timeout').with( + timeout: 120 + ) + end + end + + context 'with python' do + before do + stub_data_bag_item('keeper', 'keeper_config').and_return({ + 'config_json' => '{"token":"test-token"}', + }) + end + recipe do + ksm_fetch 'test_script' do + action :run + end + end + + it 'deploys the Python script with correct permissions' do + expect(chef_run).to create_cookbook_file('/opt/keeper_secrets_manager/scripts/ksm.py').with( + source: 'ksm.py', + mode: '0755' + ) + end + end + + context 'when encrypted data bag is missing' do + before do + # This is how ChefSpec expects data_bag_item to be stubbed if you want it to raise + stub_data_bag_item('keeper', 'keeper_config').and_raise(Chef::Exceptions::InvalidDataBagPath) + + # Stub ENV fallback + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('KEEPER_CONFIG').and_return('{"token":"env-token"}') + end + + recipe do + ksm_fetch 'fallback_to_env' do + action :run + end + end + + it 'uses fallback env variable for config' do + expect(chef_run).to run_execute('keeper_fetch_fallback_to_env').with( + environment: hash_including('KEEPER_CONFIG' => '{"token":"env-token"}') + ) + end + end +end + +describe 'ksm_fetch resource on Windows' do + step_into :ksm_fetch + platform 'windows' + + let(:real_python_path) { 'C:\Users\test\AppData\Local\Programs\Python\Python312\python.exe' } + let(:windows_store_stub) { 'C:\Users\test\AppData\Local\Microsoft\WindowsApps\python3.exe' } + let(:python_with_spaces) { 'C:\Program Files\Python313\python.exe' } + + let(:shellout_where_python3_multiple) do + double('shell_out', run_command: nil, error!: nil, + stdout: "#{windows_store_stub}\n#{real_python_path}", exitstatus: 0) + end + + let(:shellout_where_python3_spaces) do + double('shell_out', run_command: nil, error!: nil, + stdout: "#{windows_store_stub}\n#{python_with_spaces}", exitstatus: 0) + end + + let(:shellout_where_python) do + double('shell_out', run_command: nil, error!: nil, + stdout: real_python_path, exitstatus: 0) + end + + let(:shellout_not_found) do + double('shell_out', run_command: nil, error!: nil, stdout: '', exitstatus: 1) + end + + before do + # Generic fallback for any shell_out calls not explicitly stubbed + allow_any_instance_of(Chef::Provider).to receive(:shell_out).and_return(shellout_not_found) + + allow(::File).to receive(:exist?).and_call_original + allow(::File).to receive(:exist?).with(real_python_path).and_return(true) + allow(::File).to receive(:exist?).with(python_with_spaces).and_return(true) + end + + context 'with default Windows configuration' do + before do + stub_data_bag_item('keeper', 'keeper_config').and_return({ + 'config_json' => '{"token":"test-token"}', + }) + + # Stub where commands - filter out Windows Store stub + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where python3').and_return(shellout_where_python3_multiple) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where python').and_return(shellout_where_python) + end + + recipe do + ksm_fetch 'run_default' do + action :run + end + end + + it 'uses Windows default deploy path' do + expect(chef_run).to create_cookbook_file('C:\ProgramData\keeper_secrets_manager\scripts\ksm.py').with( + source: 'ksm.py', + mode: '0755' + ) + end + + it 'filters out Windows Store Python stub' do + expect(chef_run).to run_execute('keeper_fetch_run_default') + execute_resource = chef_run.execute('keeper_fetch_run_default') + # Should use real_python_path, not windows_store_stub + expect(execute_resource.command).to include(real_python_path) + expect(execute_resource.command).not_to include('WindowsApps') + end + + it 'quotes paths in command' do + expect(chef_run).to run_execute('keeper_fetch_run_default') + execute_resource = chef_run.execute('keeper_fetch_run_default') + # Paths should be quoted + expect(execute_resource.command).to match(/^"[^"]*python[^"]*" "[^"]*ksm\.py"/) + end + end + + context 'with Python in path with spaces' do + before do + stub_data_bag_item('keeper', 'keeper_config').and_return({ + 'config_json' => '{"token":"test-token"}', + }) + + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where python3').and_return(shellout_where_python3_spaces) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where python').and_return(shellout_not_found) + end + + recipe do + ksm_fetch 'run_with_spaces' do + action :run + end + end + + it 'quotes Python path with spaces' do + expect(chef_run).to run_execute('keeper_fetch_run_with_spaces') + execute_resource = chef_run.execute('keeper_fetch_run_with_spaces') + # Path with spaces should be quoted + expect(execute_resource.command).to match(/^"C:\\Program Files\\Python/) + end + end + + context 'with input_path on Windows' do + before do + stub_data_bag_item('keeper', 'keeper_config').and_return({ + 'config_json' => '{"token":"test-token"}', + }) + + allow(::File).to receive(:exist?).with('C:\custom\input.json').and_return(true) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where python3').and_return(shellout_where_python3_multiple) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where python').and_return(shellout_where_python) + end + + recipe do + ksm_fetch 'run_with_input' do + input_path 'C:\custom\input.json' + action :run + end + end + + it 'quotes input path in command' do + expect(chef_run).to run_execute('keeper_fetch_run_with_input') + execute_resource = chef_run.execute('keeper_fetch_run_with_input') + # Input path should be quoted + expect(execute_resource.command).to match(/--input "C:\\custom\\input\.json"/) + end + end +end diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/unit/resources/ksm_install_spec.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/unit/resources/ksm_install_spec.rb new file mode 100644 index 00000000..55d43a47 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/spec/unit/resources/ksm_install_spec.rb @@ -0,0 +1,304 @@ +require 'spec_helper' +require 'chefspec' +require 'chefspec/solo_runner' + +describe 'keeper_secrets_manager::install (ksm_install resource)' do + let(:runner) { ChefSpec::SoloRunner.new(platform: 'windows', version: '2019') } + let(:chef_run) do + runner.converge('keeper_secrets_manager::install') + end + + before do + # Provide a safe default for any shell_out calls so tests don't fail on unexpected args + shellout_not_found = double('shell_out', run_command: nil, error!: nil, stdout: '', exitstatus: 1) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).and_return(shellout_not_found) + end + + it 'converges the install recipe and declares the ksm_install resource' do + expect { chef_run }.to_not raise_error + expect(chef_run).to install_ksm_install('keeper_secrets_manager') + end +end + +describe 'keeper_secrets_manager_ksm_install resource' do + step_into :ksm_install + platform 'ubuntu' + + let(:shellout_double_python3) do + double('shell_out', run_command: nil, error!: nil, stdout: '/usr/bin/python3', exitstatus: 0) + end + let(:shellout_double_pip3) do + double('shell_out', run_command: nil, error!: nil, stdout: '/usr/bin/pip3', exitstatus: 0) + end + let(:shellout_double_not_found) do + double('shell_out', run_command: nil, error!: nil, stdout: '', exitstatus: 1) + end + + before do + stub_data_bag_item('keeper', 'keeper_config').and_return({ + 'config_json' => '{"token":"fake-token"}', + }) + + # Generic fallback for any shell_out calls not explicitly stubbed + allow_any_instance_of(Chef::Provider).to receive(:shell_out).and_return(shellout_double_not_found) + + # Specific which/which-like calls on unix + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which python3').and_return(shellout_double_python3) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which python').and_return(shellout_double_not_found) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which pip3').and_return(shellout_double_pip3) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('which pip').and_return(shellout_double_not_found) + + # pip show guards - return not found (so installation executes) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with(match(/pip3 show keeper-secrets-manager-core/)).and_return(shellout_double_not_found) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with(match(/pip3 show keeper-secrets-manager-cli/)).and_return(shellout_double_not_found) + + # stub_command equivalents used by ChefSpec for guards + stub_command('/usr/bin/pip3 show keeper-secrets-manager-core').and_return(false) + stub_command('/usr/bin/pip3 show keeper-secrets-manager-cli').and_return(false) + end + + context 'with default configuration' do + recipe do + ksm_install 'keeper_secrets_manager' do + action :install + end + end + + it 'creates all necessary directories' do + expect(chef_run).to create_directory('/opt/keeper_secrets_manager') + expect(chef_run).to create_directory('/opt/keeper_secrets_manager/config') + expect(chef_run).to create_directory('/opt/keeper_secrets_manager/scripts') + end + + it 'creates the keeper config file from encrypted data bag' do + expect(chef_run).to create_file('/opt/keeper_secrets_manager/config/keeper_config.json').with( + content: '{"token":"fake-token"}', + mode: '0600', + sensitive: true + ) + end + + it 'installs the helper script' do + expect(chef_run).to create_cookbook_file('/opt/keeper_secrets_manager/scripts/ksm.py') + end + + it 'runs the ksm_install resource' do + expect(chef_run).to install_ksm_install('keeper_secrets_manager') + end + + it 'installs the Python SDK by default' do + expect(chef_run).to run_execute('install_keeper_sdk') + end + end + + context 'when python_sdk is disabled' do + recipe do + ksm_install 'test' do + python_sdk false + end + end + + it 'does not install python packages' do + expect(chef_run).not_to run_execute('install_keeper_sdk') + end + end + + context 'with user install enabled' do + recipe do + ksm_install 'test' do + user_install true + end + end + + it 'declares the resource' do + expect(chef_run).to install_ksm_install('test') + end + end + + context 'with custom base directory' do + recipe do + ksm_install 'test' do + base_dir '/custom/path' + end + end + + it 'creates directories in custom location' do + expect(chef_run).to create_directory('/custom/path') + expect(chef_run).to create_directory('/custom/path/config') + expect(chef_run).to create_directory('/custom/path/scripts') + end + end + + context 'with cli_tool enabled' do + recipe do + ksm_install 'test' do + cli_tool true + end + end + + it 'installs the CLI tool' do + expect(chef_run).to run_execute('install_keeper_cli') + end + end + + context 'with install_script disabled' do + recipe do + ksm_install 'test' do + install_script false + end + end + + it 'does not create directories' do + expect(chef_run).not_to create_directory('/opt/keeper_secrets_manager') + end + end +end + +describe 'keeper_secrets_manager_ksm_install resource on Windows' do + step_into :ksm_install + platform 'windows' + + let(:real_python_path) { 'C:\Users\test\AppData\Local\Programs\Python\Python312\python.exe' } + let(:real_pip_path) { 'C:\Users\test\AppData\Local\Programs\Python\Python312\Scripts\pip3.exe' } + let(:windows_store_stub) { 'C:\Users\test\AppData\Local\Microsoft\WindowsApps\python3.exe' } + let(:python_with_spaces) { 'C:\Program Files\Python313\python.exe' } + let(:pip_with_spaces) { 'C:\Program Files\Python313\Scripts\pip3.exe' } + + let(:shellout_where_python3_multiple) do + double('shell_out', run_command: nil, error!: nil, + stdout: "#{windows_store_stub}\n#{real_python_path}", exitstatus: 0) + end + + let(:shellout_where_python3_real) do + double('shell_out', run_command: nil, error!: nil, + stdout: real_python_path, exitstatus: 0) + end + + let(:shellout_where_pip3_real) do + double('shell_out', run_command: nil, error!: nil, + stdout: real_pip_path, exitstatus: 0) + end + + let(:shellout_not_found) do + double('shell_out', run_command: nil, error!: nil, stdout: '', exitstatus: 1) + end + + before do + stub_data_bag_item('keeper', 'keeper_config').and_return({ + 'config_json' => '{"token":"fake-token"}', + }) + allow(::File).to receive(:exist?).and_call_original + allow(::File).to receive(:exist?).with(real_python_path).and_return(true) + allow(::File).to receive(:exist?).with(real_pip_path).and_return(true) + allow(::File).to receive(:exist?).with(python_with_spaces).and_return(true) + allow(::File).to receive(:exist?).with(pip_with_spaces).and_return(true) + + # Generic fallback for shell_out + allow_any_instance_of(Chef::Provider).to receive(:shell_out).and_return(shellout_not_found) + end + + context 'with default Windows configuration' do + before do + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where python3').and_return(shellout_where_python3_multiple) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where python').and_return(shellout_where_python3_real) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where pip3').and_return(shellout_where_pip3_real) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where pip').and_return(shellout_not_found) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where choco').and_return(double('so', run_command: nil, error!: nil, stdout: '', exitstatus: 1)) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with(match(/--version/)).and_return(double('so', run_command: nil, error!: nil, stdout: 'Python 3.x', exitstatus: 0)) + + # stub guards for pip show (both direct pip path and python -m pip fallback patterns) + stub_command("\"#{real_pip_path}\" show keeper-secrets-manager-core").and_return(false) + stub_command("\"#{real_pip_path}\" show keeper-secrets-manager-cli").and_return(false) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with("\"#{real_pip_path}\" show keeper-secrets-manager-core").and_return(shellout_not_found) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with("\"#{real_pip_path}\" show keeper-secrets-manager-cli").and_return(shellout_not_found) + end + + recipe do + ksm_install 'keeper_secrets_manager' do + action :install + end + end + + it 'creates Windows default directories' do + expect(chef_run).to create_directory('C:\ProgramData\keeper_secrets_manager') + expect(chef_run).to create_directory('C:\ProgramData\keeper_secrets_manager/config') + expect(chef_run).to create_directory('C:\ProgramData\keeper_secrets_manager/scripts') + end + + it 'filters out Windows Store Python stub' do + expect(chef_run).to install_ksm_install('keeper_secrets_manager') + end + + it 'uses python -m pip for pip upgrade on Windows' do + expect(chef_run).to run_execute('upgrade_pip').with( + command: match(/^"[^"]*python[^"]*" -m pip install --upgrade pip/) + ) + end + + it 'quotes paths in pip commands' do + expect(chef_run).to run_execute('install_keeper_sdk') + execute_resource = chef_run.execute('install_keeper_sdk') + expect(execute_resource.command).to match(/^"[^"]*pip[^"]*"/) + end + end + + context 'with Python in path with spaces' do + before do + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where python3').and_return(double('so', run_command: nil, error!: nil, stdout: "#{windows_store_stub}\n#{python_with_spaces}", exitstatus: 0)) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where python').and_return(shellout_not_found) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where pip3').and_return(shellout_not_found) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where pip').and_return(shellout_not_found) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where choco').and_return(double('so', run_command: nil, error!: nil, stdout: '', exitstatus: 1)) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with(match(/--version/)).and_return(double('so', run_command: nil, error!: nil, stdout: 'Python 3.x', exitstatus: 0)) + + # stub python -m pip guards + stub_command("\"#{python_with_spaces}\" -m pip show keeper-secrets-manager-core").and_return(false) + stub_command("\"#{python_with_spaces}\" -m pip show keeper-secrets-manager-cli").and_return(false) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with(match(/python.*show keeper-secrets-manager/)).and_return(shellout_not_found) + end + + recipe do + ksm_install 'test' do + action :install + end + end + + it 'quotes Python path with spaces in commands' do + expect(chef_run).to run_execute('upgrade_pip') + execute_resource = chef_run.execute('upgrade_pip') + expect(execute_resource.command).to match(/^"C:\\Program Files\\Python/) + end + + it 'falls back to python -m pip when pip not found' do + expect(chef_run).to run_execute('install_keeper_sdk') + execute_resource = chef_run.execute('install_keeper_sdk') + expect(execute_resource.command).to include('-m pip') + expect(execute_resource.command).to match(/python[^"]*" -m pip install/) + end + end + + context 'when pip not in PATH but Python is available' do + before do + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where python3').and_return(double('so', run_command: nil, error!: nil, stdout: real_python_path, exitstatus: 0)) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where pip3').and_return(shellout_not_found) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with('where choco').and_return(double('so', run_command: nil, error!: nil, stdout: '', exitstatus: 1)) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with(match(/--version/)).and_return(double('so', run_command: nil, error!: nil, stdout: 'Python 3.x', exitstatus: 0)) + + stub_command("\"#{real_python_path}\" -m pip show keeper-secrets-manager-core").and_return(false) + stub_command("\"#{real_python_path}\" -m pip show keeper-secrets-manager-cli").and_return(false) + allow_any_instance_of(Chef::Provider).to receive(:shell_out).with(match(/python.*show keeper-secrets-manager/)).and_return(shellout_not_found) + end + + recipe do + ksm_install 'test' do + action :install + end + end + + it 'uses python -m pip as fallback' do + expect(chef_run).to run_execute('install_keeper_sdk') + execute_resource = chef_run.execute('install_keeper_sdk') + expect(execute_resource.command).to match(/python[^"]*" -m pip install/) + end + end +end diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/fixtures/python/invalid_input.json b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/fixtures/python/invalid_input.json new file mode 100644 index 00000000..f3b6c229 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/fixtures/python/invalid_input.json @@ -0,0 +1,4 @@ +{ + "authentication": ["invalid_method"], + "secrets": "not_an_array" +} \ No newline at end of file diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/fixtures/python/valid_input.json b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/fixtures/python/valid_input.json new file mode 100644 index 00000000..467a02b3 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/fixtures/python/valid_input.json @@ -0,0 +1,10 @@ +{ + "authentication": [ + "base64" + ], + "secrets": [ + "jnPuLYWXt7b6Ym-_9OCvFA/field/password > APP_PASSWORD", + "jnPuLYWXt7b6Ym-_9OCvFA/field/login > LOGIN", + "jnPuLYWXt7b6Ym-_9OCvFA/file/dummy.crt > file:/tmp/Certificate.crt" + ] +} \ No newline at end of file diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/integration/default/install_test.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/integration/default/install_test.rb new file mode 100644 index 00000000..d5fa368a --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/integration/default/install_test.rb @@ -0,0 +1,39 @@ +# Chef InSpec test for recipe keeper_secrets_manager::install + +# The Chef InSpec reference, with examples and extensive documentation, can be +# found at https://docs.chef.io/inspec/resources/ + +# test/integration/default/install_test.rb +# test/integration/default/install_test.rb +describe 'Keeper Secrets Manager Installation' do + describe directory('/opt/keeper_secrets_manager') do + it { should exist } + it { should be_directory } + end + + describe directory('/opt/keeper_secrets_manager/config') do + it { should exist } + it { should be_directory } + end + + describe directory('/opt/keeper_secrets_manager/scripts') do + it { should exist } + it { should be_directory } + end + + describe file('/opt/keeper_secrets_manager/scripts/ksm.py') do + it { should exist } + it { should be_file } + its('mode') { should cmp '0755' } + end + + describe command('python3 --version') do + its('exit_status') { should eq 0 } + end + + # Test that pip packages are installed + describe command('pip3 show keeper-secrets-manager-core') do + its('exit_status') { should eq 0 } + its('stdout') { should match(/keeper-secrets-manager-core/) } + end +end diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/integration/default/python_script_test.rb b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/integration/default/python_script_test.rb new file mode 100644 index 00000000..19e1da06 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/integration/default/python_script_test.rb @@ -0,0 +1,37 @@ +# test/integration/default/python_script_test.rb +describe 'Python script functionality' do + describe file('/opt/keeper_secrets_manager/scripts/ksm.py') do + it { should exist } + it { should be_executable } + its('mode') { should cmp '0755' } + end + + describe command('python3 /opt/keeper_secrets_manager/scripts/ksm.py --help') do + its('exit_status') { should eq 0 } + its('stdout') { should match(/Keeper Secrets CLI/) } + end + + # Test with mock input file + describe 'Python script with test input' do + let(:test_input) { '/tmp/test_input.json' } + + before do + # Create test input file + test_config = { + 'authentication' => %w(token test-token), + 'secrets' => ['test/secret/path'], + 'folders' => {}, + } + + file test_input do + content test_config.to_json + mode '0644' + end + end + + # NOTE: This would need actual Keeper credentials to work + # describe command("python3 /opt/keeper_secrets_manager/scripts/ksm.py --input #{test_input}") do + # its('exit_status') { should eq 0 } + # end + end +end diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/requirements.txt b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/requirements.txt new file mode 100644 index 00000000..0fe7caa3 --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/requirements.txt @@ -0,0 +1,3 @@ +pytest>=7.0.0 +pytest-mock>=3.0.0 +keeper-secrets-manager-core>=16.0.0 \ No newline at end of file diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/unit/python/test_ksm.py b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/unit/python/test_ksm.py new file mode 100644 index 00000000..ee664f7c --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test/unit/python/test_ksm.py @@ -0,0 +1,140 @@ +# test/unit/python/test_ksm.py +import unittest +import sys +import os +import json +import tempfile +from unittest.mock import patch, MagicMock, mock_open + +# Add the files directory to Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../files/default')) + +from ksm import ( + get_env_value, + parse_secret_notation, + validate_auth_config, + get_configurations, + Constants +) + +class TestKSM(unittest.TestCase): + + def setUp(self): + """Set up test fixtures""" + self.test_config = { + "authentication": ["token", "test-token-123"], + "secrets": [ + "EG6KdJaaLG7esRZbMnfbFA/custom_field/API_KEY", + "EG6KdJaaLG7esRZbMnfbFA/custom_field/PASSWORD > APP_PASSWORD", + "EG6KdJaaLG7esRZbMnfbFA/file/cert.pem > file:/tmp/cert.pem" + ], + "folders": { + "list_all": True + } + } + + def test_get_env_value(self): + """Test environment variable retrieval""" + with patch.dict(os.environ, {'TEST_VAR': 'test_value'}): + result = get_env_value('TEST_VAR') + self.assertEqual(result, 'test_value') + + # Test non-existent variable + result = get_env_value('NON_EXISTENT_VAR') + self.assertIsNone(result) + + def test_parse_secret_notation_simple(self): + """Test parsing simple secret notation""" + notation = "EG6KdJaaLG7esRZbMnfbFA/custom_field/API_KEY" + keeper_notation, output_name, action_type = parse_secret_notation(notation) + + self.assertEqual(keeper_notation, "EG6KdJaaLG7esRZbMnfbFA/custom_field/API_KEY") + self.assertEqual(output_name, "API_KEY") + self.assertIsNone(action_type) + + def test_parse_secret_notation_with_output(self): + """Test parsing secret notation with output specification""" + notation = "EG6KdJaaLG7esRZbMnfbFA/custom_field/PASSWORD > APP_PASSWORD" + keeper_notation, output_name, action_type = parse_secret_notation(notation) + + self.assertEqual(keeper_notation, "EG6KdJaaLG7esRZbMnfbFA/custom_field/PASSWORD") + self.assertEqual(output_name, "APP_PASSWORD") + self.assertIsNone(action_type) + + def test_parse_secret_notation_env(self): + """Test parsing secret notation with env action""" + notation = "EG6KdJaaLG7esRZbMnfbFA/custom_field/TOKEN > env:TOKEN" + keeper_notation, output_name, action_type = parse_secret_notation(notation) + + self.assertEqual(keeper_notation, "EG6KdJaaLG7esRZbMnfbFA/custom_field/TOKEN") + self.assertEqual(output_name, "TOKEN") + self.assertEqual(action_type, "env") + + def test_parse_secret_notation_file(self): + """Test parsing secret notation with file action""" + notation = "EG6KdJaaLG7esRZbMnfbFA/file/cert.pem > file:/tmp/cert.pem" + keeper_notation, output_name, action_type = parse_secret_notation(notation) + + self.assertEqual(keeper_notation, "EG6KdJaaLG7esRZbMnfbFA/file/cert.pem") + self.assertEqual(output_name, "/tmp/cert.pem") + self.assertEqual(action_type, "file") + + def test_validate_auth_config_token(self): + """Test auth config validation with token""" + # Mock environment to ensure KEEPER_CONFIG is not set + with patch.dict(os.environ, {}, clear=True): + auth_config = ["token", "test-token-123"] + method, value = validate_auth_config(auth_config) + + self.assertEqual(method, "token") + self.assertEqual(value, "test-token-123") + + def test_validate_auth_config_env_fallback(self): + """Test auth config validation with environment fallback""" + with patch.dict(os.environ, {'KEEPER_CONFIG': 'env-token-456'}): + auth_config = ["token"] # No value provided + method, value = validate_auth_config(auth_config) + + self.assertEqual(method, "token") + self.assertEqual(value, "env-token-456") + + def test_validate_auth_config_invalid_method(self): + """Test auth config validation with invalid method""" + auth_config = ["invalid_method", "some-value"] + + with self.assertRaises(ValueError) as context: + validate_auth_config(auth_config) + + self.assertIn("Unsupported authentication method", str(context.exception)) + + def test_get_configurations_valid_file(self): + """Test reading valid configuration file""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(self.test_config, f) + temp_path = f.name + + try: + config = get_configurations(temp_path) + self.assertEqual(config, self.test_config) + finally: + os.unlink(temp_path) + + def test_get_configurations_invalid_json(self): + """Test reading invalid JSON file""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + f.write("invalid json content") + temp_path = f.name + + try: + with self.assertRaises(Exception): + get_configurations(temp_path) + finally: + os.unlink(temp_path) + + def test_get_configurations_missing_file(self): + """Test reading non-existent file""" + with self.assertRaises(Exception): + get_configurations("/non/existent/file.json") + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test_python.sh b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test_python.sh new file mode 100755 index 00000000..dd31f78c --- /dev/null +++ b/integration/keeper_secrets_manager_chef/cookbooks/keeper_secrets_manager/test_python.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -e + +echo "Running Python unit tests..." + +# Check if Python tests exist +if [ ! -d "test/unit/python" ]; then + echo "No Python unit tests found at test/unit/python/" + echo "Creating basic Python syntax check instead..." + + # Basic Python syntax check + if [ -f "files/default/ksm.py" ]; then + echo "Checking Python script syntax..." + python3 -m py_compile files/default/ksm.py + echo "Python script syntax is valid" + else + echo "Python script not found" + exit 1 + fi + + echo "Python checks completed!" + exit 0 +fi + +# If Python tests exist, run them +echo "Found Python unit tests, running them..." + +# Create virtual environment for testing +python3 -m venv test_env +source test_env/bin/activate + +# Install required packages +pip install keeper-secrets-manager-core pytest + +# Run Python unit tests +python -m pytest test/unit/python/ -v + +# Clean up +deactivate +rm -rf test_env + +echo "Python tests completed!"