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/.github/workflows/publish.harness.yml b/.github/workflows/publish.harness.yml new file mode 100644 index 0000000..ac15c99 --- /dev/null +++ b/.github/workflows/publish.harness.yml @@ -0,0 +1,296 @@ +# 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: + - 'v1.0.0' # 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: 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: steps.docker_creds.outputs.HAS_CREDENTIALS == 'true' + 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/.gitignore b/.gitignore new file mode 100644 index 0000000..ccb2c80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json \ 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..0b4e727 100644 --- a/README.md +++ b/README.md @@ -1 +1,219 @@ -# harness-integration +# Harness CI Keeper Plugin + +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. + +## Features + +- 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. + +## Prerequisites + +- 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) + +## About + +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. + +**Important:** Secrets are stored in `/harness/secrets/` directory (Harness shared workspace volume), not as Harness environment variables. + +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) + +## Quick Start + +```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: dhborse/keeper-harness-plugin + settings: + secrets: | + VeYTRo-PHElAwfQT6f0TIA/field/password > DB_PASSWORD + VeYTRo-PHElAwfQT6f0TIA/field/login > DB_USERNAME + VeYTRo-PHElAwfQT6f0TIA/file/credentials.txt > FILE_DATA + envVariables: + KSM_CONFIG: <+secrets.getValue("Test_File_secret")> + - step: + type: Run + name: Use_Keeper_Secrets + identifier: Use_Keeper_Secrets + spec: + image: alpine:3.20 + shell: Sh + command: | + 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 +``` + +## Inputs + +### KSM_CONFIG + +Keeper Secrets Manager configuration for authentication. Store in Harness secrets and reference: + +```yaml +envVariables: + KSM_CONFIG: <+secrets.getValue("Keeper_Config_Secret")> +``` + +**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/file/credentials.txt > FILE_DATA +``` + +## Keeper Notation Format + +The plugin supports three types of Keeper Notation queries: + +| 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` | + +**Note:** Record UID is the unique identifier for a secret record in Keeper. Get it from Keeper Vault → Record details → Record UID. + +## Destination Format + +The destination defines where the secret is stored: + +| Format | Description | Output Location | +|--------|-------------|----------------| +| `VARIABLE_NAME` | Default output (recommended) | `/harness/secrets/VARIABLE_NAME` | + +**Examples:** +```yaml +# Default: saves to /harness/secrets/DB_PASSWORD +VeYTRo-PHElAwfQT6f0TIA/field/password > DB_PASSWORD +``` + +## Accessing Secrets + +Secrets are stored in `/harness/secrets/` directory. Read them in subsequent steps: + +```yaml +- step: + type: Run + name: Use_Secrets + spec: + image: alpine:3.20 + shell: Sh + command: | + DB_USERNAME=$(cat /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 including binary files +- Supports long values and special characters + +## Secret Storage + +- **Location:** `/harness/secrets/` directory (Harness shared workspace volume) +- **Permissions:** `600` (owner read/write only) +- **Scope:** Pipeline execution only - automatically cleaned up after completion +- **Access:** Read files directly using `cat` or file operations + +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**: Secrets retrieved directly from Keeper Vault at runtime +- **No Decrypted Storage**: Secrets never pass through Harness systems in decrypted form +- **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 +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 + +| 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 + +- **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 + +## 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/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..835164e --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,69 @@ +#!/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 +# SECURITY NOTE: All secrets are written to /harness/outputs/ and /harness/secrets/ +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) + 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) + echo -n "$value" > "/harness/secrets/${name}" + chmod 600 "/harness/secrets/${name}" # Restrict permissions to owner only + +done < "$SECRETS_FILE" + +# 4. Secure Clean up +rm -f "$SECRETS_FILE" + +# 5. Hand over control to the Docker command (if any) +exec "$@" \ No newline at end of file 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/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.json b/package.json new file mode 100644 index 0000000..5150870 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "harness_keeper_plugin", + "version": "1.0.0", + "private": true, + "description": "", + "main": "index.js", + "scripts": { + "test": "jest --coverage", + "test:watch": "jest --watch", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "@keeper-security/secrets-manager-core": "^17.4.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 new file mode 100644 index 0000000..f96e813 --- /dev/null +++ b/src/index.js @@ -0,0 +1,172 @@ +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 { + core.info('Starting Keeper Secrets Manager plugin'); + 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; + } + + 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 = 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; + } + } 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 }); + fs.writeFileSync(fullPath, data); + } else { + 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 = data.toString('utf8'); + console.log(`ENV:${input.destination}='${outputValue}'`); + } + } + } + } 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(); \ No newline at end of file diff --git a/src/test/index.test.js b/src/test/index.test.js new file mode 100644 index 0000000..861c357 --- /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); + }); + }); +});