mirror of
https://github.com/Keeper-Security/harness-integration.git
synced 2026-06-04 10:14:56 +08:00
Merge pull request #1 from Keeper-Security/dev
Implemented Harness CI Keeper Plugin
This commit is contained in:
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
+23
@@ -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"]
|
||||
@@ -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:** `<keeper-notation> > <destination>`
|
||||
|
||||
**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** | `<record-uid>/field/<field-type>` | `VeYTRo.../field/password` |
|
||||
| **Custom Fields** | `<record-uid>/custom_field/<field-label>` | `VeYTRo.../custom_field/API_Key` |
|
||||
| **File Attachments** | `<record-uid>/file/<file-name>` | `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)
|
||||
Executable
+69
@@ -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 "$@"
|
||||
@@ -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/**'
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+172
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user