diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40b878d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a73b9a5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# 1. Use a slim Node.js image to keep the plugin fast and small +# Note: As of January, 2026, when building this image, it uses Node.js version 25.2.1 +# The 'node:slim' tag will automatically use the latest Node.js version every time the image is built +FROM node:slim + +# 2. Set the working directory inside the container +WORKDIR /app + +# 3. Copy package files first (to leverage Docker caching for faster builds) +COPY package*.json ./ + +# 4. Install production dependencies only +RUN npm install --omit=dev + +# 5. Copy the rest of your source code +COPY . . + +# 6. Ensure the entrypoint script is executable (important for Linux runners) +RUN chmod +x /app/entrypoint.sh + +# 7. Set the Entrypoint +# This tells Docker to run the shell script first, which then calls your Node logic +ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md index e3e06ff..0c7d841 100644 --- a/README.md +++ b/README.md @@ -1 +1,480 @@ -# harness-integration +# Harness Keeper Security Plugin + +A Harness CI/CD plugin that integrates with Keeper Security Secrets Manager (KSM) to securely fetch and inject secrets into your CI/CD pipelines at runtime. + +## Overview + +This plugin securely retrieves secrets from Keeper Security Secrets Manager and makes them available to subsequent steps in your Harness pipeline. The plugin implements a zero-knowledge architecture where secrets are retrieved directly from Keeper Vault at runtime and never pass through Harness systems in decrypted form. + +**Key Features:** +- **Zero-Knowledge Architecture**: Secrets are retrieved directly from Keeper Vault at runtime +- **Multiple Authentication Methods**: Supports one-time access tokens (`US:...`) and full JSON config +- **Keeper Notation Support**: Query fields (`field`), custom fields (`custom_field`), and file attachments (`file`) +- **Multiple Output Formats**: Environment variables, file output, and output variables +- **Secure Storage**: Secrets written to `/harness/secrets/` with secure file permissions +- **Automatic Validation**: Validates token format and required config fields +- **Base64 Decoding**: Automatically decodes base64-encoded tokens + +## Architecture + +### Zero-Knowledge Design + +The plugin ensures zero-knowledge security through the following design: + +1. **Harness CI/CD** calls the custom Docker-based plugin +2. **Plugin authenticates** using KSM configuration stored in Harness Secrets Manager +3. **Secrets are retrieved** directly from Keeper Vault at runtime +4. **Decrypted secrets** are returned as step outputs for downstream stages +5. **No external storage**: Secrets never pass through Harness systems in decrypted form + +All secret flow happens strictly between the plugin container and Keeper, while Harness provides orchestration, governance, and secure secret injection across the pipeline. + +### Components + +1. **`src/index.js`**: Main plugin logic + - Token processing and validation + - Keeper Secrets Manager integration + - Secret fetching using Keeper Notation + - Secret distribution + +2. **`entrypoint.sh`**: Container entrypoint script + - Executes the Node.js plugin + - Captures and processes plugin output + - Writes secrets to Harness directories + - Manages security and cleanup + +3. **`Example/Pipeline.yaml`**: Example Harness CI pipeline + - Demonstrates plugin configuration + - Shows secret consumption patterns + +### Execution Flow + +``` +1. Container starts → entrypoint.sh executes +2. entrypoint.sh → Runs: node /app/src/index.js +3. Plugin processes: + - Reads KSM_CONFIG from environment + - Validates and decodes token (base64 if needed) + - Initializes Keeper storage + - Fetches secrets from Keeper using Keeper Notation + - Outputs secrets to stdout (with ENV:/OUT: prefixes) +4. entrypoint.sh captures stdout: + - Parses ENV:/OUT: prefixes + - Writes to /harness/outputs/outputs.txt (output variables) + - Writes to /harness/secrets/ (file-based access) + - Sets secure permissions (chmod 600) +5. Subsequent steps access secrets via: + - Files: /harness/secrets/VARIABLE_NAME (recommended) +``` + +## Prerequisites + +- **Keeper Secrets Manager access** ([Quick Start Guide](https://docs.keeper.io/secrets-manager/secrets-manager/quick-start-guide)) +- **Secrets Manager addon enabled** for your Keeper account +- **Membership in a Role** with the Secrets Manager enforcement policy enabled +- **A Keeper Secrets Manager Application** with secrets shared to it +- **An initialized Keeper Secrets Manager Configuration** (JSON or one-time token) +- **Record UID(s)** for the secrets you want to access ⚠️ **REQUIRED** +- **Harness CI/CD account** with a project set up +- **Docker** (for building the plugin image) +- **Node.js 18+** (for local development) + +## Installation + +### Build and Push Docker Image + +Build the plugin Docker image: + +```bash +docker build -t your-dockerhub-username/harness-keeper-plugin:latest . +``` + +Push to your container registry: + +```bash +docker push your-dockerhub-username/harness-keeper-plugin:latest +``` + +**Note:** Replace `your-dockerhub-username` with your actual Docker Hub username or container registry path. + +## Configuration + +### 1. Create Keeper Access Token + +#### Option A: One-Time Access Token +1. Log in to Keeper Security +2. Navigate to Secrets Manager +3. Generate a one-time access token (format: `US:xxxxx`) +4. Base64 encode (optional but recommended): + ```bash + echo -n "US:your-token-here" | base64 + ``` + +#### Option B: Full JSON Config +Create a JSON config with: +```json +{ + "hostname": "keepersecurity.com", + "clientId": "your-client-id", + "privateKey": "your-private-key" +} +``` +Base64 encode: +```bash +echo -n '{"hostname":"...","clientId":"...","privateKey":"..."}' | base64 +``` + +### 2. Create Harness Secret + +In Harness, create a secret: +- **Type**: TEXT or FILE (both work) +- **Name**: `KSM_CONFIG` (or your preferred name) +- **Value**: Your base64-encoded token or JSON config + +### 3. Configure Pipeline + +**Before you begin:** You must have the Record UID(s) for the secrets you want to access. See "How to Get Record UID" section below. + +```yaml +pipeline: + name: harness_keeper_plugin + identifier: harness_keeper_plugin + projectIdentifier: default_project + orgIdentifier: default + stages: + - stage: + name: HkpCI + identifier: HkpCI + type: CI + spec: + cloneCodebase: false + platform: + os: Linux + arch: Amd64 + runtime: + type: Cloud + spec: {} + execution: + steps: + - step: + type: Plugin + name: Fetch_Keeper_Secrets + identifier: Fetch_Keeper_Secrets + spec: + image: your-dockerhub-username/harness-keeper-plugin:latest + settings: + secrets: | + JQPso5SMBpP29gKKGp6Enw/field/password > env:DB_PASSWORD + JQPso5SMBpP29gKKGp6Enw/field/login > env:DB_USERNAME + JQPso5SMBpP29gKKGp6Enw/custom_field/API_Key > env:API_KEY + JQPso5SMBpP29gKKGp6Enw/file/api.key > file:/app/config/api.key + envVariables: + KSM_CONFIG: <+secrets.getValue("KSM_CONFIG")> + - step: + type: Run + name: Use_Keeper_Secrets + identifier: Use_Keeper_Secrets + spec: + image: alpine:3.20 + shell: Sh + command: | + DB_USERNAME=$(cat /harness/secrets/DB_USERNAME) + DB_PASSWORD=$(cat /harness/secrets/DB_PASSWORD) + API_KEY=$(cat /harness/secrets/API_KEY) + echo "Secrets retrieved successfully" + # Use secrets in your build/deploy process +``` + +**Note:** `envVariables` is used ONLY to pass the Keeper configuration token to the plugin. The actual secrets retrieved from Keeper are stored in Harness pipeline volume storage at `/harness/secrets/`. + +## Usage + +### Secret Mapping Format + +``` + > +``` + +### Keeper Notation Format + +The plugin supports three types of Keeper Notation queries: + +1. **Standard Fields**: `/field/` + - Examples: `password`, `login`, `url`, `text`, `host`, etc. + +2. **Custom Fields**: `/custom_field/` + - Examples: `API_Key`, `My Custom Field`, `Environment`, etc. + +3. **File Attachments**: `/file/` + - Examples: `api.key`, `certificate.pem`, `config.json`, etc. + +**Important:** The first part (e.g., `JQPso5SMBpP29gKKGp6Enw`) is the **Record UID**, the unique identifier for a specific secret record in Keeper. + +### Destination Types + +| Destination Prefix | Description | Output Location | +|-------------------|-------------|----------------| +| `env:VARIABLE_NAME` | Environment variable | `/harness/secrets/VARIABLE_NAME` | +| `file:/path/to/file` | File output | Specified file path | +| `VARIABLE_NAME` (default) | Output variable | `/harness/secrets/VARIABLE_NAME` | + +### Examples + +#### 1. Standard Field → Environment Variable +```yaml +secrets: | + JQPso5SMBpP29gKKGp6Enw/field/password > env:DB_PASSWORD + JQPso5SMBpP29gKKGp6Enw/field/login > env:DB_USERNAME +``` +- Creates files: `/harness/secrets/DB_PASSWORD`, `/harness/secrets/DB_USERNAME` +- Access: `DB_PASSWORD=$(cat /harness/secrets/DB_PASSWORD)` + +#### 2. Custom Field → Output Variable +```yaml +secrets: | + JQPso5SMBpP29gKKGp6Enw/custom_field/API_Key > API_KEY + JQPso5SMBpP29gKKGp6Enw/custom_field/Environment > ENV_NAME +``` +- Creates files: `/harness/secrets/API_KEY`, `/harness/secrets/ENV_NAME` +- Access: `API_KEY=$(cat /harness/secrets/API_KEY)` + +#### 3. File Attachment → File Path +```yaml +secrets: | + JQPso5SMBpP29gKKGp6Enw/file/api.key > file:/app/config/api.key + JQPso5SMBpP29gKKGp6Enw/file/certificate.pem > file:/app/ssl/cert.pem +``` +- Downloads files and writes to specified paths +- Access: `cat /app/config/api.key` + +#### 4. Mixed Usage +```yaml +secrets: | + JQPso5SMBpP29gKKGp6Enw/field/password > env:DB_PASSWORD + JQPso5SMBpP29gKKGp6Enw/custom_field/API_Key > API_KEY + JQPso5SMBpP29gKKGp6Enw/file/config.json > file:/app/config.json +``` + +### How to Get Record UID + +The Record UID is **mandatory** and identifies which specific secret record to retrieve. Obtain it using one of these methods: + +**Method 1: From Keeper Web Vault** +1. Log in to your Keeper Vault +2. Open the secret record you want to access +3. Click on the record details/info icon +4. The Record UID is displayed in the record information (usually at the bottom) +5. Copy the Record UID (format: `JQPso5SMBpP29gKKGp6Enw`) + +**Method 2: Using Keeper Commander CLI** +```bash +keeper list +# This will show all records with their UIDs +``` + +**Method 3: Using Keeper Secrets Manager SDK/API** +- Use the Keeper Secrets Manager SDK to list records and retrieve their UIDs +- The Record UID is returned when querying records + +**Method 4: From Keeper Admin Console** +1. Navigate to Secrets Manager in Admin Console +2. View the Application and its shared records +3. Record UIDs are visible in the record list + +**Note:** The one-time access token (`US:...`) or JSON config is used to **authenticate** and access Keeper, but you still need the **Record UID** to specify which secret record to retrieve. + +## Accessing Secrets in Subsequent Steps + +### Method 1: File-Based Access (Recommended) + +The most reliable method for accessing secrets in subsequent steps: + +```yaml +- step: + type: Run + name: Use_Secrets + spec: + image: alpine:3.20 + shell: Sh + command: | + DB_PASSWORD=$(cat /harness/secrets/DB_PASSWORD) + DB_USERNAME=$(cat /harness/secrets/DB_USERNAME) + API_KEY=$(cat /harness/secrets/API_KEY) + # Use secrets in your process + echo "Secrets loaded successfully" + # Clean up after use (optional) + rm -f /harness/secrets/DB_PASSWORD /harness/secrets/DB_USERNAME +``` + +**Benefits:** +- Most reliable method +- No truncation issues +- Works with all secret types +- Supports long values and special characters + +### Method 2: Direct File Access (for file: prefix) + +When using `file:` prefix, files are written directly to the specified path: + +```yaml +- step: + type: Run + name: Use_File + spec: + command: | + # File already at /app/config/api.key + cat /app/config/api.key +``` + +## Secret Storage Mechanism + +**Important:** This plugin uses **Harness pipeline volume storage**, not Harness environment variables. + +- **Storage Location:** `/harness/secrets/` directory (Harness shared workspace volume) +- **Storage Type:** File-based storage on pipeline-scoped volume +- **Access Method:** Read files directly using `cat` or file operations +- **Scope:** Pipeline execution only - automatically cleaned up after completion +- **Permissions:** Files have `600` permissions (owner read/write only) + +The `envVariables` section in the plugin step is **only** used to pass the `KSM_CONFIG` (Keeper configuration token) to the plugin. The actual secrets retrieved from Keeper are written to files in `/harness/secrets/` directory. + +## Security + +### Zero-Knowledge Architecture + +- **Direct Retrieval**: Secrets are retrieved directly from Keeper Vault at runtime +- **No Decrypted Storage**: Secrets never pass through Harness systems in decrypted form +- **Pipeline-Scoped**: All secrets are scoped to the current pipeline execution +- **Automatic Cleanup**: Harness CI automatically cleans up `/harness/outputs/` and `/harness/secrets/` after pipeline completion +- **File Permissions**: Secrets written to `/harness/secrets/` have `600` permissions (owner read/write only) +- **No Persistence**: Secrets are not accessible outside the pipeline execution context + +### Best Practices + +1. **Use one-time access tokens** instead of permanent credentials when possible +2. **Base64 encode tokens** before storing in Harness secrets +3. **Clean up secret files** after use in pipeline steps +4. **Use appropriate secret scope** (project/org/account) +5. **Verify Record UIDs** before configuring pipelines + +## Troubleshooting + +### Token Not Found + +**Error:** `KSM config is required` + +**Solutions:** +- Verify the secret exists in Harness +- Check the secret scope matches your project +- Ensure the expression `<+secrets.getValue("KSM_CONFIG")>` is correct +- Verify secret name matches exactly + +### Invalid Token Format + +**Error:** `Invalid token format - expected US:xxxxx or JSON config` + +**Solutions:** +- Verify the token starts with `US:` (for one-time tokens) +- Check if the token is base64 encoded (it will be auto-decoded) +- For JSON config, ensure it starts with `{` and has required fields: `hostname`, `clientId`, `privateKey` + +### Token Already Used / Expired + +**Error:** `Failed: token ... invalid/expired` + +**Solutions:** +1. Generate a new token in Keeper Security +2. Base64 encode: `echo -n "US:new-token" | base64` +3. Update the Harness secret with the new token +4. Run the pipeline again + +**Note:** One-time access tokens are single-use - generate a new token for each use or use JSON config for reusable credentials. + +### Secret File Not Found + +**Error:** `ERROR: Secret files not found` or `Value not found for notation: /field/` + +**Solutions:** +- **Verify Record UID is correct**: The Record UID must match exactly (case-sensitive) +- **Check Record UID exists**: Ensure the record exists in Keeper and is shared to your Secrets Manager Application +- **Verify access permissions**: The one-time token or JSON config must have access to the record +- **Check field name**: Ensure the field type (`password`, `login`, `text`, etc.) or custom field label exists in the record +- **Verify Application sharing**: The record must be shared to the Secrets Manager Application you're using +- **Check logs**: `ls -la /harness/secrets/` to see what files were created +- **Verify notation format**: Ensure the notation follows the correct format (e.g., `/field/password`, `/custom_field/MyLabel`, `/file/filename`) + +### Expression Not Resolved + +**Error:** Token preview shows `${...}` or `<+...>` unresolved + +**Solutions:** +- Use `envVariables` (not `settings`) for Harness expressions when passing `KSM_CONFIG` to the plugin +- Verify secret name matches exactly +- Check secret scope matches the reference +- Ensure you're using the correct Harness expression syntax: `<+secrets.getValue("SECRET_NAME")>` + +### Harness Expression Not Resolved + +**Error:** `Harness expression not resolved` + +**Solutions:** +- Ensure `KSM_CONFIG` is passed via `envVariables`, not `settings` +- Verify the secret exists and is accessible in the current scope +- Check that the Harness expression syntax is correct: `<+secrets.getValue("SECRET_NAME")>` + +## Local Development + +### Test Locally + +```bash +# Set environment variables +export PLUGIN_SECRETS="JQPso5SMBpP29gKKGp6Enw/field/password > env:DB_PASSWORD" +export KSM_CONFIG="VVM6..." # Your base64 encoded token or JSON config + +# Run the plugin +node ./src/index.js +``` + +### Project Structure + +``` +harness_keeper_plugin/ +├── src/ +│ └── index.js # Main plugin logic +├── entrypoint.sh # Container entrypoint script +├── Dockerfile # Docker image definition +├── package.json # Node.js dependencies +├── Example/ +│ └── Pipeline.yaml # Example Harness CI pipeline +└── README.md # This file +``` + +## Example Pipeline + +See `Example/Pipeline.yaml` for a complete working example that demonstrates: +- Plugin configuration +- Secret mapping with multiple record UIDs +- File-based secret consumption +- Error handling and failure strategies + +## Support + +For issues or questions: +- Check the [Keeper Security Documentation](https://docs.keeper.io/secrets-manager/) +- Review [Keeper Notation Documentation](https://docs.keeper.io/secrets-manager/secrets-manager/keeper-notation/) +- Review Harness CI documentation for plugin configuration +- Verify token format and Keeper record permissions + +## License + +ISC + +## Version History + +- **1.0.0** - Initial release + - Zero-knowledge architecture implementation + - Support for one-time tokens and JSON config + - Keeper Notation support (field, custom_field, file) + - File-based secret access + - Base64 token decoding + - Secure file permissions and cleanup diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..c59e5db --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# 1. Create a temporary file to store the secrets +# Using mktemp ensures the file name is unique and not guessable +SECRETS_FILE=$(mktemp) + +# 2. Run the Node.js plugin +# Redirect STDOUT (secrets) to our file, and let STDERR (logs) flow to the console +node /app/src/index.js > "$SECRETS_FILE" + +# 3. Securely process the secrets +# Parse lines in format: ENV:VAR_NAME='value', OUT:VAR_NAME='value', or VAR_NAME='value' +# +# SECURITY NOTE: All secrets are written to /harness/outputs/ and /harness/secrets/ +# These directories are mounted volumes scoped ONLY to the current pipeline execution. +# Harness CI automatically cleans up these volumes after pipeline completion. +# Secrets are NOT accessible outside the pipeline execution context. +mkdir -p /harness/outputs /harness/secrets + +while IFS= read -r line; do + # Skip empty lines + if [ -z "$line" ]; then + continue + fi + + # Determine type: ENV:, OUT:, or default + if [[ "$line" =~ ^ENV: ]]; then + type="env" + line="${line#ENV:}" + elif [[ "$line" =~ ^OUT: ]]; then + type="out" + line="${line#OUT:}" + else + type="out" # Default to output variable + fi + + # Parse the line: split on first '=' to get name and value + name="${line%%=*}" + value="${line#*=}" + + # Remove surrounding single quotes from value if present + if [[ "$value" =~ ^\'.*\'$ ]]; then + value="${value#\'}" + value="${value%\'}" + fi + + # Skip if name is empty + if [ -z "$name" ]; then + continue + fi + + # Export for the current shell session (plugin container only - not passed to next steps) + export "$name=$value" + + # Write to Harness CI Plugin Output (for output variables) + # These are scoped to the pipeline execution and accessible via: + # <+step.output.outputVariables.VAR_NAME> in subsequent steps + # Format: KEY=VALUE (one per line) + printf "%s=%s\n" "$name" "$value" >> /harness/outputs/outputs.txt + + # For environment variables, also write to env_vars.txt for Harness to pick up + # These are available as output variables and can be referenced in envVariables section + if [ "$type" = "env" ]; then + printf "%s=%s\n" "$name" "$value" >> /harness/outputs/env_vars.txt + fi + + # Write to file for direct access (bypasses Harness truncation) + # SECURITY: Files in /harness/secrets/ are scoped to pipeline execution only + # Harness CI automatically cleans up these files after pipeline completion + echo -n "$value" > "/harness/secrets/${name}" + chmod 600 "/harness/secrets/${name}" # Restrict permissions to owner only + + # Debug: Log (removed to reduce log noise) + # value_length=${#value} + # if [ "$type" = "env" ]; then + # echo "INFO: Set environment variable: $name (length: $value_length)" + # else + # echo "INFO: Set output variable: $name (length: $value_length)" + # fi + # echo "INFO: Secret also written to /harness/secrets/${name} for direct file access" + +done < "$SECRETS_FILE" + +# 4. Secure Clean up +# Remove the temporary file to ensure no sensitive data remains on disk +# Note: /harness/outputs/ and /harness/secrets/ are cleaned up by Harness CI +# after pipeline execution completes - they are scoped to the pipeline only +rm -f "$SECRETS_FILE" + +# 5. Hand over control to the Docker command (if any) +# This allows the container to be used as a wrapper for other commands +exec "$@" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fbf4a80 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "harness_keeper_plugin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "harness_keeper_plugin", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@keeper-security/secrets-manager-core": "^17.3.0" + } + }, + "node_modules/@keeper-security/secrets-manager-core": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/@keeper-security/secrets-manager-core/-/secrets-manager-core-17.3.0.tgz", + "integrity": "sha512-9Wm5s4qbJJlOFmB1L1p2+TqWLdMg/IQMcKEXKw2ZTOQlHjGzECrX2HoeCEdCG8datt/n7uo7f84uby4GvJyOXA==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c40ccaf --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "harness_keeper_plugin", + "version": "1.0.0", + "private": true, + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "@keeper-security/secrets-manager-core": "^17.3.0" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..7c8ba7b --- /dev/null +++ b/src/index.js @@ -0,0 +1,147 @@ +const { + getSecrets, + getValue, + localConfigStorage, + downloadFile, + initializeStorage +} = require('@keeper-security/secrets-manager-core'); +const fs = require('fs'); +const path = require('path'); + +const KSM_CONFIG_PATH = '/app/ksm-config.json'; +const REQUIRED_FIELDS = ['hostname', 'clientId', 'privateKey']; + +const core = { + getMultilineInput: (name) => { + const val = process.env[`PLUGIN_${name.toUpperCase()}`] || ''; + return val.split('\n').filter(line => line.trim() !== ''); + }, + info: (msg) => process.stderr.write(`INFO: ${msg}\n`), + error: (msg) => process.stderr.write(`::error::${msg}\n`), + warning: (msg) => process.stderr.write(`::warning::${msg}\n`) +}; + +const splitInput = (text) => { + const n = text.lastIndexOf('>'); + return n < 0 ? [text, ''] : [text.substring(0, n).trim(), text.substring(n + 1).trim()]; +}; + +const processToken = (rawToken) => { + if (!rawToken) { + core.error('KSM config is required'); + core.error('Set KSM_CONFIG environment variable'); + process.exit(1); + } + + let token = rawToken.trim(); + + if (token.includes('${') || token.includes('<+') || token.includes('ngSecretManager')) { + core.error('Harness expression not resolved'); + process.exit(1); + } + + // Decode base64 if needed + if (!token.startsWith('US:') && !token.startsWith('{')) { + try { + token = Buffer.from(token, 'base64').toString('utf-8').trim(); + } catch (e) { + // Not base64, use as-is + console.log(e) + } + } + + token = token.trim(); + if (!token) { + core.error('Token is null or empty'); + process.exit(1); + } + + const isConfigJson = token.startsWith('{'); + + if (isConfigJson) { + try { + const config = JSON.parse(token); + const missing = REQUIRED_FIELDS.filter(f => !config[f]); + if (missing.length) { + core.error(`Missing required fields: ${missing.join(', ')}`); + process.exit(1); + } + return { token, isConfigJson: true, config }; + } catch (e) { + core.error(`Invalid JSON config: ${e.message}`); + process.exit(1); + } + } + + if (!token.startsWith('US:')) { + core.error('Invalid token format - expected US:xxxxx or JSON config'); + process.exit(1); + } + + return { token, isConfigJson: false, config: null }; +}; + +const parseSecretMappings = () => { + return core.getMultilineInput('secrets').map(line => { + const [notation, destRaw] = splitInput(line); + if (destRaw.startsWith('env:')) { + return { notation, destination: destRaw.slice(4), destinationType: 'environment' }; + } + if (destRaw.startsWith('file:')) { + return { notation, destination: destRaw.slice(5), destinationType: 'file' }; + } + return { notation, destination: destRaw, destinationType: 'output' }; + }); +}; + +const setupStorage = async (token, isConfigJson, config) => { + if (isConfigJson) { + fs.writeFileSync(KSM_CONFIG_PATH, JSON.stringify(config), 'utf8'); + return localConfigStorage(KSM_CONFIG_PATH); + } + const storage = localConfigStorage(KSM_CONFIG_PATH); + await initializeStorage(storage, token); + return storage; +}; + +const runPlugin = async () => { + try { + fs.mkdirSync('/app', { recursive: true }); + + const { token, isConfigJson, config } = processToken(process.env.KSM_CONFIG); + const inputs = parseSecretMappings(); + const storage = await setupStorage(token, isConfigJson, config); + const secrets = await getSecrets({ storage }); + + for (const input of inputs) { + const secret = getValue(secrets, input.notation); + if (!secret) { + core.warning(`Value not found for notation: ${input.notation}`); + continue; + } + + if (input.destinationType === 'file') { + const fullPath = path.resolve(input.destination); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + const data = (typeof secret === 'object' && secret.fileId) + ? await downloadFile(secret) + : secret; + fs.writeFileSync(fullPath, data); + } else if (input.destinationType === 'environment') { + // Output with ENV: prefix for environment variables + console.log(`ENV:${input.destination}='${secret}'`); + } else { + // Output variable (for Harness output variables) + console.log(`OUT:${input.destination}='${secret}'`); + } + } + } catch (error) { + core.error(`Failed: ${error.message}`); + if (error.message.includes('token') || error.message.includes('invalid') || error.message.includes('expired')) { + core.error('One-time access tokens are single-use - generate a new token'); + } + process.exit(1); + } +}; + +runPlugin();