Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
581a835fb5 | ||
|
|
cf1a288161 | ||
|
|
2792fede48 | ||
|
|
b575844081 | ||
|
|
c7a1c1e3bb | ||
|
|
a2a357a196 | ||
|
|
d1a4e73495 | ||
|
|
83858b7236 |
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
@@ -2,6 +2,16 @@ on: push
|
|||||||
name: Run acceptance tests
|
name: Run acceptance tests
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
unit-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm test
|
||||||
|
|
||||||
test-with-output-secrets:
|
test-with-output-secrets:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -29,7 +39,7 @@ jobs:
|
|||||||
if: ${{ matrix.auth == 'connect' }}
|
if: ${{ matrix.auth == 'connect' }}
|
||||||
uses: ./configure # 1password/load-secrets-action/configure@<version>
|
uses: ./configure # 1password/load-secrets-action/configure@<version>
|
||||||
with:
|
with:
|
||||||
connect-host: localhost:8080
|
connect-host: http://localhost:8080
|
||||||
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
|
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||||
- name: Load secrets
|
- name: Load secrets
|
||||||
id: load_secrets
|
id: load_secrets
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Load secret
|
- name: Load secret
|
||||||
uses: 1password/load-secrets-action@v1
|
uses: 1password/load-secrets-action@v2
|
||||||
with:
|
with:
|
||||||
# Export loaded secrets as environment variables
|
# Export loaded secrets as environment variables
|
||||||
export-env: true
|
export-env: true
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
SECRET: op://app-cicd/hello-world/secret
|
SECRET: op://app-cicd/hello-world/secret
|
||||||
|
|
||||||
- name: Print masked secret
|
- name: Print masked secret
|
||||||
run: echo "Secret: $SECRET"
|
run: 'echo "Secret: $SECRET"'
|
||||||
# Prints: Secret: ***
|
# Prints: Secret: ***
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -12,5 +12,5 @@ inputs:
|
|||||||
description: Export the secrets as environment variables
|
description: Export the secrets as environment variables
|
||||||
default: "true"
|
default: "true"
|
||||||
runs:
|
runs:
|
||||||
using: "node16"
|
using: "node20"
|
||||||
main: "dist/index.js"
|
main: "dist/index.js"
|
||||||
|
|||||||
@@ -11,7 +11,16 @@ const jestConfig = {
|
|||||||
testEnvironment: "node",
|
testEnvironment: "node",
|
||||||
testRegex: "(/__tests__/.*|(\\.|/)test)\\.ts",
|
testRegex: "(/__tests__/.*|(\\.|/)test)\\.ts",
|
||||||
transform: {
|
transform: {
|
||||||
".ts": ["ts-jest"],
|
".ts": [
|
||||||
|
"ts-jest",
|
||||||
|
{
|
||||||
|
// Note: We shouldn't need to include `isolatedModules` here because it's a deprecated config option in TS 5,
|
||||||
|
// but setting it to `true` fixes the `ESM syntax is not allowed in a CommonJS module when
|
||||||
|
// 'verbatimModuleSyntax' is enabled` error that we're seeing when running our Jest tests.
|
||||||
|
isolatedModules: true,
|
||||||
|
useESM: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
verbose: true,
|
verbose: true,
|
||||||
};
|
};
|
||||||
|
|||||||
26433
dist/index.js
vendored
26433
dist/index.js
vendored
File diff suppressed because one or more lines are too long
172
entrypoint.sh
172
entrypoint.sh
@@ -1,172 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# shellcheck disable=SC2046,SC2001,SC2086
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Pass User-Agent Inforomation to the 1Password CLI
|
|
||||||
export OP_INTEGRATION_NAME="1Password GitHub Action"
|
|
||||||
export OP_INTEGRATION_ID="GHA"
|
|
||||||
export OP_INTEGRATION_BUILDNUMBER="1010001"
|
|
||||||
|
|
||||||
readonly CONNECT="CONNECT"
|
|
||||||
readonly SERVICE_ACCOUNT="SERVICE_ACCOUNT"
|
|
||||||
|
|
||||||
auth_type=$CONNECT
|
|
||||||
managed_variables_var="OP_MANAGED_VARIABLES"
|
|
||||||
IFS=','
|
|
||||||
|
|
||||||
if [[ "$OP_CONNECT_HOST" != "http://"* ]] && [[ "$OP_CONNECT_HOST" != "https://"* ]]; then
|
|
||||||
export OP_CONNECT_HOST="http://"$OP_CONNECT_HOST
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Unset all secrets managed by 1Password if `unset-previous` is set.
|
|
||||||
unset_prev_secrets() {
|
|
||||||
if [ "$INPUT_UNSET_PREVIOUS" == "true" ]; then
|
|
||||||
echo "Unsetting previous values..."
|
|
||||||
|
|
||||||
# Find environment variables that are managed by 1Password.
|
|
||||||
for env_var in "${managed_variables[@]}"; do
|
|
||||||
echo "Unsetting $env_var"
|
|
||||||
unset $env_var
|
|
||||||
|
|
||||||
echo "$env_var=" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
# Keep the masks, just in case.
|
|
||||||
done
|
|
||||||
|
|
||||||
managed_variables=()
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Install op-cli
|
|
||||||
install_op_cli() {
|
|
||||||
# Create a temporary directory where the CLI is installed
|
|
||||||
OP_INSTALL_DIR="$(mktemp -d)"
|
|
||||||
if [[ ! -d "$OP_INSTALL_DIR" ]]; then
|
|
||||||
echo "Install dir $OP_INSTALL_DIR not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
export OP_INSTALL_DIR
|
|
||||||
echo "::debug::OP_INSTALL_DIR: ${OP_INSTALL_DIR}"
|
|
||||||
|
|
||||||
# Get the latest stable version of the CLI
|
|
||||||
OP_CLI_VERSION="v$(curl https://app-updates.agilebits.com/check/1/0/CLI2/en/2.0.0/N -s | jq -r .version)"
|
|
||||||
|
|
||||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
|
||||||
# Get runner's architecture
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
if [[ "$(getconf LONG_BIT)" = 32 ]]; then
|
|
||||||
ARCH="386"
|
|
||||||
elif [[ "$ARCH" == "x86_64" ]]; then
|
|
||||||
ARCH="amd64"
|
|
||||||
elif [[ "$ARCH" == "aarch64" ]]; then
|
|
||||||
ARCH="arm64"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$ARCH" != "386" ]] && [[ "$ARCH" != "amd64" ]] && [[ "$ARCH" != "arm" ]] && [[ "$ARCH" != "arm64" ]]; then
|
|
||||||
echo "Unsupported architecture for the 1Password CLI: $ARCH."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
curl -sSfLo op.zip "https://cache.agilebits.com/dist/1P/op2/pkg/${OP_CLI_VERSION}/op_linux_${ARCH}_${OP_CLI_VERSION}.zip"
|
|
||||||
unzip -od "$OP_INSTALL_DIR" op.zip && rm op.zip
|
|
||||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
|
||||||
curl -sSfLo op.pkg "https://cache.agilebits.com/dist/1P/op2/pkg/${OP_CLI_VERSION}/op_apple_universal_${OP_CLI_VERSION}.pkg"
|
|
||||||
pkgutil --expand op.pkg temp-pkg
|
|
||||||
tar -xvf temp-pkg/op.pkg/Payload -C "$OP_INSTALL_DIR"
|
|
||||||
rm -rf temp-pkg && rm op.pkg
|
|
||||||
else
|
|
||||||
echo "Operating system not supported yet for this GitHub Action: $OSTYPE."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Uninstall op-cli
|
|
||||||
uninstall_op_cli() {
|
|
||||||
if [[ -d "$OP_INSTALL_DIR" ]]; then
|
|
||||||
rm -fr "$OP_INSTALL_DIR"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
populating_secret() {
|
|
||||||
ref=$(printenv $1)
|
|
||||||
|
|
||||||
echo "Populating variable: $1"
|
|
||||||
secret_value=$("${OP_INSTALL_DIR}/op" read "$ref")
|
|
||||||
|
|
||||||
if [ -z "$secret_value" ]; then
|
|
||||||
echo "Could not find or access secret $ref"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Register a mask for the secret to prevent accidental log exposure.
|
|
||||||
# To support multiline secrets, escape percent signs and add a mask per line.
|
|
||||||
escaped_mask_value=$(echo "$secret_value" | sed -e 's/%/%25/g')
|
|
||||||
IFS=$'\n'
|
|
||||||
for line in $escaped_mask_value; do
|
|
||||||
if [ "${#line}" -lt 3 ]; then
|
|
||||||
# To avoid false positives and unreadable logs, omit mask for lines that are too short.
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
echo "::add-mask::$line"
|
|
||||||
done
|
|
||||||
unset IFS
|
|
||||||
|
|
||||||
# To support multiline secrets, we'll use the heredoc syntax to populate the environment variables.
|
|
||||||
# As the heredoc identifier, we'll use a randomly generated 64-character string,
|
|
||||||
# so that collisions are practically impossible.
|
|
||||||
# Read more: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
|
|
||||||
delimiter="$(openssl rand -hex 32)"
|
|
||||||
|
|
||||||
if [ "$INPUT_EXPORT_ENV" == "true" ]; then
|
|
||||||
{
|
|
||||||
# Populate env var, using heredoc syntax with generated identifier
|
|
||||||
echo "$env_var<<${delimiter}"
|
|
||||||
echo "$secret_value"
|
|
||||||
echo "${delimiter}"
|
|
||||||
} >> $GITHUB_ENV
|
|
||||||
echo "GITHUB_ENV: $(cat $GITHUB_ENV)"
|
|
||||||
|
|
||||||
else
|
|
||||||
{
|
|
||||||
# Populate env var, using heredoc syntax with generated identifier
|
|
||||||
echo "$env_var<<${delimiter}"
|
|
||||||
echo "$secret_value"
|
|
||||||
echo "${delimiter}"
|
|
||||||
} >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
managed_variables+=("$env_var")
|
|
||||||
}
|
|
||||||
|
|
||||||
# Load environment variables using op cli. Iterate over them to find 1Password references, load the secret values,
|
|
||||||
# and make them available as environment variables in the next steps.
|
|
||||||
extract_secrets() {
|
|
||||||
IFS=$'\n'
|
|
||||||
for env_var in $("${OP_INSTALL_DIR}/op" env ls); do
|
|
||||||
populating_secret $env_var
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
read -r -a managed_variables <<< "$(printenv $managed_variables_var)"
|
|
||||||
|
|
||||||
if [ -z "$OP_CONNECT_TOKEN" ] || [ -z "$OP_CONNECT_HOST" ]; then
|
|
||||||
if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
|
|
||||||
echo "(\$OP_CONNECT_TOKEN and \$OP_CONNECT_HOST) or \$OP_SERVICE_ACCOUNT_TOKEN must be set"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
auth_type=$SERVICE_ACCOUNT
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf "Authenticated with %s \n" $auth_type
|
|
||||||
|
|
||||||
unset_prev_secrets
|
|
||||||
install_op_cli
|
|
||||||
extract_secrets
|
|
||||||
uninstall_op_cli
|
|
||||||
|
|
||||||
unset IFS
|
|
||||||
# Add extra env var that lists which secrets are managed by 1Password so that in a later step
|
|
||||||
# these can be unset again.
|
|
||||||
managed_variables_str=$(IFS=','; echo "${managed_variables[*]}")
|
|
||||||
echo "$managed_variables_var=$managed_variables_str" >> $GITHUB_ENV
|
|
||||||
46
install_cli.sh
Executable file
46
install_cli.sh
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Install op-cli
|
||||||
|
install_op_cli() {
|
||||||
|
# Create a temporary directory where the CLI is installed
|
||||||
|
OP_INSTALL_DIR="$(mktemp -d)"
|
||||||
|
if [[ ! -d "$OP_INSTALL_DIR" ]]; then
|
||||||
|
echo "Install dir $OP_INSTALL_DIR not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "::debug::OP_INSTALL_DIR: ${OP_INSTALL_DIR}"
|
||||||
|
|
||||||
|
# Get the latest stable version of the CLI
|
||||||
|
CLI_VERSION="v$(curl https://app-updates.agilebits.com/check/1/0/CLI2/en/2.0.0/N -s | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+')"
|
||||||
|
|
||||||
|
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||||
|
# Get runner's architecture
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [[ "$(getconf LONG_BIT)" = 32 ]]; then
|
||||||
|
ARCH="386"
|
||||||
|
elif [[ "$ARCH" == "x86_64" ]]; then
|
||||||
|
ARCH="amd64"
|
||||||
|
elif [[ "$ARCH" == "aarch64" ]]; then
|
||||||
|
ARCH="arm64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ARCH" != "386" ]] && [[ "$ARCH" != "amd64" ]] && [[ "$ARCH" != "arm" ]] && [[ "$ARCH" != "arm64" ]]; then
|
||||||
|
echo "Unsupported architecture for the 1Password CLI: $ARCH."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -sSfLo op.zip "https://cache.agilebits.com/dist/1P/op2/pkg/${CLI_VERSION}/op_linux_${ARCH}_${CLI_VERSION}.zip"
|
||||||
|
unzip -od "$OP_INSTALL_DIR" op.zip && rm op.zip
|
||||||
|
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
curl -sSfLo op.pkg "https://cache.agilebits.com/dist/1P/op2/pkg/${CLI_VERSION}/op_apple_universal_${CLI_VERSION}.pkg"
|
||||||
|
pkgutil --expand op.pkg temp-pkg
|
||||||
|
tar -xvf temp-pkg/op.pkg/Payload -C "$OP_INSTALL_DIR"
|
||||||
|
rm -rf temp-pkg && rm op.pkg
|
||||||
|
else
|
||||||
|
echo "Operating system not supported yet for this GitHub Action: $OSTYPE."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_op_cli
|
||||||
9597
package-lock.json
generated
9597
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "load-secrets-action",
|
"name": "load-secrets-action",
|
||||||
"version": "1.2.0",
|
"version": "2.0.0",
|
||||||
"description": "Load Secrets from 1Password",
|
"description": "Load Secrets from 1Password",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -39,19 +39,20 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/1Password/load-secrets-action#readme",
|
"homepage": "https://github.com/1Password/load-secrets-action#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.0",
|
"@1password/op-js": "^0.1.11",
|
||||||
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/exec": "^1.1.1"
|
"@actions/exec": "^1.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@1password/front-end-style": "^6.0.1",
|
"@1password/front-end-style": "^6.0.1",
|
||||||
"@types/jest": "^29.5.0",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/node": "^18.15.10",
|
"@types/node": "^20.11.30",
|
||||||
"@vercel/ncc": "^0.36.1",
|
"@vercel/ncc": "^0.38.1",
|
||||||
"husky": "^8.0.3",
|
"husky": "^9.0.11",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.7.0",
|
||||||
"lint-staged": "^13.2.0",
|
"lint-staged": "^15.2.2",
|
||||||
"ts-jest": "^29.0.5",
|
"ts-jest": "^29.1.2",
|
||||||
"typescript": "^4.9.5"
|
"typescript": "^5.4.2"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": "./node_modules/@1password/front-end-style/eslintrc.yml",
|
"extends": "./node_modules/@1password/front-end-style/eslintrc.yml",
|
||||||
|
|||||||
6
src/constants.ts
Normal file
6
src/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const envConnectHost = "OP_CONNECT_HOST";
|
||||||
|
export const envConnectToken = "OP_CONNECT_TOKEN";
|
||||||
|
export const envServiceAccountToken = "OP_SERVICE_ACCOUNT_TOKEN";
|
||||||
|
export const envManagedVariables = "OP_MANAGED_VARIABLES";
|
||||||
|
|
||||||
|
export const authErr = `Authentication error with environment variables: you must set either 1) ${envServiceAccountToken}, or 2) both ${envConnectHost} and ${envConnectToken}.`;
|
||||||
55
src/index.ts
55
src/index.ts
@@ -2,19 +2,28 @@ import path from "path";
|
|||||||
import url from "url";
|
import url from "url";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import * as exec from "@actions/exec";
|
import * as exec from "@actions/exec";
|
||||||
|
import { validateCli } from "@1password/op-js";
|
||||||
|
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
|
||||||
|
|
||||||
const run = async () => {
|
const loadSecretsAction = async () => {
|
||||||
try {
|
try {
|
||||||
const currentFile = url.fileURLToPath(import.meta.url);
|
|
||||||
const currentDir = path.dirname(currentFile);
|
|
||||||
const parentDir = path.resolve(currentDir, "..");
|
|
||||||
|
|
||||||
// Get action inputs
|
// Get action inputs
|
||||||
process.env.INPUT_UNSET_PREVIOUS = core.getInput("unset-previous");
|
const shouldUnsetPrevious = core.getBooleanInput("unset-previous");
|
||||||
process.env.INPUT_EXPORT_ENV = core.getInput("export-env");
|
const shouldExportEnv = core.getBooleanInput("export-env");
|
||||||
|
|
||||||
// Execute bash script
|
// Unset all secrets managed by 1Password if `unset-previous` is set.
|
||||||
await exec.exec(`sh -c "` + parentDir + `/entrypoint.sh"`);
|
if (shouldUnsetPrevious) {
|
||||||
|
unsetPrevious();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that a proper authentication configuration is set for the CLI
|
||||||
|
validateAuth();
|
||||||
|
|
||||||
|
// Download and install the CLI
|
||||||
|
await installCLI();
|
||||||
|
|
||||||
|
// Load secrets
|
||||||
|
await loadSecrets(shouldExportEnv);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// It's possible for the Error constructor to be modified to be anything
|
// It's possible for the Error constructor to be modified to be anything
|
||||||
// in JavaScript, so the following code accounts for this possibility.
|
// in JavaScript, so the following code accounts for this possibility.
|
||||||
@@ -29,4 +38,30 @@ const run = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void run();
|
// This function's name is an exception from the naming convention
|
||||||
|
// since we refer to the 1Password CLI here.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
const installCLI = async (): Promise<void> => {
|
||||||
|
// validateCli checks if there's an existing 1Password CLI installed on the runner.
|
||||||
|
// If there's no CLI installed, then validateCli will throw an error, which we will use
|
||||||
|
// as an indicator that we need to execute the installation script.
|
||||||
|
await validateCli().catch(async () => {
|
||||||
|
const currentFile = url.fileURLToPath(import.meta.url);
|
||||||
|
const currentDir = path.dirname(currentFile);
|
||||||
|
const parentDir = path.resolve(currentDir, "..");
|
||||||
|
|
||||||
|
// Execute bash script
|
||||||
|
const cmdOut = await exec.getExecOutput(
|
||||||
|
`sh -c "` + parentDir + `/install_cli.sh"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add path to 1Password CLI to $PATH
|
||||||
|
const outArr = cmdOut.stdout.split("\n");
|
||||||
|
if (outArr[0] && process.env.PATH) {
|
||||||
|
const cliPath = outArr[0]?.replace(/^(::debug::OP_INSTALL_DIR: )/, "");
|
||||||
|
core.addPath(cliPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSecretsAction();
|
||||||
|
|||||||
164
src/utils.test.ts
Normal file
164
src/utils.test.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
import * as exec from "@actions/exec";
|
||||||
|
import { read, setClientInfo } from "@1password/op-js";
|
||||||
|
import {
|
||||||
|
extractSecret,
|
||||||
|
loadSecrets,
|
||||||
|
unsetPrevious,
|
||||||
|
validateAuth,
|
||||||
|
} from "./utils";
|
||||||
|
import {
|
||||||
|
authErr,
|
||||||
|
envConnectHost,
|
||||||
|
envConnectToken,
|
||||||
|
envManagedVariables,
|
||||||
|
envServiceAccountToken,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
|
jest.mock("@actions/core");
|
||||||
|
jest.mock("@actions/exec", () => ({
|
||||||
|
getExecOutput: jest.fn(() => ({
|
||||||
|
stdout: "MOCK_SECRET",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
jest.mock("@1password/op-js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateAuth", () => {
|
||||||
|
const testConnectHost = "https://localhost:8000";
|
||||||
|
const testConnectToken = "token";
|
||||||
|
const testServiceAccountToken = "ops_token";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env[envConnectHost] = "";
|
||||||
|
process.env[envConnectToken] = "";
|
||||||
|
process.env[envServiceAccountToken] = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error when no config is provided", () => {
|
||||||
|
expect(validateAuth).toThrowError(authErr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error when partial Connect config is provided", () => {
|
||||||
|
process.env[envConnectHost] = testConnectHost;
|
||||||
|
expect(validateAuth).toThrowError(authErr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be authenticated as a Connect client", () => {
|
||||||
|
process.env[envConnectHost] = testConnectHost;
|
||||||
|
process.env[envConnectToken] = testConnectToken;
|
||||||
|
expect(validateAuth).not.toThrowError(authErr);
|
||||||
|
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be authenticated as a service account", () => {
|
||||||
|
process.env[envServiceAccountToken] = testServiceAccountToken;
|
||||||
|
expect(validateAuth).not.toThrowError(authErr);
|
||||||
|
expect(core.info).toHaveBeenCalledWith(
|
||||||
|
"Authenticated with Service account.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prioritize Connect over service account if both are configured", () => {
|
||||||
|
process.env[envServiceAccountToken] = testServiceAccountToken;
|
||||||
|
process.env[envConnectHost] = testConnectHost;
|
||||||
|
process.env[envConnectToken] = testConnectToken;
|
||||||
|
expect(validateAuth).not.toThrowError(authErr);
|
||||||
|
expect(core.warning).toHaveBeenCalled();
|
||||||
|
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractSecret", () => {
|
||||||
|
const envTestSecretEnv = "TEST_SECRET";
|
||||||
|
const testSecretRef = "op://vault/item/secret";
|
||||||
|
const testSecretValue = "Secret1@3$";
|
||||||
|
|
||||||
|
read.parse = jest.fn().mockReturnValue(testSecretValue);
|
||||||
|
|
||||||
|
process.env[envTestSecretEnv] = testSecretRef;
|
||||||
|
|
||||||
|
it("should set secret as step output", () => {
|
||||||
|
extractSecret(envTestSecretEnv, false);
|
||||||
|
expect(core.exportVariable).not.toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
testSecretValue,
|
||||||
|
);
|
||||||
|
expect(core.setOutput).toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
testSecretValue,
|
||||||
|
);
|
||||||
|
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set secret as environment variable", () => {
|
||||||
|
extractSecret(envTestSecretEnv, true);
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
testSecretValue,
|
||||||
|
);
|
||||||
|
expect(core.setOutput).not.toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
testSecretValue,
|
||||||
|
);
|
||||||
|
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadSecrets", () => {
|
||||||
|
it("sets the client info and gets the executed output", async () => {
|
||||||
|
await loadSecrets(true);
|
||||||
|
|
||||||
|
expect(setClientInfo).toHaveBeenCalledWith({
|
||||||
|
name: "1Password GitHub Action",
|
||||||
|
id: "GHA",
|
||||||
|
});
|
||||||
|
expect(exec.getExecOutput).toHaveBeenCalledWith('sh -c "op env ls"');
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||||
|
"OP_MANAGED_VARIABLES",
|
||||||
|
"MOCK_SECRET",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("return early if no env vars with secrets found", async () => {
|
||||||
|
(exec.getExecOutput as jest.Mock).mockReturnValueOnce({ stdout: "" });
|
||||||
|
await loadSecrets(true);
|
||||||
|
|
||||||
|
expect(exec.getExecOutput).toHaveBeenCalledWith('sh -c "op env ls"');
|
||||||
|
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("core.exportVariable", () => {
|
||||||
|
it("is called when shouldExportEnv is true", async () => {
|
||||||
|
await loadSecrets(true);
|
||||||
|
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not called when shouldExportEnv is false", async () => {
|
||||||
|
await loadSecrets(false);
|
||||||
|
|
||||||
|
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unsetPrevious", () => {
|
||||||
|
const testManagedEnv = "TEST_SECRET";
|
||||||
|
const testSecretValue = "MyS3cr#T";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env[testManagedEnv] = testSecretValue;
|
||||||
|
process.env[envManagedVariables] = testManagedEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should unset the environment variable if user wants it", () => {
|
||||||
|
unsetPrevious();
|
||||||
|
expect(core.info).toHaveBeenCalledWith("Unsetting previous values ...");
|
||||||
|
expect(core.info).toHaveBeenCalledWith("Unsetting TEST_SECRET");
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith("TEST_SECRET", "");
|
||||||
|
});
|
||||||
|
});
|
||||||
91
src/utils.ts
Normal file
91
src/utils.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
import * as exec from "@actions/exec";
|
||||||
|
import { read, setClientInfo, semverToInt } from "@1password/op-js";
|
||||||
|
import { version } from "../package.json";
|
||||||
|
import {
|
||||||
|
authErr,
|
||||||
|
envConnectHost,
|
||||||
|
envConnectToken,
|
||||||
|
envServiceAccountToken,
|
||||||
|
envManagedVariables,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
|
export const validateAuth = (): void => {
|
||||||
|
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
|
||||||
|
const isServiceAccount = process.env[envServiceAccountToken];
|
||||||
|
|
||||||
|
if (isConnect && isServiceAccount) {
|
||||||
|
core.warning(
|
||||||
|
"WARNING: Both service account and Connect credentials are provided. Connect credentials will take priority.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConnect && !isServiceAccount) {
|
||||||
|
throw new Error(authErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authType = isConnect ? "Connect" : "Service account";
|
||||||
|
|
||||||
|
core.info(`Authenticated with ${authType}.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractSecret = (
|
||||||
|
envName: string,
|
||||||
|
shouldExportEnv: boolean,
|
||||||
|
): void => {
|
||||||
|
core.info(`Populating variable: ${envName}`);
|
||||||
|
|
||||||
|
const ref = process.env[envName];
|
||||||
|
if (!ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretValue = read.parse(ref);
|
||||||
|
if (!secretValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldExportEnv) {
|
||||||
|
core.exportVariable(envName, secretValue);
|
||||||
|
} else {
|
||||||
|
core.setOutput(envName, secretValue);
|
||||||
|
}
|
||||||
|
core.setSecret(secretValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
|
||||||
|
// Pass User-Agent Information to the 1Password CLI
|
||||||
|
setClientInfo({
|
||||||
|
name: "1Password GitHub Action",
|
||||||
|
id: "GHA",
|
||||||
|
build: semverToInt(version),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load secrets from environment variables using 1Password CLI.
|
||||||
|
// Iterate over them to find 1Password references, extract the secret values,
|
||||||
|
// and make them available in the next steps either as step outputs or as environment variables.
|
||||||
|
const res = await exec.getExecOutput(`sh -c "op env ls"`);
|
||||||
|
|
||||||
|
if (res.stdout === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envs = res.stdout.replace(/\n+$/g, "").split(/\r?\n/);
|
||||||
|
for (const envName of envs) {
|
||||||
|
extractSecret(envName, shouldExportEnv);
|
||||||
|
}
|
||||||
|
if (shouldExportEnv) {
|
||||||
|
core.exportVariable(envManagedVariables, envs.join());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unsetPrevious = (): void => {
|
||||||
|
if (process.env[envManagedVariables]) {
|
||||||
|
core.info("Unsetting previous values ...");
|
||||||
|
const managedEnvs = process.env[envManagedVariables].split(",");
|
||||||
|
for (const envName of managedEnvs) {
|
||||||
|
core.info(`Unsetting ${envName}`);
|
||||||
|
core.exportVariable(envName, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -6,8 +6,6 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"exactOptionalPropertyTypes": true,
|
"exactOptionalPropertyTypes": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"importsNotUsedAsValues": "error",
|
|
||||||
"isolatedModules": true,
|
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
@@ -17,9 +15,9 @@
|
|||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"outDir": "./dist/",
|
"resolveJsonModule": true,
|
||||||
"rootDir": "./src/",
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "es2022"
|
"target": "es2022",
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user