Compare commits

..

3 Commits

Author SHA1 Message Date
Eddy Filip
cb23d65d8d Add missing config for tests
Some checks failed
Run acceptance tests / test (push) Has been cancelled
Run acceptance tests / test-user (push) Has been cancelled
2021-08-03 19:23:44 +02:00
Eddy Filip
adde26c41b Fix lint 2021-08-03 19:18:04 +02:00
Eddy Filip
6bfaeeb67d Enable the user to use the GitHub Action without Connect 2021-08-03 19:11:41 +02:00
26 changed files with 333 additions and 39700 deletions

View File

@@ -5,9 +5,6 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@2.0.0
with:
ignore_paths: >-
.husky
- uses: actions/checkout@v2
- name: ShellCheck
uses: ludeeus/action-shellcheck@1.1.0

View File

@@ -1,44 +0,0 @@
name: Release
on:
push:
branches:
# TODO: This branch is for PR testing purposes; update branch to "main" if we proceed with this PR.
- ruetz-automate-releases
# Specify that this "Release" workflow depends on the other workflows below completing successfully.
workflow_run:
workflows: ["Lint", "Run acceptance tests"]
types:
- completed
permissions:
contents: read # for checkout
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write # to be able to publish a GitHub release
issues: write # to be able to comment on released issues
pull-requests: write # to be able to comment on released pull requests
id-token: write # to enable use of OIDC for npm provenance
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install dependencies
run: npm clean-install
- name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
run: npm audit signatures
- name: Build codebase
run: npm run build
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release

View File

@@ -2,145 +2,94 @@ on: push
name: Run acceptance tests
jobs:
unit-tests:
test:
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:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
auth: [connect, service-account]
exclude:
- os: macos-latest
auth: connect
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- name: Launch 1Password Connect instance
if: ${{ matrix.auth == 'connect' }}
env:
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
run: |
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
- name: Configure Service account
if: ${{ matrix.auth == 'service-account' }}
uses: ./configure
with:
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Configure 1Password Connect
if: ${{ matrix.auth == 'connect' }}
uses: ./configure # 1password/load-secrets-action/configure@<version>
with:
use-connect: yes
connect-host: http://localhost:8080
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
- name: Load secrets
id: load_secrets
uses: ./ # 1password/load-secrets-action@<version>
with:
export-env: false
env:
SECRET: op://acceptance-tests/test-secret/password
SECRET_IN_SECTION: op://acceptance-tests/test-secret/test-section/password
MULTILINE_SECRET: op://acceptance-tests/multiline-secret/notesPlain
- name: Assert test secret values
env:
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
run: ./tests/assert-env-set.sh
test-with-export-env:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
auth: [connect, service-account]
exclude:
- os: macos-latest
auth: connect
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Launch 1Password Connect instance
if: ${{ matrix.auth == 'connect' }}
env:
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
run: |
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
- name: Configure Service account
if: ${{ matrix.auth == 'service-account' }}
uses: ./configure
with:
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Configure 1Password Connect
if: ${{ matrix.auth == 'connect' }}
uses: ./configure # 1password/load-secrets-action/configure@<version>
with:
connect-host: http://localhost:8080
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
- name: Load secrets
id: load_secrets
uses: ./ # 1password/load-secrets-action@<version>
env:
SECRET: op://acceptance-tests/test-secret/password
SECRET_IN_SECTION: op://acceptance-tests/test-secret/test-section/password
MULTILINE_SECRET: op://acceptance-tests/multiline-secret/notesPlain
SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/password
SECRET_IN_SECTION: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/test-section/password
UNMASKED_VALUE: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/test-section/username
- name: Load multiline secret
uses: ./ # 1password/load-secrets-action@<version>
env:
MULTILINE_SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/ghtz3jvcc6dqmzc53d3r3eskge/notesPlain
- name: Print environment variables with masked secrets
run: printenv
- name: Assert test secret values
run: ./tests/assert-env-set.sh
- name: Remove secrets
uses: ./ # 1password/load-secrets-action@<version>
with:
unset-previous: true
- name: Print environment variables with secrets removed
run: printenv
- name: Assert removed secrets
run: ./tests/assert-env-unset.sh
test-references-with-ids:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
auth: [connect, service-account]
exclude:
- os: macos-latest
auth: connect
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Launch 1Password Connect instance
if: ${{ matrix.auth == 'connect' }}
- name: Load secrets by vault and item titles
uses: ./ # 1password/load-secrets-action@<version>
env:
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
run: |
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
- name: Configure Service account
if: ${{ matrix.auth == 'service-account' }}
uses: ./configure
with:
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Configure 1Password Connect
if: ${{ matrix.auth == 'connect' }}
SECRET: op://acceptance-tests/test-secret/password
SECRET_IN_SECTION: op://acceptance-tests/test-secret/test-section/password
MULTILINE_SECRET: op://acceptance-tests/multiline-secret/notesPlain
- name: Print environment variables with masked secrets
run: printenv
- name: Assert test secret values again
run: ./tests/assert-env-set.sh
test-user:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Configure 1Password with a user
uses: ./configure # 1password/load-secrets-action/configure@<version>
with:
connect-host: http://localhost:8080
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
user-domain: ${{ secrets.USER_DOMAIN }}
user-email: ${{ secrets.USER_EMAIL }}
user-key: ${{ secrets.USER_KEY }}
user-pwd: ${{ secrets.USER_PWD }}
- name: Load secrets
id: load_secrets
uses: ./ # 1password/load-secrets-action@<version>
env:
SECRET: op://p4a7xwirm3lcwtu6okulbhym5i/mybhbbwhewncjb67cy4v7hjpgi/password
SECRET_IN_SECTION: op://p4a7xwirm3lcwtu6okulbhym5i/mybhbbwhewncjb67cy4v7hjpgi/test-section/password
UNMASKED_VALUE: op://p4a7xwirm3lcwtu6okulbhym5i/mybhbbwhewncjb67cy4v7hjpgi/test-section/username
- name: Load multiline secret
uses: ./ # 1password/load-secrets-action@<version>
env:
MULTILINE_SECRET: op://p4a7xwirm3lcwtu6okulbhym5i/bcshnrykfwf5i5zm7nkqy63dca/notesPlain
- name: Print environment variables with masked secrets
run: printenv
- name: Assert test secret values
run: ./tests/assert-env-set.sh
- name: Remove secrets
uses: ./ # 1password/load-secrets-action@<version>
with:
export-env: false
unset-previous: true
- name: Print environment variables with secrets removed
run: printenv
- name: Assert removed secrets
run: ./tests/assert-env-unset.sh
- name: Load secrets by vault and item titles
uses: ./ # 1password/load-secrets-action@<version>
env:
SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/password
SECRET_IN_SECTION: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/Section_tco6nsqycj6jcbyx63h5isxcny/doxu3mhkozcznnk5vjrkpdqayy
MULTILINE_SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/ghtz3jvcc6dqmzc53d3r3eskge/notesPlain
- name: Assert test secret values
env:
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
run: ./tests/assert-env-set.sh
SECRET: op://acceptance-tests/test-secret/password
SECRET_IN_SECTION: op://acceptance-tests/test-secret/test-section/password
MULTILINE_SECRET: op://acceptance-tests/multiline-secret/notesPlain
- name: Print environment variables with masked secrets
run: printenv
- name: Assert test secret values again
run: ./tests/assert-env-set.sh

2
.gitignore vendored
View File

@@ -1,2 +0,0 @@
coverage/
node_modules/

View File

@@ -1,51 +0,0 @@
# Contributing
Thank you for your interest in contributing to the 1Password load-secrets-action project 👋! Before you start, please take a moment to read through this guide to understand our contribution process.
## Testing
Unit tests can be run with `npm run test`.
After following the steps below for signing commits, you can test against your PR with these steps:
1. Create or use an existing repo to run the `load-secrets` GitHub Action.
2. In a workflow yaml file that uses the GitHub Action, modify the `uses: 1Password/load-secrets-action` line to be
```yaml
uses: 1Password/load-secrets-action@<branch-name>
```
OR
```yaml
uses: 1Password/load-secrets-action@<commit-hash>
```
3. Trigger the action, which now includes your changes.
## Documentation Updates
If applicable, update the [README.md](./README.md) to reflect any changes introduced by the new code.
## Sign your commits
To get your PR merged, we require you to sign your commits.
### Sign commits with 1Password
You can also sign commits using 1Password, which lets you sign commits with biometrics without the signing key leaving the local 1Password process.
Learn how to use [1Password to sign your commits](https://developer.1password.com/docs/ssh/git-commit-signing/).
### Sign commits with ssh-agent
Follow the steps below to set up commit signing with `ssh-agent`:
1. [Generate an SSH key and add it to ssh-agent](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent)
2. [Add the SSH key to your GitHub account](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account)
3. [Configure git to use your SSH key for commits signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key#telling-git-about-your-ssh-key)
### Sign commits with gpg
Follow the steps below to set up commit signing with `gpg`:
1. [Generate a GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key)
2. [Add the GPG key to your GitHub account](https://docs.github.com/en/authentication/managing-commit-signature-verification/adding-a-gpg-key-to-your-github-account)
3. [Configure git to use your GPG key for commits signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key#telling-git-about-your-gpg-key)

164
README.md
View File

@@ -1,27 +1,10 @@
<!-- Image sourced from https://blog.1password.com/1password-service-accounts/ -->
<img alt="" role="img" src="https://blog.1password.com/posts/2023/1password-service-accounts/header.png"/>
# Load Secrets from 1Password - GitHub Action
<div align="center">
<h1>Load Secrets from 1Password - GitHub Action</h1>
<p>Provide the secrets your GitHub runner needs from 1Password.</p>
<a href="https://developer.1password.com/docs/ci-cd/github-actions">
<img alt="Get started" src="https://user-images.githubusercontent.com/45081667/226940040-16d3684b-60f4-4d95-adb2-5757a8f1bc15.png" height="37"/>
</a>
</div>
The action to load secrets from [1Password Connect](https://1password.com/secrets/) into GitHub Actions.
---
Specify right from your workflow YAML which secrets from 1Password should be loaded into your job, and the action will make them available as environment variables for the next steps.
`load-secrets-action` loads secrets from 1Password into GitHub Actions using [Service Accounts](https://developer.1password.com/docs/service-accounts) or [1Password Connect](https://developer.1password.com/docs/connect).
Specify in your workflow YAML file which secrets from 1Password should be loaded into your job, and the action will make them available as environment variables for the next steps.
Read more on the [1Password Developer Portal](https://developer.1password.com/docs/ci-cd/github-actions).
## 🪄 See it in action!
[![Using 1Password Service Accounts with GitHub Actions - showcase](https://img.youtube.com/vi/kVBl5iQYgSA/maxresdefault.jpg)](https://www.youtube.com/watch?v=kVBl5iQYgSA "Using 1Password Service Accounts with GitHub Actions")
## ✨ Quickstart
## Usage
```yml
on: push
@@ -29,32 +12,141 @@ jobs:
hello-world:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- name: Load secret
uses: 1password/load-secrets-action@v2
with:
# Export loaded secrets as environment variables
export-env: true
uses: 1password/load-secrets-action@v1
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
OP_CONNECT_HOST: <Your Connect instance URL>
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
SECRET: op://app-cicd/hello-world/secret
- name: Print masked secret
run: 'echo "Secret: $SECRET"'
run: echo "Secret: $SECRET"
# Prints: Secret: ***
```
<details>
<summary><b>Longer usage example</b></summary>
## 💙 Community & Support
```yml
on: push
name: Deploy app
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.
- Join the [Developer Slack workspace](https://join.slack.com/t/1password-devs/shared_invite/zt-1halo11ps-6o9pEv96xZ3LtX_VE0fJQA).
- Subscribe to the [Developer Newsletter](https://1password.com/dev-subscribe/).
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
## 🔐 Security
- name: Configure 1Password Connect
uses: 1password/load-secrets-action/configure@v1
with:
# Persist the 1Password Connect URL for next steps. You can also persist
# the Connect token using input `connect-token`, but keep in mind that
# every single step in the job would then be able to access the token.
connect-host: https://1password.acme.com
1Password requests you practice responsible disclosure if you discover a vulnerability.
- name: Load Docker credentials
uses: 1password/load-secrets-action@v1
env:
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
DOCKERHUB_USERNAME: op://app-cicd/docker/username
DOCKERHUB_TOKEN: op://app-cicd/docker/token
Please file requests via [**BugCrowd**](https://bugcrowd.com/agilebits).
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
For information about security practices, please visit the [1Password Bug Bounty Program](https://bugcrowd.com/agilebits).
- name: Print environment variables with masked secrets
run: printenv
- name: Build and push Docker image
uses: docker/build-push-action@v2
with:
push: true
tags: acme/app:latest
- name: Load AWS credentials
uses: 1password/load-secrets-action@v1
with:
# Remove local copies of the Docker credentials, which are not needed anymore
unset-previous: true
env:
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
AWS_ACCESS_KEY_ID: op://app-cicd/aws/access-key-id
AWS_SECRET_ACCESS_KEY: op://app-cicd/aws/secret-access-key
- name: Deploy app
# This script expects AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to be set, which was
# done automatically by the step above
run: ./deploy.sh
```
</details>
## Action Inputs
| Name | Default | Description |
|---|---|---|
| `unset-previous` | `false` | Whether to unset environment variables populated by 1Password in earlier job steps |
## Secrets Reference Syntax
To specify which secret should be loaded into which environment variable, the action will look for `op://` reference URIs in environment variables, and replace those with the actual secret values.
These reference URIs have the following syntax:
> `op://<vault>/<item>[/<section>]/<field>`
So for example, the reference URI `op://app-cicd/aws/secret-access-key` would be interpreted as:
* **Vault:** `app-cicd`
* **Item:** `aws`
* **Section:** default section
* **Field:** `secret-access-key`
## Masking
Similar to regular GitHub repository secrets, secret fields from 1Password will automatically be masked from the GitHub Actions logs too.
A 1Password field is considered 'secret' when it's marked as concealed (which shows as `•••••••` in the 1Password GUI) or when it's a secure note.
So if one of these values accidentally gets printed, it'll get replaced with `***`.
This means that a username or port field for example will not get masked.
## 1Password Connect Configuration
To use the action, you need to have a [1Password Connect](https://support.1password.com/secrets-automation/#step-1-set-up-a-secrets-automation-workflow) instance deployed somewhere.
To configure the action with your Connect URL and a Connect token, you can set the `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` variables.
If you're using the `load-secrets` action more than once in a single job, you can use the `configure` action to avoid duplicate configuration:
```yml
on: push
jobs:
hello-world:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Configure 1Password Connect
uses: 1password/load-secrets-action/configure@v1
with:
connect-host: <Your Connect instance URL>
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
- name: Load secret
uses: 1password/load-secrets-action@v1
env:
SECRET: op://app-cicd/hello-world/secret
```
### `configure` Action Inputs
| Name | Default | Environment variable | Description |
|---|---|---|---|
| `connect-host` | | `OP_CONNECT_HOST` | Your 1Password Connect instance URL |
| `connect-token` | | `OP_CONNECT_TOKEN` | Token to authenticate to your 1Password Connect instance |
## Supported Runners
You can run the action on Linux and macOS runners. Windows is currently not supported.

View File

@@ -7,10 +7,11 @@ branding:
inputs:
unset-previous:
description: Whether to unset environment variables populated by 1Password in earlier job steps
default: "false"
export-env:
description: Export the secrets as environment variables
default: "true"
default: false
runs:
using: "node20"
main: "dist/index.js"
using: composite
steps:
- run: |
export INPUT_UNSET_PREVIOUS=${{ inputs.unset-previous }}
${{ github.action_path }}/entrypoint.sh
shell: bash

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged --config ./config/lint-staged.config.js

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run validate

View File

@@ -1,3 +0,0 @@
coverage/
dist/
node_modules/

View File

@@ -1,28 +0,0 @@
const jestConfig = {
/**
* Jest docs: "We recommend placing the extensions most commonly used in your project
* on the left, so if you are using TypeScript, you may want to consider
* moving 'ts' to the beginning of the array."
*
* https://jestjs.io/docs/configuration#modulefileextensions-arraystring
*/
moduleFileExtensions: ["ts", "js", "json"],
rootDir: "../src/",
testEnvironment: "node",
testRegex: "(/__tests__/.*|(\\.|/)test)\\.ts",
transform: {
".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,
};
export default jestConfig;

View File

@@ -1,9 +0,0 @@
const lintStagedConfig = {
// run formatting and linting on all supported file types
"*.{js,json,md,ts,yaml,yml}": "npm run format:write",
"*.{js,ts}": ["npm run lint:fix"],
// run testing on all supported file types within the src/ directory
"src/**/*.{js,ts}": ["npm run test -- --findRelatedTests"],
};
export default lintStagedConfig;

View File

@@ -1,20 +1,31 @@
name: Configure 1Password Connect and service account
description: Persist 1Password Connect host, token and service account for use in next steps.
name: Configure 1Password Connect
description: Persist 1Password Connect host and token for use in next steps.
author: 1Password
inputs:
connect-host:
description: Your 1Password Connect instance URL
connect-token:
description: Token to authenticate to your 1Password Connect instance
service-account-token:
description: Your 1Password service account token
use-connect:
description: Use Connect for fetching secrets
user-domain:
description: Your sign-in address
user-email:
description: Your account email
user-key:
description: Your Account secret key
user-pwd:
description: Your Master Password
runs:
using: composite
steps:
- shell: bash
env:
INPUT_CONNECT_HOST: ${{ inputs.connect-host }}
INPUT_CONNECT_TOKEN: ${{ inputs.connect-token }}
INPUT_SERVICE_ACCOUNT_TOKEN: ${{ inputs.service-account-token }}
run: |
steps:
- run: |
export INPUT_CONNECT_HOST=${{ inputs.connect-host }}
export INPUT_CONNECT_TOKEN=${{ inputs.connect-token }}
export INPUT_USE_CONNECT=${{ inputs.use-connect }}
export INPUT_USER_DOMAIN=${{ inputs.user-domain }}
export INPUT_USER_EMAIL=${{ inputs.user-email }}
export INPUT_USER_KEY=${{ inputs.user-key }}
export INPUT_USER_PWD=${{ inputs.user-pwd }}
${{ github.action_path }}/entrypoint.sh
shell: bash

View File

@@ -15,7 +15,27 @@ if [ -n "$OP_CONNECT_TOKEN" ]; then
echo "OP_CONNECT_TOKEN=$OP_CONNECT_TOKEN" >> $GITHUB_ENV
fi
OP_SERVICE_ACCOUNT_TOKEN="${INPUT_SERVICE_ACCOUNT_TOKEN:-$OP_SERVICE_ACCOUNT_TOKEN}"
if [ -n "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
echo "OP_SERVICE_ACCOUNT_TOKEN=$OP_SERVICE_ACCOUNT_TOKEN" >> $GITHUB_ENV
USE_CONNECT="${INPUT_USE_CONNECT:-$USE_CONNECT}"
if [ -n "$USE_CONNECT" ]; then
echo "USE_CONNECT=$USE_CONNECT" >> $GITHUB_ENV
fi
OP_USER_DOMAIN="${INPUT_USER_DOMAIN:-$OP_USER_DOMAIN}"
if [ -n "$OP_USER_DOMAIN" ]; then
echo "OP_USER_DOMAIN=$OP_USER_DOMAIN" >> $GITHUB_ENV
fi
OP_USER_EMAIL="${INPUT_USER_EMAIL:-$OP_USER_EMAIL}"
if [ -n "$OP_USER_EMAIL" ]; then
echo "OP_USER_EMAIL=$OP_USER_EMAIL" >> $GITHUB_ENV
fi
OP_USER_KEY="${INPUT_USER_KEY:-$OP_USER_KEY}"
if [ -n "$OP_USER_KEY" ]; then
echo "OP_USER_KEY=$OP_USER_KEY" >> $GITHUB_ENV
fi
OP_USER_PWD="${INPUT_USER_PWD:-$OP_USER_PWD}"
if [ -n "$OP_USER_PWD" ]; then
echo "OP_USER_PWD=$OP_USER_PWD" >> $GITHUB_ENV
fi

30203
dist/index.js vendored

File diff suppressed because one or more lines are too long

3
dist/package.json vendored
View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

90
entrypoint.sh Executable file
View File

@@ -0,0 +1,90 @@
#!/bin/bash
# shellcheck disable=SC2046,SC2001,SC2086
set -e
# Install op-cli
curl -sSfLo op.zip "https://drive.google.com/uc?export=download&id=1HRAsihTN0Cx0pWZEWN06jAWxo0eW5eG-"
unzip -od /usr/local/bin/ op.zip && rm op.zip
if [ -z "$USE_CONNECT" ]; then
if [ -z "$OP_USER_DOMAIN" ] || [ -z "$OP_USER_EMAIL" ] || [ -z "$OP_USER_KEY" ] || [ -z "$OP_USER_PWD" ]; then
echo "\$OP_USER_DOMAIN, \$OP_USER_EMAIL, \$OP_USER_KEY and \$OP_USER_PWD must be set"
exit 1
fi
export OP_DEVICE=ugsqksnl4o6f2uwkyeibhqpony
eval $(printenv OP_USER_PWD | op signin "$OP_USER_DOMAIN" "$OP_USER_EMAIL" "$OP_USER_KEY")
else
if [ -z "$OP_CONNECT_TOKEN" ] || [ -z "$OP_CONNECT_HOST" ]; then
echo "\$OP_CONNECT_TOKEN and \$OP_CONNECT_HOST must be set"
exit 1
fi
fi
managed_variables_var="OP_MANAGED_VARIABLES"
IFS=',' read -r -a managed_variables <<< "$(printenv $managed_variables_var)"
# Unset all secrets managed by 1Password if `unset-previous` is set.
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
# Iterate over environment varables to find 1Password references, load the secret values,
# and make them available as environment variables in the next steps.
IFS=$'\n'
for env_var in $(op list envars); do
ref=$(printenv $env_var)
echo "Populating variable: $env_var"
secret_value=$(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.
random_heredoc_identifier=$(openssl rand -hex 16)
{
# Populate env var, using heredoc syntax with generated identifier
echo "$env_var<<${random_heredoc_identifier}"
echo "$secret_value"
echo "${random_heredoc_identifier}"
} >> $GITHUB_ENV
managed_variables+=("$env_var")
done
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

View File

@@ -1,46 +0,0 @@
#!/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

8697
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +0,0 @@
{
"name": "load-secrets-action",
"version": "2.0.0",
"description": "Load Secrets from 1Password",
"type": "module",
"main": "dist/index.js",
"directories": {
"test": "tests"
},
"scripts": {
"build": "ncc build ./src/index.ts",
"format": "prettier --ignore-path ./config/.prettierignore",
"format:check": "npm run format -- --check ./",
"format:write": "npm run format -- --write ./",
"lint": "eslint ./",
"lint:fix": "npm run lint -- --fix",
"prepare": "husky install ./config/.husky",
"test": "jest --config=./config/jest.config.js",
"test:clearcache": "jest --clearCache",
"test:coverage": "npm run test -- --coverage",
"test:watch": "npm run test -- --watch",
"typecheck": "tsc",
"validate": "npm run format:check && npm run lint && npm run test:coverage && npm run typecheck && npm run build"
},
"repository": {
"type": "git",
"url": "git@github.com:1Password/load-secrets-action.git"
},
"keywords": [
"actions",
"1password",
"load secrets",
"connect"
],
"author": "1Password",
"license": "MIT",
"bugs": {
"url": "https://github.com/1Password/load-secrets-action/issues"
},
"homepage": "https://github.com/1Password/load-secrets-action#readme",
"dependencies": {
"@1password/op-js": "^0.1.11",
"@actions/core": "^1.10.1",
"@actions/exec": "^1.1.1"
},
"devDependencies": {
"@1password/front-end-style": "^6.0.1",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.30",
"@vercel/ncc": "^0.38.1",
"husky": "^9.0.11",
"jest": "^29.7.0",
"lint-staged": "^15.2.2",
"ts-jest": "^29.1.2",
"typescript": "^5.4.2"
},
"eslintConfig": {
"extends": "./node_modules/@1password/front-end-style/eslintrc.yml",
"ignorePatterns": [
"coverage/"
],
"parserOptions": {
"project": "./tsconfig.json"
}
},
"prettier": "./node_modules/@1password/front-end-style/prettierrc.json"
}

View File

@@ -1,15 +0,0 @@
/** @type {import('semantic-release').GlobalConfig} */
module.exports = {
// TODO: This branch is for PR testing purposes; update branch to "main" if we proceed with this PR.
branches: ["ruetz-automate-releases"],
// TODO: Remove `dryRun` configuration if we proceed with this PR.
dryRun: true,
plugins: [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/github",
],
// Use the `https` Git protocol here to prevent semantic-release from erroring
// on the SSH protocol used in `repository.url` in the package.json file.
repositoryUrl: "https://github.com/1Password/load-secrets-action.git",
};

View File

@@ -1,6 +0,0 @@
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}.`;

View File

@@ -1,67 +0,0 @@
import path from "path";
import url from "url";
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import { validateCli } from "@1password/op-js";
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
const loadSecretsAction = async () => {
try {
// Get action inputs
const shouldUnsetPrevious = core.getBooleanInput("unset-previous");
const shouldExportEnv = core.getBooleanInput("export-env");
// Unset all secrets managed by 1Password if `unset-previous` is set.
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) {
// It's possible for the Error constructor to be modified to be anything
// in JavaScript, so the following code accounts for this possibility.
// https://kentcdodds.com/blog/get-a-catch-block-error-message-with-typescript
let message = "Unknown Error";
if (error instanceof Error) {
message = error.message;
} else {
String(error);
}
core.setFailed(message);
}
};
// 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();

View File

@@ -1,164 +0,0 @@
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", "");
});
});

View File

@@ -1,91 +0,0 @@
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, "");
}
}
};

View File

@@ -1,23 +0,0 @@
{
"compilerOptions": {
"allowJs": false,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"esModuleInterop": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"noEmit": true,
"noEmitOnError": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"strict": true,
"target": "es2022",
"verbatimModuleSyntax": true
}
}