From 19d87f527a77bb405d77725866161544ce9a13f2 Mon Sep 17 00:00:00 2001 From: Hitesh Borase Date: Wed, 7 Jan 2026 10:20:44 +0530 Subject: [PATCH 01/11] initial commit --- .gitignore | 1 + Dockerfile | 23 +++ README.md | 481 +++++++++++++++++++++++++++++++++++++++++++++- entrypoint.sh | 92 +++++++++ package-lock.json | 22 +++ package.json | 17 ++ src/index.js | 147 ++++++++++++++ 7 files changed, 782 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100755 entrypoint.sh create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/index.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40b878d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a73b9a5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# 1. Use a slim Node.js image to keep the plugin fast and small +# Note: As of January, 2026, when building this image, it uses Node.js version 25.2.1 +# The 'node:slim' tag will automatically use the latest Node.js version every time the image is built +FROM node:slim + +# 2. Set the working directory inside the container +WORKDIR /app + +# 3. Copy package files first (to leverage Docker caching for faster builds) +COPY package*.json ./ + +# 4. Install production dependencies only +RUN npm install --omit=dev + +# 5. Copy the rest of your source code +COPY . . + +# 6. Ensure the entrypoint script is executable (important for Linux runners) +RUN chmod +x /app/entrypoint.sh + +# 7. Set the Entrypoint +# This tells Docker to run the shell script first, which then calls your Node logic +ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md index e3e06ff..0c7d841 100644 --- a/README.md +++ b/README.md @@ -1 +1,480 @@ -# harness-integration +# Harness Keeper Security Plugin + +A Harness CI/CD plugin that integrates with Keeper Security Secrets Manager (KSM) to securely fetch and inject secrets into your CI/CD pipelines at runtime. + +## Overview + +This plugin securely retrieves secrets from Keeper Security Secrets Manager and makes them available to subsequent steps in your Harness pipeline. The plugin implements a zero-knowledge architecture where secrets are retrieved directly from Keeper Vault at runtime and never pass through Harness systems in decrypted form. + +**Key Features:** +- **Zero-Knowledge Architecture**: Secrets are retrieved directly from Keeper Vault at runtime +- **Multiple Authentication Methods**: Supports one-time access tokens (`US:...`) and full JSON config +- **Keeper Notation Support**: Query fields (`field`), custom fields (`custom_field`), and file attachments (`file`) +- **Multiple Output Formats**: Environment variables, file output, and output variables +- **Secure Storage**: Secrets written to `/harness/secrets/` with secure file permissions +- **Automatic Validation**: Validates token format and required config fields +- **Base64 Decoding**: Automatically decodes base64-encoded tokens + +## Architecture + +### Zero-Knowledge Design + +The plugin ensures zero-knowledge security through the following design: + +1. **Harness CI/CD** calls the custom Docker-based plugin +2. **Plugin authenticates** using KSM configuration stored in Harness Secrets Manager +3. **Secrets are retrieved** directly from Keeper Vault at runtime +4. **Decrypted secrets** are returned as step outputs for downstream stages +5. **No external storage**: Secrets never pass through Harness systems in decrypted form + +All secret flow happens strictly between the plugin container and Keeper, while Harness provides orchestration, governance, and secure secret injection across the pipeline. + +### Components + +1. **`src/index.js`**: Main plugin logic + - Token processing and validation + - Keeper Secrets Manager integration + - Secret fetching using Keeper Notation + - Secret distribution + +2. **`entrypoint.sh`**: Container entrypoint script + - Executes the Node.js plugin + - Captures and processes plugin output + - Writes secrets to Harness directories + - Manages security and cleanup + +3. **`Example/Pipeline.yaml`**: Example Harness CI pipeline + - Demonstrates plugin configuration + - Shows secret consumption patterns + +### Execution Flow + +``` +1. Container starts → entrypoint.sh executes +2. entrypoint.sh → Runs: node /app/src/index.js +3. Plugin processes: + - Reads KSM_CONFIG from environment + - Validates and decodes token (base64 if needed) + - Initializes Keeper storage + - Fetches secrets from Keeper using Keeper Notation + - Outputs secrets to stdout (with ENV:/OUT: prefixes) +4. entrypoint.sh captures stdout: + - Parses ENV:/OUT: prefixes + - Writes to /harness/outputs/outputs.txt (output variables) + - Writes to /harness/secrets/ (file-based access) + - Sets secure permissions (chmod 600) +5. Subsequent steps access secrets via: + - Files: /harness/secrets/VARIABLE_NAME (recommended) +``` + +## Prerequisites + +- **Keeper Secrets Manager access** ([Quick Start Guide](https://docs.keeper.io/secrets-manager/secrets-manager/quick-start-guide)) +- **Secrets Manager addon enabled** for your Keeper account +- **Membership in a Role** with the Secrets Manager enforcement policy enabled +- **A Keeper Secrets Manager Application** with secrets shared to it +- **An initialized Keeper Secrets Manager Configuration** (JSON or one-time token) +- **Record UID(s)** for the secrets you want to access ⚠️ **REQUIRED** +- **Harness CI/CD account** with a project set up +- **Docker** (for building the plugin image) +- **Node.js 18+** (for local development) + +## Installation + +### Build and Push Docker Image + +Build the plugin Docker image: + +```bash +docker build -t your-dockerhub-username/harness-keeper-plugin:latest . +``` + +Push to your container registry: + +```bash +docker push your-dockerhub-username/harness-keeper-plugin:latest +``` + +**Note:** Replace `your-dockerhub-username` with your actual Docker Hub username or container registry path. + +## Configuration + +### 1. Create Keeper Access Token + +#### Option A: One-Time Access Token +1. Log in to Keeper Security +2. Navigate to Secrets Manager +3. Generate a one-time access token (format: `US:xxxxx`) +4. Base64 encode (optional but recommended): + ```bash + echo -n "US:your-token-here" | base64 + ``` + +#### Option B: Full JSON Config +Create a JSON config with: +```json +{ + "hostname": "keepersecurity.com", + "clientId": "your-client-id", + "privateKey": "your-private-key" +} +``` +Base64 encode: +```bash +echo -n '{"hostname":"...","clientId":"...","privateKey":"..."}' | base64 +``` + +### 2. Create Harness Secret + +In Harness, create a secret: +- **Type**: TEXT or FILE (both work) +- **Name**: `KSM_CONFIG` (or your preferred name) +- **Value**: Your base64-encoded token or JSON config + +### 3. Configure Pipeline + +**Before you begin:** You must have the Record UID(s) for the secrets you want to access. See "How to Get Record UID" section below. + +```yaml +pipeline: + name: harness_keeper_plugin + identifier: harness_keeper_plugin + projectIdentifier: default_project + orgIdentifier: default + stages: + - stage: + name: HkpCI + identifier: HkpCI + type: CI + spec: + cloneCodebase: false + platform: + os: Linux + arch: Amd64 + runtime: + type: Cloud + spec: {} + execution: + steps: + - step: + type: Plugin + name: Fetch_Keeper_Secrets + identifier: Fetch_Keeper_Secrets + spec: + image: your-dockerhub-username/harness-keeper-plugin:latest + settings: + secrets: | + JQPso5SMBpP29gKKGp6Enw/field/password > env:DB_PASSWORD + JQPso5SMBpP29gKKGp6Enw/field/login > env:DB_USERNAME + JQPso5SMBpP29gKKGp6Enw/custom_field/API_Key > env:API_KEY + JQPso5SMBpP29gKKGp6Enw/file/api.key > file:/app/config/api.key + envVariables: + KSM_CONFIG: <+secrets.getValue("KSM_CONFIG")> + - step: + type: Run + name: Use_Keeper_Secrets + identifier: Use_Keeper_Secrets + spec: + image: alpine:3.20 + shell: Sh + command: | + DB_USERNAME=$(cat /harness/secrets/DB_USERNAME) + DB_PASSWORD=$(cat /harness/secrets/DB_PASSWORD) + API_KEY=$(cat /harness/secrets/API_KEY) + echo "Secrets retrieved successfully" + # Use secrets in your build/deploy process +``` + +**Note:** `envVariables` is used ONLY to pass the Keeper configuration token to the plugin. The actual secrets retrieved from Keeper are stored in Harness pipeline volume storage at `/harness/secrets/`. + +## Usage + +### Secret Mapping Format + +``` + > +``` + +### Keeper Notation Format + +The plugin supports three types of Keeper Notation queries: + +1. **Standard Fields**: `/field/` + - Examples: `password`, `login`, `url`, `text`, `host`, etc. + +2. **Custom Fields**: `/custom_field/` + - Examples: `API_Key`, `My Custom Field`, `Environment`, etc. + +3. **File Attachments**: `/file/` + - Examples: `api.key`, `certificate.pem`, `config.json`, etc. + +**Important:** The first part (e.g., `JQPso5SMBpP29gKKGp6Enw`) is the **Record UID**, the unique identifier for a specific secret record in Keeper. + +### Destination Types + +| Destination Prefix | Description | Output Location | +|-------------------|-------------|----------------| +| `env:VARIABLE_NAME` | Environment variable | `/harness/secrets/VARIABLE_NAME` | +| `file:/path/to/file` | File output | Specified file path | +| `VARIABLE_NAME` (default) | Output variable | `/harness/secrets/VARIABLE_NAME` | + +### Examples + +#### 1. Standard Field → Environment Variable +```yaml +secrets: | + JQPso5SMBpP29gKKGp6Enw/field/password > env:DB_PASSWORD + JQPso5SMBpP29gKKGp6Enw/field/login > env:DB_USERNAME +``` +- Creates files: `/harness/secrets/DB_PASSWORD`, `/harness/secrets/DB_USERNAME` +- Access: `DB_PASSWORD=$(cat /harness/secrets/DB_PASSWORD)` + +#### 2. Custom Field → Output Variable +```yaml +secrets: | + JQPso5SMBpP29gKKGp6Enw/custom_field/API_Key > API_KEY + JQPso5SMBpP29gKKGp6Enw/custom_field/Environment > ENV_NAME +``` +- Creates files: `/harness/secrets/API_KEY`, `/harness/secrets/ENV_NAME` +- Access: `API_KEY=$(cat /harness/secrets/API_KEY)` + +#### 3. File Attachment → File Path +```yaml +secrets: | + JQPso5SMBpP29gKKGp6Enw/file/api.key > file:/app/config/api.key + JQPso5SMBpP29gKKGp6Enw/file/certificate.pem > file:/app/ssl/cert.pem +``` +- Downloads files and writes to specified paths +- Access: `cat /app/config/api.key` + +#### 4. Mixed Usage +```yaml +secrets: | + JQPso5SMBpP29gKKGp6Enw/field/password > env:DB_PASSWORD + JQPso5SMBpP29gKKGp6Enw/custom_field/API_Key > API_KEY + JQPso5SMBpP29gKKGp6Enw/file/config.json > file:/app/config.json +``` + +### How to Get Record UID + +The Record UID is **mandatory** and identifies which specific secret record to retrieve. Obtain it using one of these methods: + +**Method 1: From Keeper Web Vault** +1. Log in to your Keeper Vault +2. Open the secret record you want to access +3. Click on the record details/info icon +4. The Record UID is displayed in the record information (usually at the bottom) +5. Copy the Record UID (format: `JQPso5SMBpP29gKKGp6Enw`) + +**Method 2: Using Keeper Commander CLI** +```bash +keeper list +# This will show all records with their UIDs +``` + +**Method 3: Using Keeper Secrets Manager SDK/API** +- Use the Keeper Secrets Manager SDK to list records and retrieve their UIDs +- The Record UID is returned when querying records + +**Method 4: From Keeper Admin Console** +1. Navigate to Secrets Manager in Admin Console +2. View the Application and its shared records +3. Record UIDs are visible in the record list + +**Note:** The one-time access token (`US:...`) or JSON config is used to **authenticate** and access Keeper, but you still need the **Record UID** to specify which secret record to retrieve. + +## Accessing Secrets in Subsequent Steps + +### Method 1: File-Based Access (Recommended) + +The most reliable method for accessing secrets in subsequent steps: + +```yaml +- step: + type: Run + name: Use_Secrets + spec: + image: alpine:3.20 + shell: Sh + command: | + DB_PASSWORD=$(cat /harness/secrets/DB_PASSWORD) + DB_USERNAME=$(cat /harness/secrets/DB_USERNAME) + API_KEY=$(cat /harness/secrets/API_KEY) + # Use secrets in your process + echo "Secrets loaded successfully" + # Clean up after use (optional) + rm -f /harness/secrets/DB_PASSWORD /harness/secrets/DB_USERNAME +``` + +**Benefits:** +- Most reliable method +- No truncation issues +- Works with all secret types +- Supports long values and special characters + +### Method 2: Direct File Access (for file: prefix) + +When using `file:` prefix, files are written directly to the specified path: + +```yaml +- step: + type: Run + name: Use_File + spec: + command: | + # File already at /app/config/api.key + cat /app/config/api.key +``` + +## Secret Storage Mechanism + +**Important:** This plugin uses **Harness pipeline volume storage**, not Harness environment variables. + +- **Storage Location:** `/harness/secrets/` directory (Harness shared workspace volume) +- **Storage Type:** File-based storage on pipeline-scoped volume +- **Access Method:** Read files directly using `cat` or file operations +- **Scope:** Pipeline execution only - automatically cleaned up after completion +- **Permissions:** Files have `600` permissions (owner read/write only) + +The `envVariables` section in the plugin step is **only** used to pass the `KSM_CONFIG` (Keeper configuration token) to the plugin. The actual secrets retrieved from Keeper are written to files in `/harness/secrets/` directory. + +## Security + +### Zero-Knowledge Architecture + +- **Direct Retrieval**: Secrets are retrieved directly from Keeper Vault at runtime +- **No Decrypted Storage**: Secrets never pass through Harness systems in decrypted form +- **Pipeline-Scoped**: All secrets are scoped to the current pipeline execution +- **Automatic Cleanup**: Harness CI automatically cleans up `/harness/outputs/` and `/harness/secrets/` after pipeline completion +- **File Permissions**: Secrets written to `/harness/secrets/` have `600` permissions (owner read/write only) +- **No Persistence**: Secrets are not accessible outside the pipeline execution context + +### Best Practices + +1. **Use one-time access tokens** instead of permanent credentials when possible +2. **Base64 encode tokens** before storing in Harness secrets +3. **Clean up secret files** after use in pipeline steps +4. **Use appropriate secret scope** (project/org/account) +5. **Verify Record UIDs** before configuring pipelines + +## Troubleshooting + +### Token Not Found + +**Error:** `KSM config is required` + +**Solutions:** +- Verify the secret exists in Harness +- Check the secret scope matches your project +- Ensure the expression `<+secrets.getValue("KSM_CONFIG")>` is correct +- Verify secret name matches exactly + +### Invalid Token Format + +**Error:** `Invalid token format - expected US:xxxxx or JSON config` + +**Solutions:** +- Verify the token starts with `US:` (for one-time tokens) +- Check if the token is base64 encoded (it will be auto-decoded) +- For JSON config, ensure it starts with `{` and has required fields: `hostname`, `clientId`, `privateKey` + +### Token Already Used / Expired + +**Error:** `Failed: token ... invalid/expired` + +**Solutions:** +1. Generate a new token in Keeper Security +2. Base64 encode: `echo -n "US:new-token" | base64` +3. Update the Harness secret with the new token +4. Run the pipeline again + +**Note:** One-time access tokens are single-use - generate a new token for each use or use JSON config for reusable credentials. + +### Secret File Not Found + +**Error:** `ERROR: Secret files not found` or `Value not found for notation: /field/` + +**Solutions:** +- **Verify Record UID is correct**: The Record UID must match exactly (case-sensitive) +- **Check Record UID exists**: Ensure the record exists in Keeper and is shared to your Secrets Manager Application +- **Verify access permissions**: The one-time token or JSON config must have access to the record +- **Check field name**: Ensure the field type (`password`, `login`, `text`, etc.) or custom field label exists in the record +- **Verify Application sharing**: The record must be shared to the Secrets Manager Application you're using +- **Check logs**: `ls -la /harness/secrets/` to see what files were created +- **Verify notation format**: Ensure the notation follows the correct format (e.g., `/field/password`, `/custom_field/MyLabel`, `/file/filename`) + +### Expression Not Resolved + +**Error:** Token preview shows `${...}` or `<+...>` unresolved + +**Solutions:** +- Use `envVariables` (not `settings`) for Harness expressions when passing `KSM_CONFIG` to the plugin +- Verify secret name matches exactly +- Check secret scope matches the reference +- Ensure you're using the correct Harness expression syntax: `<+secrets.getValue("SECRET_NAME")>` + +### Harness Expression Not Resolved + +**Error:** `Harness expression not resolved` + +**Solutions:** +- Ensure `KSM_CONFIG` is passed via `envVariables`, not `settings` +- Verify the secret exists and is accessible in the current scope +- Check that the Harness expression syntax is correct: `<+secrets.getValue("SECRET_NAME")>` + +## Local Development + +### Test Locally + +```bash +# Set environment variables +export PLUGIN_SECRETS="JQPso5SMBpP29gKKGp6Enw/field/password > env:DB_PASSWORD" +export KSM_CONFIG="VVM6..." # Your base64 encoded token or JSON config + +# Run the plugin +node ./src/index.js +``` + +### Project Structure + +``` +harness_keeper_plugin/ +├── src/ +│ └── index.js # Main plugin logic +├── entrypoint.sh # Container entrypoint script +├── Dockerfile # Docker image definition +├── package.json # Node.js dependencies +├── Example/ +│ └── Pipeline.yaml # Example Harness CI pipeline +└── README.md # This file +``` + +## Example Pipeline + +See `Example/Pipeline.yaml` for a complete working example that demonstrates: +- Plugin configuration +- Secret mapping with multiple record UIDs +- File-based secret consumption +- Error handling and failure strategies + +## Support + +For issues or questions: +- Check the [Keeper Security Documentation](https://docs.keeper.io/secrets-manager/) +- Review [Keeper Notation Documentation](https://docs.keeper.io/secrets-manager/secrets-manager/keeper-notation/) +- Review Harness CI documentation for plugin configuration +- Verify token format and Keeper record permissions + +## License + +ISC + +## Version History + +- **1.0.0** - Initial release + - Zero-knowledge architecture implementation + - Support for one-time tokens and JSON config + - Keeper Notation support (field, custom_field, file) + - File-based secret access + - Base64 token decoding + - Secure file permissions and cleanup diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..c59e5db --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# 1. Create a temporary file to store the secrets +# Using mktemp ensures the file name is unique and not guessable +SECRETS_FILE=$(mktemp) + +# 2. Run the Node.js plugin +# Redirect STDOUT (secrets) to our file, and let STDERR (logs) flow to the console +node /app/src/index.js > "$SECRETS_FILE" + +# 3. Securely process the secrets +# Parse lines in format: ENV:VAR_NAME='value', OUT:VAR_NAME='value', or VAR_NAME='value' +# +# SECURITY NOTE: All secrets are written to /harness/outputs/ and /harness/secrets/ +# These directories are mounted volumes scoped ONLY to the current pipeline execution. +# Harness CI automatically cleans up these volumes after pipeline completion. +# Secrets are NOT accessible outside the pipeline execution context. +mkdir -p /harness/outputs /harness/secrets + +while IFS= read -r line; do + # Skip empty lines + if [ -z "$line" ]; then + continue + fi + + # Determine type: ENV:, OUT:, or default + if [[ "$line" =~ ^ENV: ]]; then + type="env" + line="${line#ENV:}" + elif [[ "$line" =~ ^OUT: ]]; then + type="out" + line="${line#OUT:}" + else + type="out" # Default to output variable + fi + + # Parse the line: split on first '=' to get name and value + name="${line%%=*}" + value="${line#*=}" + + # Remove surrounding single quotes from value if present + if [[ "$value" =~ ^\'.*\'$ ]]; then + value="${value#\'}" + value="${value%\'}" + fi + + # Skip if name is empty + if [ -z "$name" ]; then + continue + fi + + # Export for the current shell session (plugin container only - not passed to next steps) + export "$name=$value" + + # Write to Harness CI Plugin Output (for output variables) + # These are scoped to the pipeline execution and accessible via: + # <+step.output.outputVariables.VAR_NAME> in subsequent steps + # Format: KEY=VALUE (one per line) + printf "%s=%s\n" "$name" "$value" >> /harness/outputs/outputs.txt + + # For environment variables, also write to env_vars.txt for Harness to pick up + # These are available as output variables and can be referenced in envVariables section + if [ "$type" = "env" ]; then + printf "%s=%s\n" "$name" "$value" >> /harness/outputs/env_vars.txt + fi + + # Write to file for direct access (bypasses Harness truncation) + # SECURITY: Files in /harness/secrets/ are scoped to pipeline execution only + # Harness CI automatically cleans up these files after pipeline completion + echo -n "$value" > "/harness/secrets/${name}" + chmod 600 "/harness/secrets/${name}" # Restrict permissions to owner only + + # Debug: Log (removed to reduce log noise) + # value_length=${#value} + # if [ "$type" = "env" ]; then + # echo "INFO: Set environment variable: $name (length: $value_length)" + # else + # echo "INFO: Set output variable: $name (length: $value_length)" + # fi + # echo "INFO: Secret also written to /harness/secrets/${name} for direct file access" + +done < "$SECRETS_FILE" + +# 4. Secure Clean up +# Remove the temporary file to ensure no sensitive data remains on disk +# Note: /harness/outputs/ and /harness/secrets/ are cleaned up by Harness CI +# after pipeline execution completes - they are scoped to the pipeline only +rm -f "$SECRETS_FILE" + +# 5. Hand over control to the Docker command (if any) +# This allows the container to be used as a wrapper for other commands +exec "$@" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fbf4a80 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "harness_keeper_plugin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "harness_keeper_plugin", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@keeper-security/secrets-manager-core": "^17.3.0" + } + }, + "node_modules/@keeper-security/secrets-manager-core": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/@keeper-security/secrets-manager-core/-/secrets-manager-core-17.3.0.tgz", + "integrity": "sha512-9Wm5s4qbJJlOFmB1L1p2+TqWLdMg/IQMcKEXKw2ZTOQlHjGzECrX2HoeCEdCG8datt/n7uo7f84uby4GvJyOXA==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c40ccaf --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "harness_keeper_plugin", + "version": "1.0.0", + "private": true, + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "@keeper-security/secrets-manager-core": "^17.3.0" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..7c8ba7b --- /dev/null +++ b/src/index.js @@ -0,0 +1,147 @@ +const { + getSecrets, + getValue, + localConfigStorage, + downloadFile, + initializeStorage +} = require('@keeper-security/secrets-manager-core'); +const fs = require('fs'); +const path = require('path'); + +const KSM_CONFIG_PATH = '/app/ksm-config.json'; +const REQUIRED_FIELDS = ['hostname', 'clientId', 'privateKey']; + +const core = { + getMultilineInput: (name) => { + const val = process.env[`PLUGIN_${name.toUpperCase()}`] || ''; + return val.split('\n').filter(line => line.trim() !== ''); + }, + info: (msg) => process.stderr.write(`INFO: ${msg}\n`), + error: (msg) => process.stderr.write(`::error::${msg}\n`), + warning: (msg) => process.stderr.write(`::warning::${msg}\n`) +}; + +const splitInput = (text) => { + const n = text.lastIndexOf('>'); + return n < 0 ? [text, ''] : [text.substring(0, n).trim(), text.substring(n + 1).trim()]; +}; + +const processToken = (rawToken) => { + if (!rawToken) { + core.error('KSM config is required'); + core.error('Set KSM_CONFIG environment variable'); + process.exit(1); + } + + let token = rawToken.trim(); + + if (token.includes('${') || token.includes('<+') || token.includes('ngSecretManager')) { + core.error('Harness expression not resolved'); + process.exit(1); + } + + // Decode base64 if needed + if (!token.startsWith('US:') && !token.startsWith('{')) { + try { + token = Buffer.from(token, 'base64').toString('utf-8').trim(); + } catch (e) { + // Not base64, use as-is + console.log(e) + } + } + + token = token.trim(); + if (!token) { + core.error('Token is null or empty'); + process.exit(1); + } + + const isConfigJson = token.startsWith('{'); + + if (isConfigJson) { + try { + const config = JSON.parse(token); + const missing = REQUIRED_FIELDS.filter(f => !config[f]); + if (missing.length) { + core.error(`Missing required fields: ${missing.join(', ')}`); + process.exit(1); + } + return { token, isConfigJson: true, config }; + } catch (e) { + core.error(`Invalid JSON config: ${e.message}`); + process.exit(1); + } + } + + if (!token.startsWith('US:')) { + core.error('Invalid token format - expected US:xxxxx or JSON config'); + process.exit(1); + } + + return { token, isConfigJson: false, config: null }; +}; + +const parseSecretMappings = () => { + return core.getMultilineInput('secrets').map(line => { + const [notation, destRaw] = splitInput(line); + if (destRaw.startsWith('env:')) { + return { notation, destination: destRaw.slice(4), destinationType: 'environment' }; + } + if (destRaw.startsWith('file:')) { + return { notation, destination: destRaw.slice(5), destinationType: 'file' }; + } + return { notation, destination: destRaw, destinationType: 'output' }; + }); +}; + +const setupStorage = async (token, isConfigJson, config) => { + if (isConfigJson) { + fs.writeFileSync(KSM_CONFIG_PATH, JSON.stringify(config), 'utf8'); + return localConfigStorage(KSM_CONFIG_PATH); + } + const storage = localConfigStorage(KSM_CONFIG_PATH); + await initializeStorage(storage, token); + return storage; +}; + +const runPlugin = async () => { + try { + fs.mkdirSync('/app', { recursive: true }); + + const { token, isConfigJson, config } = processToken(process.env.KSM_CONFIG); + const inputs = parseSecretMappings(); + const storage = await setupStorage(token, isConfigJson, config); + const secrets = await getSecrets({ storage }); + + for (const input of inputs) { + const secret = getValue(secrets, input.notation); + if (!secret) { + core.warning(`Value not found for notation: ${input.notation}`); + continue; + } + + if (input.destinationType === 'file') { + const fullPath = path.resolve(input.destination); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + const data = (typeof secret === 'object' && secret.fileId) + ? await downloadFile(secret) + : secret; + fs.writeFileSync(fullPath, data); + } else if (input.destinationType === 'environment') { + // Output with ENV: prefix for environment variables + console.log(`ENV:${input.destination}='${secret}'`); + } else { + // Output variable (for Harness output variables) + console.log(`OUT:${input.destination}='${secret}'`); + } + } + } catch (error) { + core.error(`Failed: ${error.message}`); + if (error.message.includes('token') || error.message.includes('invalid') || error.message.includes('expired')) { + core.error('One-time access tokens are single-use - generate a new token'); + } + process.exit(1); + } +}; + +runPlugin(); From 65858f46b46c557c266ff5e30c842ddeb4b7d107 Mon Sep 17 00:00:00 2001 From: Hitesh Borase Date: Wed, 14 Jan 2026 11:01:12 +0530 Subject: [PATCH 02/11] updated readme file and added support for file based record from keeper --- .dockerignore | 32 ++++ README.md | 485 +++++++++++++------------------------------------- src/index.js | 41 ++++- 3 files changed, 184 insertions(+), 374 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dccd0fe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# Dependencies - we install fresh in Docker +node_modules +npm-debug.log +yarn-error.log + +# Git files - not needed in image +.git +.gitignore +.gitattributes + +# IDE and editor files +.vscode +.idea +*.swp +*.swo +*~ +.DS_Store + +# Environment files +.env +.env.local +.env.*.local + +# Build artifacts +dist/ +build/ +*.log + +# Temporary files +tmp/ +temp/ +*.tmp diff --git a/README.md b/README.md index 0c7d841..d757237 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,51 @@ -# Harness Keeper Security Plugin +# Harness CI Keeper Plugin -A Harness CI/CD plugin that integrates with Keeper Security Secrets Manager (KSM) to securely fetch and inject secrets into your CI/CD pipelines at runtime. +Keeper Secrets Manager integration into Harness CI for dynamic secrets retrieval ## Overview This plugin securely retrieves secrets from Keeper Security Secrets Manager and makes them available to subsequent steps in your Harness pipeline. The plugin implements a zero-knowledge architecture where secrets are retrieved directly from Keeper Vault at runtime and never pass through Harness systems in decrypted form. -**Key Features:** -- **Zero-Knowledge Architecture**: Secrets are retrieved directly from Keeper Vault at runtime -- **Multiple Authentication Methods**: Supports one-time access tokens (`US:...`) and full JSON config -- **Keeper Notation Support**: Query fields (`field`), custom fields (`custom_field`), and file attachments (`file`) -- **Multiple Output Formats**: Environment variables, file output, and output variables -- **Secure Storage**: Secrets written to `/harness/secrets/` with secure file permissions -- **Automatic Validation**: Validates token format and required config fields -- **Base64 Decoding**: Automatically decodes base64-encoded tokens +## Features -## Architecture - -### Zero-Knowledge Design - -The plugin ensures zero-knowledge security through the following design: - -1. **Harness CI/CD** calls the custom Docker-based plugin -2. **Plugin authenticates** using KSM configuration stored in Harness Secrets Manager -3. **Secrets are retrieved** directly from Keeper Vault at runtime -4. **Decrypted secrets** are returned as step outputs for downstream stages -5. **No external storage**: Secrets never pass through Harness systems in decrypted form +- Retrieve secrets from the Keeper Vault within the Harness CI pipeline +- Support for standard fields, custom fields, and file attachments +- Zero-knowledge architecture - secrets retrieved directly from Keeper at runtime All secret flow happens strictly between the plugin container and Keeper, while Harness provides orchestration, governance, and secure secret injection across the pipeline. -### Components - -1. **`src/index.js`**: Main plugin logic - - Token processing and validation - - Keeper Secrets Manager integration - - Secret fetching using Keeper Notation - - Secret distribution - -2. **`entrypoint.sh`**: Container entrypoint script - - Executes the Node.js plugin - - Captures and processes plugin output - - Writes secrets to Harness directories - - Manages security and cleanup - -3. **`Example/Pipeline.yaml`**: Example Harness CI pipeline - - Demonstrates plugin configuration - - Shows secret consumption patterns - -### Execution Flow - -``` -1. Container starts → entrypoint.sh executes -2. entrypoint.sh → Runs: node /app/src/index.js -3. Plugin processes: - - Reads KSM_CONFIG from environment - - Validates and decodes token (base64 if needed) - - Initializes Keeper storage - - Fetches secrets from Keeper using Keeper Notation - - Outputs secrets to stdout (with ENV:/OUT: prefixes) -4. entrypoint.sh captures stdout: - - Parses ENV:/OUT: prefixes - - Writes to /harness/outputs/outputs.txt (output variables) - - Writes to /harness/secrets/ (file-based access) - - Sets secure permissions (chmod 600) -5. Subsequent steps access secrets via: - - Files: /harness/secrets/VARIABLE_NAME (recommended) -``` - ## Prerequisites -- **Keeper Secrets Manager access** ([Quick Start Guide](https://docs.keeper.io/secrets-manager/secrets-manager/quick-start-guide)) -- **Secrets Manager addon enabled** for your Keeper account -- **Membership in a Role** with the Secrets Manager enforcement policy enabled -- **A Keeper Secrets Manager Application** with secrets shared to it -- **An initialized Keeper Secrets Manager Configuration** (JSON or one-time token) -- **Record UID(s)** for the secrets you want to access ⚠️ **REQUIRED** -- **Harness CI/CD account** with a project set up -- **Docker** (for building the plugin image) -- **Node.js 18+** (for local development) +- Keeper Secrets Manager access with Application configured +- Harness CI account with project setup +- KSM configuration (one-time access token `US:...` or Base64-encoded token or JSON confign) -## Installation +## About -### Build and Push Docker Image +The plugin retrieves secrets from Keeper Secrets Manager and stores them in **Harness pipeline volume storage** at `/harness/secrets/` directory for reliable file-based access by subsequent steps. -Build the plugin Docker image: +**Important:** Secrets are stored in `/harness/secrets/` directory (Harness shared workspace volume), not as Harness environment variables. -```bash -docker build -t your-dockerhub-username/harness-keeper-plugin:latest . +All secrets are: +- **Pipeline-scoped**: Only accessible during the current pipeline execution +- **Automatically cleaned up**: Harness CI removes all secrets after pipeline completion +- **Securely stored**: Files have restricted permissions (600 - owner read/write only) + +### Project Structure + +``` +harness_keeper_plugin/ +├── src/ +│ └── index.js # Main plugin logic +├── entrypoint.sh # Container entrypoint script +├── Dockerfile # Docker image definition +├── package.json # Node.js dependencies +├── Example/ +│ └── Pipeline.yaml # Example Harness CI pipeline +└── README.md # This file ``` -Push to your container registry: - -```bash -docker push your-dockerhub-username/harness-keeper-plugin:latest -``` - -**Note:** Replace `your-dockerhub-username` with your actual Docker Hub username or container registry path. - -## Configuration - -### 1. Create Keeper Access Token - -#### Option A: One-Time Access Token -1. Log in to Keeper Security -2. Navigate to Secrets Manager -3. Generate a one-time access token (format: `US:xxxxx`) -4. Base64 encode (optional but recommended): - ```bash - echo -n "US:your-token-here" | base64 - ``` - -#### Option B: Full JSON Config -Create a JSON config with: -```json -{ - "hostname": "keepersecurity.com", - "clientId": "your-client-id", - "privateKey": "your-private-key" -} -``` -Base64 encode: -```bash -echo -n '{"hostname":"...","clientId":"...","privateKey":"..."}' | base64 -``` - -### 2. Create Harness Secret - -In Harness, create a secret: -- **Type**: TEXT or FILE (both work) -- **Name**: `KSM_CONFIG` (or your preferred name) -- **Value**: Your base64-encoded token or JSON config - -### 3. Configure Pipeline - -**Before you begin:** You must have the Record UID(s) for the secrets you want to access. See "How to Get Record UID" section below. +## Quick Start ```yaml pipeline: @@ -161,15 +73,15 @@ pipeline: name: Fetch_Keeper_Secrets identifier: Fetch_Keeper_Secrets spec: - image: your-dockerhub-username/harness-keeper-plugin:latest + image: dhborse/keeper-harness-plugin settings: secrets: | - JQPso5SMBpP29gKKGp6Enw/field/password > env:DB_PASSWORD - JQPso5SMBpP29gKKGp6Enw/field/login > env:DB_USERNAME - JQPso5SMBpP29gKKGp6Enw/custom_field/API_Key > env:API_KEY - JQPso5SMBpP29gKKGp6Enw/file/api.key > file:/app/config/api.key + VeYTRo-PHElAwfQT6f0TIA/field/password > DB_PASSWORD + VeYTRo-PHElAwfQT6f0TIA/field/login > DB_USERNAME + VeYTRo-PHElAwfQT6f0TIA/custom_field/text > KEEPER_TEXT + VeYTRo-PHElAwfQT6f0TIA/file/credentials.txt > FILE_DATA envVariables: - KSM_CONFIG: <+secrets.getValue("KSM_CONFIG")> + KSM_CONFIG: <+secrets.getValue("Test_File_secret")> - step: type: Run name: Use_Keeper_Secrets @@ -178,116 +90,79 @@ pipeline: image: alpine:3.20 shell: Sh command: | - DB_USERNAME=$(cat /harness/secrets/DB_USERNAME) - DB_PASSWORD=$(cat /harness/secrets/DB_PASSWORD) - API_KEY=$(cat /harness/secrets/API_KEY) - echo "Secrets retrieved successfully" - # Use secrets in your build/deploy process + if [ -f /harness/secrets/DB_USERNAME ] && [ -f /harness/secrets/DB_PASSWORD ]; then + DB_USERNAME=$(cat /harness/secrets/DB_USERNAME) + DB_PASSWORD=$(cat /harness/secrets/DB_PASSWORD) + echo "Username: $DB_USERNAME" + echo "Password: $DB_PASSWORD" + fi + + if [ -f /harness/secrets/FILE_DATA ]; then + FILE_DATA=$(cat /harness/secrets/FILE_DATA) + echo "File Data: $FILE_DATA" + fi ``` -**Note:** `envVariables` is used ONLY to pass the Keeper configuration token to the plugin. The actual secrets retrieved from Keeper are stored in Harness pipeline volume storage at `/harness/secrets/`. +## Inputs -## Usage +### KSM_CONFIG -### Secret Mapping Format +Keeper Secrets Manager configuration for authentication. Store in Harness secrets and reference: -``` - > +```yaml +envVariables: + KSM_CONFIG: <+secrets.getValue("Keeper_Config_Secret")> ``` -### Keeper Notation Format +**Supported Formats:** +- One-time access token: `US:xxxxx` +- JSON configuration: `{"hostname": "...", "clientId": "...", "privateKey": "..."}` +- Base64-encoded token or JSON config + +### Secrets + +Keeper Notation queries mapping secrets to destinations: + +**Format:** ` > ` + +**Example:** +```yaml +secrets: | + VeYTRo-PHElAwfQT6f0TIA/field/password > DB_PASSWORD + VeYTRo-PHElAwfQT6f0TIA/field/login > DB_USERNAME + VeYTRo-PHElAwfQT6f0TIA/custom_field/text > KEEPER_TEXT + VeYTRo-PHElAwfQT6f0TIA/file/credentials.txt > FILE_DATA +``` + +## Keeper Notation Format The plugin supports three types of Keeper Notation queries: -1. **Standard Fields**: `/field/` - - Examples: `password`, `login`, `url`, `text`, `host`, etc. +| Type | Format | Example | +|------|--------|---------| +| **Standard Fields** | `/field/` | `VeYTRo.../field/password` | +| **Custom Fields** | `/custom_field/` | `VeYTRo.../custom_field/API_Key` | +| **File Attachments** | `/file/` | `VeYTRo.../file/credentials.txt` | -2. **Custom Fields**: `/custom_field/` - - Examples: `API_Key`, `My Custom Field`, `Environment`, etc. +**Note:** Record UID is the unique identifier for a secret record in Keeper. Get it from Keeper Vault → Record details → Record UID. -3. **File Attachments**: `/file/` - - Examples: `api.key`, `certificate.pem`, `config.json`, etc. +## Destination Format -**Important:** The first part (e.g., `JQPso5SMBpP29gKKGp6Enw`) is the **Record UID**, the unique identifier for a specific secret record in Keeper. +The destination defines where the secret is stored: -### Destination Types +| Format | Description | Output Location | +|--------|-------------|----------------| +| `VARIABLE_NAME` | Default output (recommended) | `/harness/secrets/VARIABLE_NAME` | -| Destination Prefix | Description | Output Location | -|-------------------|-------------|----------------| -| `env:VARIABLE_NAME` | Environment variable | `/harness/secrets/VARIABLE_NAME` | -| `file:/path/to/file` | File output | Specified file path | -| `VARIABLE_NAME` (default) | Output variable | `/harness/secrets/VARIABLE_NAME` | - -### Examples - -#### 1. Standard Field → Environment Variable +**Examples:** ```yaml -secrets: | - JQPso5SMBpP29gKKGp6Enw/field/password > env:DB_PASSWORD - JQPso5SMBpP29gKKGp6Enw/field/login > env:DB_USERNAME -``` -- Creates files: `/harness/secrets/DB_PASSWORD`, `/harness/secrets/DB_USERNAME` -- Access: `DB_PASSWORD=$(cat /harness/secrets/DB_PASSWORD)` - -#### 2. Custom Field → Output Variable -```yaml -secrets: | - JQPso5SMBpP29gKKGp6Enw/custom_field/API_Key > API_KEY - JQPso5SMBpP29gKKGp6Enw/custom_field/Environment > ENV_NAME -``` -- Creates files: `/harness/secrets/API_KEY`, `/harness/secrets/ENV_NAME` -- Access: `API_KEY=$(cat /harness/secrets/API_KEY)` - -#### 3. File Attachment → File Path -```yaml -secrets: | - JQPso5SMBpP29gKKGp6Enw/file/api.key > file:/app/config/api.key - JQPso5SMBpP29gKKGp6Enw/file/certificate.pem > file:/app/ssl/cert.pem -``` -- Downloads files and writes to specified paths -- Access: `cat /app/config/api.key` - -#### 4. Mixed Usage -```yaml -secrets: | - JQPso5SMBpP29gKKGp6Enw/field/password > env:DB_PASSWORD - JQPso5SMBpP29gKKGp6Enw/custom_field/API_Key > API_KEY - JQPso5SMBpP29gKKGp6Enw/file/config.json > file:/app/config.json +# Default: saves to /harness/secrets/DB_PASSWORD +VeYTRo-PHElAwfQT6f0TIA/field/password > DB_PASSWORD ``` -### How to Get Record UID +## Accessing Secrets -The Record UID is **mandatory** and identifies which specific secret record to retrieve. Obtain it using one of these methods: - -**Method 1: From Keeper Web Vault** -1. Log in to your Keeper Vault -2. Open the secret record you want to access -3. Click on the record details/info icon -4. The Record UID is displayed in the record information (usually at the bottom) -5. Copy the Record UID (format: `JQPso5SMBpP29gKKGp6Enw`) - -**Method 2: Using Keeper Commander CLI** -```bash -keeper list -# This will show all records with their UIDs -``` - -**Method 3: Using Keeper Secrets Manager SDK/API** -- Use the Keeper Secrets Manager SDK to list records and retrieve their UIDs -- The Record UID is returned when querying records - -**Method 4: From Keeper Admin Console** -1. Navigate to Secrets Manager in Admin Console -2. View the Application and its shared records -3. Record UIDs are visible in the record list - -**Note:** The one-time access token (`US:...`) or JSON config is used to **authenticate** and access Keeper, but you still need the **Record UID** to specify which secret record to retrieve. - -## Accessing Secrets in Subsequent Steps - -### Method 1: File-Based Access (Recommended) - -The most reliable method for accessing secrets in subsequent steps: +Secrets are stored in `/harness/secrets/` directory. Read them in subsequent steps: ```yaml - step: @@ -297,177 +172,52 @@ The most reliable method for accessing secrets in subsequent steps: image: alpine:3.20 shell: Sh command: | - DB_PASSWORD=$(cat /harness/secrets/DB_PASSWORD) DB_USERNAME=$(cat /harness/secrets/DB_USERNAME) - API_KEY=$(cat /harness/secrets/API_KEY) - # Use secrets in your process - echo "Secrets loaded successfully" - # Clean up after use (optional) - rm -f /harness/secrets/DB_PASSWORD /harness/secrets/DB_USERNAME + DB_PASSWORD=$(cat /harness/secrets/DB_PASSWORD) + FILE_DATA=$(cat /harness/secrets/FILE_DATA) + # Use secrets in your build/deploy process ``` **Benefits:** - Most reliable method - No truncation issues -- Works with all secret types +- Works with all secret types including binary files - Supports long values and special characters -### Method 2: Direct File Access (for file: prefix) +## Secret Storage -When using `file:` prefix, files are written directly to the specified path: - -```yaml -- step: - type: Run - name: Use_File - spec: - command: | - # File already at /app/config/api.key - cat /app/config/api.key -``` - -## Secret Storage Mechanism - -**Important:** This plugin uses **Harness pipeline volume storage**, not Harness environment variables. - -- **Storage Location:** `/harness/secrets/` directory (Harness shared workspace volume) -- **Storage Type:** File-based storage on pipeline-scoped volume -- **Access Method:** Read files directly using `cat` or file operations +- **Location:** `/harness/secrets/` directory (Harness shared workspace volume) +- **Permissions:** `600` (owner read/write only) - **Scope:** Pipeline execution only - automatically cleaned up after completion -- **Permissions:** Files have `600` permissions (owner read/write only) +- **Access:** Read files directly using `cat` or file operations -The `envVariables` section in the plugin step is **only** used to pass the `KSM_CONFIG` (Keeper configuration token) to the plugin. The actual secrets retrieved from Keeper are written to files in `/harness/secrets/` directory. +The `envVariables` section is **only** used to pass `KSM_CONFIG` to the plugin. Actual secrets are written to files in `/harness/secrets/` directory. ## Security -### Zero-Knowledge Architecture - -- **Direct Retrieval**: Secrets are retrieved directly from Keeper Vault at runtime +- **Zero-Knowledge**: Secrets retrieved directly from Keeper Vault at runtime - **No Decrypted Storage**: Secrets never pass through Harness systems in decrypted form -- **Pipeline-Scoped**: All secrets are scoped to the current pipeline execution -- **Automatic Cleanup**: Harness CI automatically cleans up `/harness/outputs/` and `/harness/secrets/` after pipeline completion -- **File Permissions**: Secrets written to `/harness/secrets/` have `600` permissions (owner read/write only) -- **No Persistence**: Secrets are not accessible outside the pipeline execution context +- **Pipeline-Scoped**: All secrets scoped to current pipeline execution +- **Automatic Cleanup**: Harness CI removes secrets after pipeline completion +- **File Permissions**: Files have `600` permissions (owner read/write only) ### Best Practices -1. **Use one-time access tokens** instead of permanent credentials when possible -2. **Base64 encode tokens** before storing in Harness secrets -3. **Clean up secret files** after use in pipeline steps -4. **Use appropriate secret scope** (project/org/account) -5. **Verify Record UIDs** before configuring pipelines +1. Use one-time access tokens instead of permanent credentials +2. Base64 encode tokens before storing in Harness secrets +3. Clean up secret files after use in pipeline steps +4. Use appropriate secret scope (project/org/account) +5. Verify Record UIDs before configuring pipelines ## Troubleshooting -### Token Not Found - -**Error:** `KSM config is required` - -**Solutions:** -- Verify the secret exists in Harness -- Check the secret scope matches your project -- Ensure the expression `<+secrets.getValue("KSM_CONFIG")>` is correct -- Verify secret name matches exactly - -### Invalid Token Format - -**Error:** `Invalid token format - expected US:xxxxx or JSON config` - -**Solutions:** -- Verify the token starts with `US:` (for one-time tokens) -- Check if the token is base64 encoded (it will be auto-decoded) -- For JSON config, ensure it starts with `{` and has required fields: `hostname`, `clientId`, `privateKey` - -### Token Already Used / Expired - -**Error:** `Failed: token ... invalid/expired` - -**Solutions:** -1. Generate a new token in Keeper Security -2. Base64 encode: `echo -n "US:new-token" | base64` -3. Update the Harness secret with the new token -4. Run the pipeline again - -**Note:** One-time access tokens are single-use - generate a new token for each use or use JSON config for reusable credentials. - -### Secret File Not Found - -**Error:** `ERROR: Secret files not found` or `Value not found for notation: /field/` - -**Solutions:** -- **Verify Record UID is correct**: The Record UID must match exactly (case-sensitive) -- **Check Record UID exists**: Ensure the record exists in Keeper and is shared to your Secrets Manager Application -- **Verify access permissions**: The one-time token or JSON config must have access to the record -- **Check field name**: Ensure the field type (`password`, `login`, `text`, etc.) or custom field label exists in the record -- **Verify Application sharing**: The record must be shared to the Secrets Manager Application you're using -- **Check logs**: `ls -la /harness/secrets/` to see what files were created -- **Verify notation format**: Ensure the notation follows the correct format (e.g., `/field/password`, `/custom_field/MyLabel`, `/file/filename`) - -### Expression Not Resolved - -**Error:** Token preview shows `${...}` or `<+...>` unresolved - -**Solutions:** -- Use `envVariables` (not `settings`) for Harness expressions when passing `KSM_CONFIG` to the plugin -- Verify secret name matches exactly -- Check secret scope matches the reference -- Ensure you're using the correct Harness expression syntax: `<+secrets.getValue("SECRET_NAME")>` - -### Harness Expression Not Resolved - -**Error:** `Harness expression not resolved` - -**Solutions:** -- Ensure `KSM_CONFIG` is passed via `envVariables`, not `settings` -- Verify the secret exists and is accessible in the current scope -- Check that the Harness expression syntax is correct: `<+secrets.getValue("SECRET_NAME")>` - -## Local Development - -### Test Locally - -```bash -# Set environment variables -export PLUGIN_SECRETS="JQPso5SMBpP29gKKGp6Enw/field/password > env:DB_PASSWORD" -export KSM_CONFIG="VVM6..." # Your base64 encoded token or JSON config - -# Run the plugin -node ./src/index.js -``` - -### Project Structure - -``` -harness_keeper_plugin/ -├── src/ -│ └── index.js # Main plugin logic -├── entrypoint.sh # Container entrypoint script -├── Dockerfile # Docker image definition -├── package.json # Node.js dependencies -├── Example/ -│ └── Pipeline.yaml # Example Harness CI pipeline -└── README.md # This file -``` - -## Example Pipeline - -See `Example/Pipeline.yaml` for a complete working example that demonstrates: -- Plugin configuration -- Secret mapping with multiple record UIDs -- File-based secret consumption -- Error handling and failure strategies - -## Support - -For issues or questions: -- Check the [Keeper Security Documentation](https://docs.keeper.io/secrets-manager/) -- Review [Keeper Notation Documentation](https://docs.keeper.io/secrets-manager/secrets-manager/keeper-notation/) -- Review Harness CI documentation for plugin configuration -- Verify token format and Keeper record permissions - -## License - -ISC +| Error | Solution | +|-------|----------| +| `KSM config is required` | Verify secret exists and expression `<+secrets.getValue("SECRET_NAME")>` is correct | +| `Invalid token format` | Check token starts with `US:` or `{` for JSON config | +| `Value not found for notation` | Verify Record UID, field name (case-sensitive), and Application permissions | +| `Failed to download file` | Check file exists in record, name matches exactly, and Application has file access permissions | +| `Harness expression not resolved` | Verify secret reference expression and secret scope (project/org/account) | ## Version History @@ -478,3 +228,8 @@ ISC - File-based secret access - Base64 token decoding - Secure file permissions and cleanup + +## References + +- [Keeper Notation Documentation](https://docs.keeper.io/en/keeperpam/secrets-manager/about/keeper-notation) +- [Keeper Secrets Manager Quick Start Guide](https://docs.keeper.io/en/keeperpam/secrets-manager/quick-start-guide) \ No newline at end of file diff --git a/src/index.js b/src/index.js index 7c8ba7b..4284310 100644 --- a/src/index.js +++ b/src/index.js @@ -120,19 +120,42 @@ const runPlugin = async () => { continue; } + const notationIsFile = input.notation.includes('/file/'); + const isFileReference = typeof secret === 'object' && + secret !== null && + (secret.fileId || secret.fileUid || secret.url || notationIsFile); + + let data; + + if (isFileReference) { + try { + const fileData = await downloadFile(secret); + data = fileData instanceof Uint8Array ? Buffer.from(fileData) : + Buffer.isBuffer(fileData) ? fileData : Buffer.from(fileData); + } catch (downloadError) { + core.error(`Failed to download file for notation ${input.notation}: ${downloadError.message}`); + continue; + } + } else { + data = Buffer.isBuffer(secret) ? secret : + typeof secret === 'string' ? Buffer.from(secret, 'utf8') : + Buffer.from(String(secret), 'utf8'); + } + if (input.destinationType === 'file') { const fullPath = path.resolve(input.destination); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - const data = (typeof secret === 'object' && secret.fileId) - ? await downloadFile(secret) - : secret; fs.writeFileSync(fullPath, data); - } else if (input.destinationType === 'environment') { - // Output with ENV: prefix for environment variables - console.log(`ENV:${input.destination}='${secret}'`); } else { - // Output variable (for Harness output variables) - console.log(`OUT:${input.destination}='${secret}'`); + fs.mkdirSync('/harness/secrets', { recursive: true }); + const secretFilePath = path.join('/harness/secrets', input.destination); + fs.writeFileSync(secretFilePath, data); + fs.chmodSync(secretFilePath, 0o600); + + if (input.destinationType === 'environment') { + const outputValue = Buffer.isBuffer(data) ? data.toString('utf8') : String(data); + console.log(`ENV:${input.destination}='${outputValue}'`); + } } } } catch (error) { @@ -144,4 +167,4 @@ const runPlugin = async () => { } }; -runPlugin(); +runPlugin(); \ No newline at end of file From 1462420c40884194f0fc0c1a0edbbd359917381b Mon Sep 17 00:00:00 2001 From: Hitesh Borase Date: Thu, 15 Jan 2026 17:55:52 +0530 Subject: [PATCH 03/11] unit testing --- .gitignore | 3 +- entrypoint.sh | 23 - jest.config.js | 20 + package-lock.json | 22 - package.json | 6 +- src/index.js | 8 +- src/test/index.test.js | 929 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 961 insertions(+), 50 deletions(-) create mode 100644 jest.config.js delete mode 100644 package-lock.json create mode 100644 src/test/index.test.js diff --git a/.gitignore b/.gitignore index 40b878d..ccb2c80 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -node_modules/ \ No newline at end of file +node_modules/ +package-lock.json \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh index c59e5db..835164e 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -9,12 +9,7 @@ SECRETS_FILE=$(mktemp) node /app/src/index.js > "$SECRETS_FILE" # 3. Securely process the secrets -# Parse lines in format: ENV:VAR_NAME='value', OUT:VAR_NAME='value', or VAR_NAME='value' -# # SECURITY NOTE: All secrets are written to /harness/outputs/ and /harness/secrets/ -# These directories are mounted volumes scoped ONLY to the current pipeline execution. -# Harness CI automatically cleans up these volumes after pipeline completion. -# Secrets are NOT accessible outside the pipeline execution context. mkdir -p /harness/outputs /harness/secrets while IFS= read -r line; do @@ -53,9 +48,6 @@ while IFS= read -r line; do export "$name=$value" # Write to Harness CI Plugin Output (for output variables) - # These are scoped to the pipeline execution and accessible via: - # <+step.output.outputVariables.VAR_NAME> in subsequent steps - # Format: KEY=VALUE (one per line) printf "%s=%s\n" "$name" "$value" >> /harness/outputs/outputs.txt # For environment variables, also write to env_vars.txt for Harness to pick up @@ -65,28 +57,13 @@ while IFS= read -r line; do fi # Write to file for direct access (bypasses Harness truncation) - # SECURITY: Files in /harness/secrets/ are scoped to pipeline execution only - # Harness CI automatically cleans up these files after pipeline completion echo -n "$value" > "/harness/secrets/${name}" chmod 600 "/harness/secrets/${name}" # Restrict permissions to owner only - # Debug: Log (removed to reduce log noise) - # value_length=${#value} - # if [ "$type" = "env" ]; then - # echo "INFO: Set environment variable: $name (length: $value_length)" - # else - # echo "INFO: Set output variable: $name (length: $value_length)" - # fi - # echo "INFO: Secret also written to /harness/secrets/${name} for direct file access" - done < "$SECRETS_FILE" # 4. Secure Clean up -# Remove the temporary file to ensure no sensitive data remains on disk -# Note: /harness/outputs/ and /harness/secrets/ are cleaned up by Harness CI -# after pipeline execution completes - they are scoped to the pipeline only rm -f "$SECRETS_FILE" # 5. Hand over control to the Docker command (if any) -# This allows the container to be used as a wrapper for other commands exec "$@" \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..2dbe9f9 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,20 @@ +module.exports = { + testEnvironment: 'node', + collectCoverageFrom: [ + 'src/**/*.js', + '!src/**/*.test.js', + ], + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, + testMatch: [ + '**/__tests__/**/*.js', + '**/*.test.js', + ], + verbose: true, +}; diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index fbf4a80..0000000 --- a/package-lock.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "harness_keeper_plugin", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "harness_keeper_plugin", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@keeper-security/secrets-manager-core": "^17.3.0" - } - }, - "node_modules/@keeper-security/secrets-manager-core": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/@keeper-security/secrets-manager-core/-/secrets-manager-core-17.3.0.tgz", - "integrity": "sha512-9Wm5s4qbJJlOFmB1L1p2+TqWLdMg/IQMcKEXKw2ZTOQlHjGzECrX2HoeCEdCG8datt/n7uo7f84uby4GvJyOXA==", - "license": "MIT" - } - } -} diff --git a/package.json b/package.json index c40ccaf..bae7134 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest --coverage", + "test:watch": "jest --watch" }, "keywords": [], "author": "", @@ -13,5 +14,8 @@ "type": "commonjs", "dependencies": { "@keeper-security/secrets-manager-core": "^17.3.0" + }, + "devDependencies": { + "jest": "^29.7.0" } } diff --git a/src/index.js b/src/index.js index 4284310..3af32c4 100644 --- a/src/index.js +++ b/src/index.js @@ -106,6 +106,7 @@ const setupStorage = async (token, isConfigJson, config) => { const runPlugin = async () => { try { + core.info('Starting Keeper Secrets Manager plugin'); fs.mkdirSync('/app', { recursive: true }); const { token, isConfigJson, config } = processToken(process.env.KSM_CONFIG); @@ -130,8 +131,9 @@ const runPlugin = async () => { if (isFileReference) { try { const fileData = await downloadFile(secret); - data = fileData instanceof Uint8Array ? Buffer.from(fileData) : - Buffer.isBuffer(fileData) ? fileData : Buffer.from(fileData); + data = Buffer.isBuffer(fileData) ? fileData : + fileData instanceof Uint8Array ? Buffer.from(fileData) : + Buffer.from(fileData); } catch (downloadError) { core.error(`Failed to download file for notation ${input.notation}: ${downloadError.message}`); continue; @@ -153,7 +155,7 @@ const runPlugin = async () => { fs.chmodSync(secretFilePath, 0o600); if (input.destinationType === 'environment') { - const outputValue = Buffer.isBuffer(data) ? data.toString('utf8') : String(data); + const outputValue = data.toString('utf8'); console.log(`ENV:${input.destination}='${outputValue}'`); } } diff --git a/src/test/index.test.js b/src/test/index.test.js new file mode 100644 index 0000000..f6fc89c --- /dev/null +++ b/src/test/index.test.js @@ -0,0 +1,929 @@ +// Mock all external dependencies before requiring the module +jest.mock('@keeper-security/secrets-manager-core'); +jest.mock('fs'); +jest.mock('path'); + +const fs = require('fs'); +const path = require('path'); + +describe('index.js - Complete Test Suite', () => { + let getSecrets, getValue, localConfigStorage, downloadFile, initializeStorage; + let mockExit, mockStderrWrite, mockConsoleLog; + let originalEnv; + + // Helper to wait for async operations to complete + const waitForAsync = async () => { + // Wait multiple ticks to ensure all async operations complete + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + // Also wait a small timeout to ensure all promises resolve + await new Promise(resolve => setTimeout(resolve, 10)); + }; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + jest.resetModules(); + + // Save original environment + originalEnv = { ...process.env }; + + // Mock process methods + mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); + mockStderrWrite = jest.spyOn(process.stderr, 'write').mockImplementation(() => {}); + mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + + // Setup mocks for Keeper Secrets Manager + getSecrets = jest.fn(); + getValue = jest.fn(); + localConfigStorage = jest.fn(); + downloadFile = jest.fn(); + initializeStorage = jest.fn(); + + const secretsManagerCore = require('@keeper-security/secrets-manager-core'); + secretsManagerCore.getSecrets = getSecrets; + secretsManagerCore.getValue = getValue; + secretsManagerCore.localConfigStorage = localConfigStorage; + secretsManagerCore.downloadFile = downloadFile; + secretsManagerCore.initializeStorage = initializeStorage; + + // Setup fs mocks - need to get fresh references after resetModules + const fsModule = require('fs'); + fs.mkdirSync = jest.fn(); + fs.writeFileSync = jest.fn(); + fs.chmodSync = jest.fn(); + // Also set on the module itself + fsModule.mkdirSync = fs.mkdirSync; + fsModule.writeFileSync = fs.writeFileSync; + fsModule.chmodSync = fs.chmodSync; + + // Setup path mocks + const pathModule = require('path'); + path.resolve = jest.fn((p) => `/resolved/${p}`); + path.dirname = jest.fn((p) => { + const parts = p.split('/'); + parts.pop(); + return parts.join('/') || '/'; + }); + path.join = jest.fn((...args) => args.join('/')); + pathModule.resolve = path.resolve; + pathModule.dirname = path.dirname; + pathModule.join = path.join; + + // Clear environment variables + delete process.env.KSM_CONFIG; + delete process.env.PLUGIN_SECRETS; + }); + + afterEach(() => { + // Restore original environment + process.env = originalEnv; + }); + + afterAll(() => { + mockExit?.mockRestore(); + mockStderrWrite?.mockRestore(); + mockConsoleLog?.mockRestore(); + }); + + describe('processToken - Error Cases', () => { + test('should exit when rawToken is null', () => { + delete process.env.KSM_CONFIG; + require('../index'); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('KSM config is required')); + }); + + test('should exit when rawToken is undefined', () => { + delete process.env.KSM_CONFIG; + require('../index'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + test('should exit when rawToken is empty string', () => { + process.env.KSM_CONFIG = ''; + require('../index'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + test('should exit when token contains ${ Harness expression', () => { + process.env.KSM_CONFIG = '${harness.expression}'; + require('../index'); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Harness expression not resolved')); + }); + + test('should exit when token contains <+ Harness expression', () => { + process.env.KSM_CONFIG = '<+expression>'; + require('../index'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + test('should exit when token contains ngSecretManager', () => { + process.env.KSM_CONFIG = 'ngSecretManager.token'; + require('../index'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + test('should exit when token is empty after trim', () => { + process.env.KSM_CONFIG = ' '; + require('../index'); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Token is null or empty')); + }); + + test('should exit when JSON config is missing hostname field', () => { + const jsonConfig = JSON.stringify({ clientId: 'client', privateKey: 'key' }); + process.env.KSM_CONFIG = jsonConfig; + require('../index'); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Missing required fields')); + }); + + test('should exit when JSON config is missing clientId field', () => { + const jsonConfig = JSON.stringify({ hostname: 'test.com', privateKey: 'key' }); + process.env.KSM_CONFIG = jsonConfig; + require('../index'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + test('should exit when JSON config is missing privateKey field', () => { + const jsonConfig = JSON.stringify({ hostname: 'test.com', clientId: 'client' }); + process.env.KSM_CONFIG = jsonConfig; + require('../index'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + test('should exit when JSON config is missing multiple fields', () => { + const jsonConfig = JSON.stringify({ hostname: 'test.com' }); + process.env.KSM_CONFIG = jsonConfig; + require('../index'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + test('should exit when JSON config is invalid', () => { + process.env.KSM_CONFIG = '{invalid json}'; + require('../index'); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON config')); + }); + + test('should exit when token format is invalid (not US: or JSON)', () => { + process.env.KSM_CONFIG = 'INVALID:token'; + require('../index'); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Invalid token format')); + }); + }); + + describe('processToken - Success Cases', () => { + test('should decode base64 token successfully', async () => { + const base64Token = Buffer.from('US:decoded-token').toString('base64'); + process.env.KSM_CONFIG = base64Token; + process.env.PLUGIN_SECRETS = 'notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(initializeStorage).toHaveBeenCalled(); + }); + + test('should handle base64 decode error gracefully', async () => { + process.env.KSM_CONFIG = 'invalid-base64!@#'; + process.env.PLUGIN_SECRETS = 'notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + // Should continue execution despite decode error + }); + + test('should parse valid JSON config', async () => { + const jsonConfig = JSON.stringify({ hostname: 'test.com', clientId: 'client', privateKey: 'key' }); + process.env.KSM_CONFIG = jsonConfig; + process.env.PLUGIN_SECRETS = 'notation>output'; + const mockStorage = {}; + localConfigStorage.mockReturnValue(mockStorage); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(fs.writeFileSync).toHaveBeenCalledWith('/app/ksm-config.json', jsonConfig, 'utf8'); + expect(localConfigStorage).toHaveBeenCalledWith('/app/ksm-config.json'); + }); + + test('should accept US: token format', async () => { + process.env.KSM_CONFIG = 'US:test-token'; + process.env.PLUGIN_SECRETS = 'notation>output'; + const mockStorage = {}; + localConfigStorage.mockReturnValue(mockStorage); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(initializeStorage).toHaveBeenCalledWith(mockStorage, 'US:test-token'); + }); + + test('should handle token with whitespace', async () => { + process.env.KSM_CONFIG = ' US:test-token '; + process.env.PLUGIN_SECRETS = 'notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(initializeStorage).toHaveBeenCalledWith(expect.anything(), 'US:test-token'); + }); + + test('should call info function on plugin start', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + // Verify info was called with the startup message + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('INFO: Starting Keeper Secrets Manager plugin')); + }); + + test('should test console.log in base64 decode catch block', () => { + // This test ensures line 49 (console.log(e)) is covered + // Buffer.from doesn't throw for invalid base64, so we need to mock it to throw + const originalBufferFrom = Buffer.from; + Buffer.from = jest.fn(() => { + throw new Error('Base64 decode error'); + }); + + process.env.KSM_CONFIG = 'not-us-format-token'; + process.env.PLUGIN_SECRETS = 'notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + // The console.log in catch block should have been called + expect(mockConsoleLog).toHaveBeenCalled(); + + // Restore Buffer.from + Buffer.from = originalBufferFrom; + }); + }); + + describe('parseSecretMappings', () => { + test('should parse env: destination type', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>env:VAR_NAME'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(mockConsoleLog).toHaveBeenCalledWith("ENV:VAR_NAME='secret-value'"); + }); + + test('should parse file: destination type', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should parse output destination type (default)', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output_var'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should handle input without > separator', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should handle multiple > characters', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>part1>destination'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + }); + + test('should handle multiple secret mappings', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation1>env:VAR1\nnotation2>file:/path/file\nnotation3>output1'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(getValue).toHaveBeenCalledTimes(3); + }); + + test('should filter empty lines from multiline input', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation1>output1\n\nnotation2>output2\n \nnotation3>output3'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(getValue).toHaveBeenCalledTimes(3); + }); + + test('should handle empty secrets input', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = ''; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + + require('../index'); + await waitForAsync(); + expect(getValue).not.toHaveBeenCalled(); + }); + }); + + describe('runPlugin - File Handling', () => { + test('should create /app directory', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output'; + const mockStorage = {}; + localConfigStorage.mockReturnValue(mockStorage); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + // Verify the async chain executed + expect(initializeStorage).toHaveBeenCalled(); + expect(getSecrets).toHaveBeenCalled(); + // Check that mkdirSync was called for /app (first call in runPlugin) + expect(fs.mkdirSync).toHaveBeenCalledWith('/app', { recursive: true }); + }); + + test('should handle file destination type', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('file-content'); + + require('../index'); + await waitForAsync(); + expect(path.resolve).toHaveBeenCalledWith('/path/to/file.txt'); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should create directory for file destination', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('file-content'); + path.dirname.mockReturnValue('/path/to'); + + require('../index'); + await waitForAsync(); + expect(fs.mkdirSync).toHaveBeenCalledWith('/path/to', { recursive: true }); + }); + + test('should handle file secret with fileId', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue({ fileId: 'file123' }); + downloadFile.mockResolvedValue(Buffer.from('file-data')); + path.dirname.mockReturnValue('/path/to'); + + require('../index'); + await waitForAsync(); + expect(downloadFile).toHaveBeenCalledWith({ fileId: 'file123' }); + }); + + test('should handle file secret with fileUid', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue({ fileUid: 'file123' }); + downloadFile.mockResolvedValue(new Uint8Array([1, 2, 3])); + path.dirname.mockReturnValue('/path/to'); + + require('../index'); + await waitForAsync(); + expect(downloadFile).toHaveBeenCalled(); + }); + + test('should handle file secret with url', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue({ url: 'https://example.com/file' }); + downloadFile.mockResolvedValue(Buffer.from('file-data')); + path.dirname.mockReturnValue('/path/to'); + + require('../index'); + await waitForAsync(); + expect(downloadFile).toHaveBeenCalled(); + }); + + test('should handle file secret with /file/ notation', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'record/field/file/file.txt>file:/path/to/file.txt'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + // Return an object so isFileReference check passes + // The notation includes '/file/' which will make notationIsFile true + getValue.mockReturnValue({ someProperty: 'value' }); + downloadFile.mockResolvedValue(Buffer.from('file-data')); + path.dirname.mockReturnValue('/path/to'); + + require('../index'); + await waitForAsync(); + expect(downloadFile).toHaveBeenCalled(); + }); + + test('should handle downloadFile error gracefully', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue({ fileId: 'file123' }); + downloadFile.mockRejectedValue(new Error('Download failed')); + path.dirname.mockReturnValue('/path/to'); + + require('../index'); + await waitForAsync(); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Failed to download file')); + }); + + test('should handle Uint8Array file data', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue({ fileId: 'file123' }); + const uint8Array = new Uint8Array([1, 2, 3, 4]); + downloadFile.mockResolvedValue(uint8Array); + path.dirname.mockReturnValue('/path/to'); + + require('../index'); + await waitForAsync(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should handle Buffer file data', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue({ fileId: 'file123' }); + downloadFile.mockResolvedValue(Buffer.from('buffer-data')); + path.dirname.mockReturnValue('/path/to'); + + require('../index'); + await waitForAsync(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should handle other file data types', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue({ fileId: 'file123' }); + // Return something that's not Uint8Array or Buffer to test the third branch + downloadFile.mockResolvedValue('string-data'); + path.dirname.mockReturnValue('/path/to'); + + require('../index'); + await waitForAsync(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should handle file data that is neither Uint8Array nor Buffer (line 135 branch)', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue({ fileId: 'file123' }); + // Return something that's not Uint8Array or Buffer to test the third branch of ternary + // Use an array to ensure it's not Uint8Array (regular array) + downloadFile.mockResolvedValue([1, 2, 3]); + path.dirname.mockReturnValue('/path/to'); + path.resolve.mockReturnValue('/path/to/file.txt'); + + require('../index'); + await waitForAsync(); + expect(downloadFile).toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should handle file data that is Buffer (line 134 branch)', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue({ fileId: 'file123' }); + // Return a Buffer (not Uint8Array) to test the Buffer.isBuffer branch + // Note: In Node.js, Buffer IS a Uint8Array, so this tests the first branch + const bufferData = Buffer.from('buffer-content'); + downloadFile.mockResolvedValue(bufferData); + path.dirname.mockReturnValue('/path/to'); + path.resolve.mockReturnValue('/resolved/path/to/file.txt'); + + require('../index'); + await waitForAsync(); + expect(downloadFile).toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalledWith(expect.stringContaining('file.txt'), bufferData); + }); + + test('should handle file data that is plain Uint8Array (not Buffer)', async () => { + // Test the second branch: Buffer.isBuffer is false but instanceof Uint8Array is true + // Use a plain Uint8Array (not a Buffer) to test the middle branch of the ternary + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue({ fileId: 'file123' }); + + // Create a plain Uint8Array (not a Buffer) + // Buffer.isBuffer will return false, but instanceof Uint8Array will be true + const uint8Array = new Uint8Array([1, 2, 3, 4, 5]); + downloadFile.mockResolvedValue(uint8Array); + path.dirname.mockReturnValue('/path/to'); + path.resolve.mockReturnValue('/resolved/path/to/file.txt'); + + require('../index'); + await waitForAsync(); + expect(downloadFile).toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalled(); + // Should convert Uint8Array to Buffer + const writeCall = fs.writeFileSync.mock.calls.find(call => call[0].includes('file.txt')); + expect(writeCall).toBeDefined(); + }); + }); + + describe('runPlugin - Secret Value Handling', () => { + test('should handle string secret value', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output_var'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('string-secret'); + + require('../index'); + await waitForAsync(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should handle Buffer secret value', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output_var'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue(Buffer.from('buffer-secret')); + + require('../index'); + await waitForAsync(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should handle non-string, non-buffer secret value', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output_var'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue(12345); + + require('../index'); + await waitForAsync(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should skip secret when value is not found', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output_var'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue(null); + + require('../index'); + await waitForAsync(); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('::warning::')); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Value not found')); + }); + + test('should skip secret when value is undefined', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output_var'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue(undefined); + + require('../index'); + await waitForAsync(); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('::warning::')); + }); + }); + + describe('runPlugin - Destination Types', () => { + test('should handle environment destination type', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>env:VAR_NAME'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('env-value'); + + require('../index'); + await waitForAsync(); + expect(mockConsoleLog).toHaveBeenCalledWith("ENV:VAR_NAME='env-value'"); + expect(fs.mkdirSync).toHaveBeenCalledWith('/harness/secrets', { recursive: true }); + expect(fs.writeFileSync).toHaveBeenCalled(); + expect(fs.chmodSync).toHaveBeenCalled(); + }); + + test('should handle environment destination with Buffer data', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>env:VAR_NAME'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue(Buffer.from('buffer-env-value')); + + require('../index'); + await waitForAsync(); + expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining("ENV:VAR_NAME")); + }); + + test('should handle environment destination with non-Buffer data (line 157 branch)', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>env:VAR_NAME'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + // Return a number to test the String(data) branch + getValue.mockReturnValue(12345); + + require('../index'); + await waitForAsync(); + expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining("ENV:VAR_NAME")); + expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining("12345")); + }); + + + test('should create /harness/secrets directory for non-file destinations', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output_var'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(fs.mkdirSync).toHaveBeenCalledWith('/harness/secrets', { recursive: true }); + }); + + test('should set file permissions to 0o600 for secret files', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output_var'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(fs.chmodSync).toHaveBeenCalledWith('/harness/secrets/output_var', 0o600); + }); + + test('should not output ENV: for non-environment destinations', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output_var'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(mockConsoleLog).not.toHaveBeenCalledWith(expect.stringContaining('ENV:')); + }); + }); + + describe('runPlugin - Error Handling', () => { + test('should handle storage initialization error', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockRejectedValue(new Error('Storage initialization failed')); + + require('../index'); + await waitForAsync(); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Failed:')); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + test('should handle token-related error with specific message', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockRejectedValue(new Error('Invalid token')); + + require('../index'); + await waitForAsync(); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('One-time access tokens')); + }); + + test('should handle expired token error', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockRejectedValue(new Error('Token expired')); + + require('../index'); + await waitForAsync(); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('One-time access tokens')); + }); + + test('should handle invalid token error', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockRejectedValue(new Error('invalid')); + + require('../index'); + await waitForAsync(); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('One-time access tokens')); + }); + + test('should handle getSecrets error', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockRejectedValue(new Error('Failed to get secrets')); + + require('../index'); + await waitForAsync(); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Failed:')); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + test('should handle generic error without token message', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockRejectedValue(new Error('Generic error')); + + require('../index'); + await waitForAsync(); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Failed:')); + expect(mockStderrWrite).not.toHaveBeenCalledWith(expect.stringContaining('One-time access tokens')); + }); + }); + + describe('Edge Cases and Integration', () => { + test('should handle null secret object', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue(null); + + require('../index'); + await waitForAsync(); + expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('::warning::')); + }); + + test('should handle object secret without file properties', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue({ someProperty: 'value' }); + + require('../index'); + await waitForAsync(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should handle object secret without file properties and notation without /file/', async () => { + // Test the branch where typeof secret === 'object' && secret !== null is true + // but (secret.fileId || secret.fileUid || secret.url || notationIsFile) is false + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'regular/notation>output'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + // Return an object without fileId, fileUid, url, and notation doesn't include '/file/' + getValue.mockReturnValue({ regularProperty: 'value', anotherProp: 123 }); + + require('../index'); + await waitForAsync(); + // Should treat it as a regular secret, not a file reference + expect(downloadFile).not.toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should handle complex multiline input with mixed types', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation1>env:VAR1\nnotation2>file:/path/file\nnotation3>output1\nnotation4>env:VAR2'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue('secret-value'); + + require('../index'); + await waitForAsync(); + expect(getValue).toHaveBeenCalledTimes(4); + }); + + test('should handle file notation with multiple file references', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'record1/field/file/file1.txt>file:/path/file1\nrecord2/field/file/file2.txt>file:/path/file2'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + getValue.mockReturnValue({ fileId: 'file123' }); + downloadFile.mockResolvedValue(Buffer.from('file-data')); + path.dirname.mockReturnValue('/path'); + + require('../index'); + await waitForAsync(); + expect(downloadFile).toHaveBeenCalledTimes(2); + }); + }); +}); From eff136970ab318ad1e574643c95c0ae289c9b2c0 Mon Sep 17 00:00:00 2001 From: Hitesh Borase Date: Thu, 15 Jan 2026 18:05:56 +0530 Subject: [PATCH 04/11] updated readme file --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index d757237..8cc694f 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,6 @@ pipeline: secrets: | VeYTRo-PHElAwfQT6f0TIA/field/password > DB_PASSWORD VeYTRo-PHElAwfQT6f0TIA/field/login > DB_USERNAME - VeYTRo-PHElAwfQT6f0TIA/custom_field/text > KEEPER_TEXT VeYTRo-PHElAwfQT6f0TIA/file/credentials.txt > FILE_DATA envVariables: KSM_CONFIG: <+secrets.getValue("Test_File_secret")> @@ -130,7 +129,6 @@ Keeper Notation queries mapping secrets to destinations: secrets: | VeYTRo-PHElAwfQT6f0TIA/field/password > DB_PASSWORD VeYTRo-PHElAwfQT6f0TIA/field/login > DB_USERNAME - VeYTRo-PHElAwfQT6f0TIA/custom_field/text > KEEPER_TEXT VeYTRo-PHElAwfQT6f0TIA/file/credentials.txt > FILE_DATA ``` From 973e6898c707ed56c7c6771ab7dda4f590e11f1a Mon Sep 17 00:00:00 2001 From: Hitesh Borase Date: Tue, 20 Jan 2026 12:04:42 +0530 Subject: [PATCH 05/11] readme file updated --- README.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/README.md b/README.md index 8cc694f..0b4e727 100644 --- a/README.md +++ b/README.md @@ -31,20 +31,6 @@ All secrets are: - **Automatically cleaned up**: Harness CI removes all secrets after pipeline completion - **Securely stored**: Files have restricted permissions (600 - owner read/write only) -### Project Structure - -``` -harness_keeper_plugin/ -├── src/ -│ └── index.js # Main plugin logic -├── entrypoint.sh # Container entrypoint script -├── Dockerfile # Docker image definition -├── package.json # Node.js dependencies -├── Example/ -│ └── Pipeline.yaml # Example Harness CI pipeline -└── README.md # This file -``` - ## Quick Start ```yaml From 8cc1496644691872f2d7e35b18da57d617cb973c Mon Sep 17 00:00:00 2001 From: Hitesh Borase Date: Tue, 20 Jan 2026 14:48:25 +0530 Subject: [PATCH 06/11] created github actions workflow --- .github/workflows/publish.vscode.yml | 285 +++++++++++++++++++++++++++ eslint.config.js | 43 ++++ package.json | 7 +- src/index.js | 10 +- src/test/index.test.js | 10 +- 5 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/publish.vscode.yml create mode 100644 eslint.config.js diff --git a/.github/workflows/publish.vscode.yml b/.github/workflows/publish.vscode.yml new file mode 100644 index 0000000..5ac191b --- /dev/null +++ b/.github/workflows/publish.vscode.yml @@ -0,0 +1,285 @@ +# Harness CI Keeper Plugin - Release Workflow +# +# HOW TO PUBLISH A NEW RELEASE: +# 1. Update the version in package.json (e.g., from 1.0.0 to 1.0.1) +# 2. Commit your changes: git commit -am "Bump version to 1.0.1" +# 3. Create a git tag matching the version: git tag v1.0.1 +# 4. Push the tag to GitHub: git push origin v1.0.1 +# 5. The workflow will automatically: +# - Run linting +# - Run tests +# - Build the Docker image +# - Generate SBOM +# - Create a GitHub release +# +# Note: This workflow triggers on version tags (v*.*.*) or manual workflow dispatch + +name: Publish Harness CI Keeper Plugin + +on: + workflow_dispatch: + push: + tags: + - 'v*.*.*' # Triggers on version tags like v1.0.0, v1.0.1, etc. + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linting + run: npm run lint + + - name: Run tests + run: npm run test + + build: + runs-on: ubuntu-latest + needs: [test] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Get version + id: get_version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT + echo "Version: ${VERSION}" + + - name: Log in to Docker Hub (if credentials provided) + if: ${{ secrets.DOCKERHUB_USERNAME }} + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set Docker image tags + id: docker_tags + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + if [ -n "${DOCKERHUB_USERNAME}" ]; then + IMAGE_TAG="${DOCKERHUB_USERNAME}/harness-keeper-plugin:${{ steps.get_version.outputs.version }}" + LATEST_TAG="${DOCKERHUB_USERNAME}/harness-keeper-plugin:latest" + else + IMAGE_TAG="harness-keeper-plugin:${{ steps.get_version.outputs.version }}" + LATEST_TAG="harness-keeper-plugin:latest" + fi + echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_OUTPUT + echo "LATEST_TAG=${LATEST_TAG}" >> $GITHUB_OUTPUT + echo "Image tag: ${IMAGE_TAG}" + echo "Latest tag: ${LATEST_TAG}" + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/') }} + tags: | + ${{ steps.docker_tags.outputs.IMAGE_TAG }} + ${{ steps.docker_tags.outputs.LATEST_TAG }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: harness-keeper-plugin-build + path: | + Dockerfile + package.json + retention-days: 30 + + generate-sbom: + runs-on: ubuntu-latest + needs: build + + steps: + - name: Get the source code + uses: actions/checkout@v4 + + - name: Install Syft + 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 + echo "/tmp/bin" >> $GITHUB_PATH + + - name: Install Manifest CLI + run: | + 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: Create Syft configuration + run: | + cat > syft-config.yaml << 'EOF' + package: + search: + scope: all-layers + cataloger: + enabled: true + java: + enabled: false + python: + enabled: false + nodejs: + enabled: true + EOF + + - name: Generate and upload SBOM + env: + MANIFEST_API_KEY: ${{ secrets.MANIFEST_TOKEN }} + run: | + # Get version from package.json + echo "Detecting Harness CI Keeper Plugin version..." + if [ -f "package.json" ]; then + VERSION=$(grep -o '"version": "[^"]*"' "package.json" | cut -d'"' -f4) + echo "Detected version: ${VERSION}" + else + VERSION="1.0.0" + echo "Could not detect version, using default: ${VERSION}" + fi + + echo "Generating SBOM with Manifest CLI..." + /tmp/bin/manifest sbom "." \ + --generator=syft \ + --name=harness-keeper-plugin \ + --version=${VERSION} \ + --output=spdx-json \ + --file=harness-keeper-plugin-sbom.json \ + --api-key=${MANIFEST_API_KEY} \ + --publish=true \ + --asset-label=application,sbom-generated,nodejs,harness-plugin,docker \ + --generator-config=syft-config.yaml + + echo "SBOM generated and uploaded successfully: harness-keeper-plugin-sbom.json" + echo "---------- SBOM Preview (first 20 lines) ----------" + head -n 20 harness-keeper-plugin-sbom.json + + # Docker registry publish job - reserved for future use + # publish-docker-registry: + # runs-on: ubuntu-latest + # environment: prod + # needs: [test, build, generate-sbom] + # + # steps: + # - name: Checkout code + # uses: actions/checkout@v4 + # + # - name: Set up Docker Buildx + # uses: docker/setup-buildx-action@v3 + # + # - name: Get version + # id: get_version + # run: | + # VERSION=$(node -p "require('./package.json').version") + # echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT + # + # - name: Log in to Docker Registry + # uses: docker/login-action@v3 + # with: + # registry: ${{ secrets.DOCKER_REGISTRY }} + # username: ${{ secrets.DOCKER_USERNAME }} + # password: ${{ secrets.DOCKER_PASSWORD }} + # + # - name: Build and push Docker image + # uses: docker/build-push-action@v5 + # with: + # context: . + # push: true + # tags: | + # ${{ secrets.DOCKER_REGISTRY }}/harness-keeper-plugin:${{ steps.get_version.outputs.version }} + # ${{ secrets.DOCKER_REGISTRY }}/harness-keeper-plugin:latest + + create-release: + runs-on: ubuntu-latest + needs: [test, build, generate-sbom] + # Only run when triggered by a tag push + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Get version + id: get_version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT + echo "Version: ${VERSION}" + + - name: Set Docker image name for release + id: docker_image + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + if [ -n "${DOCKERHUB_USERNAME}" ]; then + IMAGE_NAME="${DOCKERHUB_USERNAME}/harness-keeper-plugin:${{ steps.get_version.outputs.version }}" + else + IMAGE_NAME="harness-keeper-plugin:${{ steps.get_version.outputs.version }}" + fi + echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + body: | + ## Harness CI Keeper Plugin ${{ github.ref_name }} + + ### Installation + + #### Docker Image: + The Docker image for this release is available at: + ``` + ${{ steps.docker_image.outputs.IMAGE_NAME }} + ``` + + #### Usage in Harness CI: + ```yaml + - step: + type: Plugin + name: Fetch_Keeper_Secrets + identifier: Fetch_Keeper_Secrets + spec: + image: ${{ steps.docker_image.outputs.IMAGE_NAME }} + settings: + secrets: | + VeYTRo-PHElAwfQT6f0TIA/field/password > DB_PASSWORD + VeYTRo-PHElAwfQT6f0TIA/field/login > DB_USERNAME + envVariables: + KSM_CONFIG: <+secrets.getValue("Keeper_Config_Secret")> + ``` + + ### What's Changed + See the [full changelog](https://github.com/${{ github.repository }}/compare/previous-tag...${{ github.ref_name }}) + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..e4d90b1 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,43 @@ +const js = require('@eslint/js'); +const globals = require('globals'); + +module.exports = [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2021, + sourceType: 'script', + globals: { + ...globals.node + } + }, + rules: { + 'no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_' + }], + 'no-console': 'off', + 'indent': ['error', 4], + 'quotes': ['error', 'single'], + 'semi': ['error', 'always'] + } + }, + { + // Jest test files configuration + files: ['**/*.test.js', '**/__tests__/**/*.js'], + languageOptions: { + globals: { + ...globals.jest + } + } + }, + { + ignores: [ + 'node_modules/**', + '*.vsix', + 'dist/**', + 'build/**', + '.git/**' + ] + } +]; diff --git a/package.json b/package.json index bae7134..82832b2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "main": "index.js", "scripts": { "test": "jest --coverage", - "test:watch": "jest --watch" + "test:watch": "jest --watch", + "lint": "eslint .", + "lint:fix": "eslint . --fix" }, "keywords": [], "author": "", @@ -16,6 +18,9 @@ "@keeper-security/secrets-manager-core": "^17.3.0" }, "devDependencies": { + "@eslint/js": "^9.39.2", + "eslint": "^9.39.2", + "globals": "^17.0.0", "jest": "^29.7.0" } } diff --git a/src/index.js b/src/index.js index 3af32c4..f96e813 100644 --- a/src/index.js +++ b/src/index.js @@ -46,7 +46,7 @@ const processToken = (rawToken) => { token = Buffer.from(token, 'base64').toString('utf-8').trim(); } catch (e) { // Not base64, use as-is - console.log(e) + console.log(e); } } @@ -132,16 +132,16 @@ const runPlugin = async () => { try { const fileData = await downloadFile(secret); data = Buffer.isBuffer(fileData) ? fileData : - fileData instanceof Uint8Array ? Buffer.from(fileData) : - Buffer.from(fileData); + fileData instanceof Uint8Array ? Buffer.from(fileData) : + Buffer.from(fileData); } catch (downloadError) { core.error(`Failed to download file for notation ${input.notation}: ${downloadError.message}`); continue; } } else { data = Buffer.isBuffer(secret) ? secret : - typeof secret === 'string' ? Buffer.from(secret, 'utf8') : - Buffer.from(String(secret), 'utf8'); + typeof secret === 'string' ? Buffer.from(secret, 'utf8') : + Buffer.from(String(secret), 'utf8'); } if (input.destinationType === 'file') { diff --git a/src/test/index.test.js b/src/test/index.test.js index f6fc89c..861c357 100644 --- a/src/test/index.test.js +++ b/src/test/index.test.js @@ -296,7 +296,7 @@ describe('index.js - Complete Test Suite', () => { require('../index'); await waitForAsync(); - expect(mockConsoleLog).toHaveBeenCalledWith("ENV:VAR_NAME='secret-value'"); + expect(mockConsoleLog).toHaveBeenCalledWith('ENV:VAR_NAME=\'secret-value\''); }); test('should parse file: destination type', async () => { @@ -705,7 +705,7 @@ describe('index.js - Complete Test Suite', () => { require('../index'); await waitForAsync(); - expect(mockConsoleLog).toHaveBeenCalledWith("ENV:VAR_NAME='env-value'"); + expect(mockConsoleLog).toHaveBeenCalledWith('ENV:VAR_NAME=\'env-value\''); expect(fs.mkdirSync).toHaveBeenCalledWith('/harness/secrets', { recursive: true }); expect(fs.writeFileSync).toHaveBeenCalled(); expect(fs.chmodSync).toHaveBeenCalled(); @@ -721,7 +721,7 @@ describe('index.js - Complete Test Suite', () => { require('../index'); await waitForAsync(); - expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining("ENV:VAR_NAME")); + expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('ENV:VAR_NAME')); }); test('should handle environment destination with non-Buffer data (line 157 branch)', async () => { @@ -735,8 +735,8 @@ describe('index.js - Complete Test Suite', () => { require('../index'); await waitForAsync(); - expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining("ENV:VAR_NAME")); - expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining("12345")); + expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('ENV:VAR_NAME')); + expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('12345')); }); From 800a71eb9fdde2e8e1504668e73a0dee85488097 Mon Sep 17 00:00:00 2001 From: Hitesh Borase Date: Tue, 20 Jan 2026 15:20:46 +0530 Subject: [PATCH 07/11] github workflow file bug fixed --- .github/workflows/publish.vscode.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.vscode.yml b/.github/workflows/publish.vscode.yml index 5ac191b..43030bd 100644 --- a/.github/workflows/publish.vscode.yml +++ b/.github/workflows/publish.vscode.yml @@ -66,8 +66,19 @@ jobs: echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT echo "Version: ${VERSION}" + - name: Check for Docker Hub credentials + id: docker_creds + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + if [ -n "${DOCKERHUB_USERNAME}" ]; then + echo "HAS_CREDENTIALS=true" >> $GITHUB_OUTPUT + else + echo "HAS_CREDENTIALS=false" >> $GITHUB_OUTPUT + fi + - name: Log in to Docker Hub (if credentials provided) - if: ${{ secrets.DOCKERHUB_USERNAME }} + if: steps.docker_creds.outputs.HAS_CREDENTIALS == 'true' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} From 565d20d9e31e80577cbd09df4e729143181dd88f Mon Sep 17 00:00:00 2001 From: Hitesh Borase Date: Tue, 20 Jan 2026 17:15:38 +0530 Subject: [PATCH 08/11] fixed git workflow file --- .github/workflows/{publish.vscode.yml => harnessKeeperCI.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{publish.vscode.yml => harnessKeeperCI.yml} (100%) diff --git a/.github/workflows/publish.vscode.yml b/.github/workflows/harnessKeeperCI.yml similarity index 100% rename from .github/workflows/publish.vscode.yml rename to .github/workflows/harnessKeeperCI.yml From f295596ffccc4fb1996aab1b59471a288df599e9 Mon Sep 17 00:00:00 2001 From: Hitesh Borase Date: Wed, 21 Jan 2026 12:21:18 +0530 Subject: [PATCH 09/11] git gitHub actions release workflow file updated --- .github/workflows/harnessKeeperCI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/harnessKeeperCI.yml b/.github/workflows/harnessKeeperCI.yml index 43030bd..ac15c99 100644 --- a/.github/workflows/harnessKeeperCI.yml +++ b/.github/workflows/harnessKeeperCI.yml @@ -20,7 +20,7 @@ on: workflow_dispatch: push: tags: - - 'v*.*.*' # Triggers on version tags like v1.0.0, v1.0.1, etc. + - 'v1.0.0' # Triggers on version tags like v1.0.0, v1.0.1, etc. jobs: test: From 4055fc902c12e09c623b31a1197621836437d96a Mon Sep 17 00:00:00 2001 From: Hitesh Borase Date: Wed, 21 Jan 2026 14:10:35 +0530 Subject: [PATCH 10/11] git workflow file name updated --- .github/workflows/{harnessKeeperCI.yml => publish.harness.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{harnessKeeperCI.yml => publish.harness.yml} (100%) diff --git a/.github/workflows/harnessKeeperCI.yml b/.github/workflows/publish.harness.yml similarity index 100% rename from .github/workflows/harnessKeeperCI.yml rename to .github/workflows/publish.harness.yml From 8736f4f364983d34e5f608628c6be562abdbe1af Mon Sep 17 00:00:00 2001 From: Hitesh Borase Date: Fri, 23 Jan 2026 21:15:54 +0530 Subject: [PATCH 11/11] secrets manager core module version updated --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 82832b2..5150870 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "license": "ISC", "type": "commonjs", "dependencies": { - "@keeper-security/secrets-manager-core": "^17.3.0" + "@keeper-security/secrets-manager-core": "^17.4.0" }, "devDependencies": { "@eslint/js": "^9.39.2",