diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..cc3c745 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,10 @@ +on: pull_request +name: Lint + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: ShellCheck + uses: ludeeus/action-shellcheck@1.1.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..374245e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,51 @@ +on: push +name: Run acceptance tests + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Launch 1Password Connect instance + 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 1Password Connect + uses: ./configure # 1password/load-secrets-action/configure@ + with: + connect-host: http://localhost:8080 + connect-token: ${{ secrets.OP_CONNECT_TOKEN }} + - name: Load secrets + uses: ./ # 1password/load-secrets-action@ + env: + 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@ + 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@ + with: + unset-previous: true + - name: Print environment variables with secrets removed + run: printenv + - name: Assert removed secrets + run: ./tests/assert-env-unset.sh + - name: Load secret again + uses: ./ # 1password/load-secrets-action@ + env: + SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/password + SECRET_IN_SECTION: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/test-section/password + MULTILINE_SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/ghtz3jvcc6dqmzc53d3r3eskge/notesPlain + - name: Print environment variables with masked secrets + run: printenv + - name: Assert test secret values again + run: ./tests/assert-env-set.sh diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..0177386 --- /dev/null +++ b/action.yml @@ -0,0 +1,17 @@ +name: Load secrets from 1Password +description: Make secrets from 1Password Connect available as environment variables in the next steps. +author: 1Password +branding: + icon: lock + color: blue +inputs: + unset-previous: + description: Whether to unset environment variables populated by 1Password in earlier job steps + default: false +runs: + using: composite + steps: + - run: | + export INPUT_UNSET_PREVIOUS=${{ inputs.unset-previous }} + ${{ github.action_path }}/entrypoint.sh + shell: bash diff --git a/configure/action.yml b/configure/action.yml new file mode 100644 index 0000000..97a44b4 --- /dev/null +++ b/configure/action.yml @@ -0,0 +1,16 @@ +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 +runs: + using: composite + steps: + - run: | + export INPUT_CONNECT_HOST=${{ inputs.connect-host }} + export INPUT_CONNECT_TOKEN=${{ inputs.connect-token }} + ${{ github.action_path }}/entrypoint.sh + shell: bash diff --git a/configure/entrypoint.sh b/configure/entrypoint.sh new file mode 100755 index 0000000..a0a4494 --- /dev/null +++ b/configure/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# shellcheck disable=SC2086 +set -e + +# Capture Connect configuration in $GITHUB_ENV, giving (optional) inputs +# precendence over OP_CONNECT_* environment variables. + +OP_CONNECT_HOST="${INPUT_CONNECT_HOST:-$OP_CONNECT_HOST}" +if [ -n "$OP_CONNECT_HOST" ]; then + echo "OP_CONNECT_HOST=$OP_CONNECT_HOST" >> $GITHUB_ENV +fi + +OP_CONNECT_TOKEN="${INPUT_CONNECT_TOKEN:-$OP_CONNECT_TOKEN}" +if [ -n "$OP_CONNECT_TOKEN" ]; then + echo "OP_CONNECT_TOKEN=$OP_CONNECT_TOKEN" >> $GITHUB_ENV +fi diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..dde2fbe --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,134 @@ +#!/bin/bash +# shellcheck disable=SC2046,SC2001,SC2086 +set -e + +if [ -z "$OP_CONNECT_TOKEN" ] || [ -z "$OP_CONNECT_HOST" ]; then + echo "\$OP_CONNECT_TOKEN and \$OP_CONNECT_HOST must be set" + exit 1 +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 possible_ref in $(printenv | grep "=op://" | grep -v "^#"); do + env_var=$(echo "$possible_ref" | cut -d '=' -f1) + ref=$(printenv $env_var) + + if [[ ! $ref == "op://"* ]]; then + echo "Not really a reference: $ref" + continue + fi + + path=$(echo $ref | sed -e "s/^op:\/\///") + if [ $(echo "$path" | tr -cd '/' | wc -c) -lt 2 ]; then + echo "Expected path to be in format op:///[/
]/: $ref" + continue + fi + + echo "Populating variable: $env_var" + + vault="" + item="" + section="" + field="" + i=0 + IFS="/" + for component in $path; do + ((i+=1)) + case "$i" in + 1) vault=$component ;; + 2) item=$component ;; + 3) section=$component ;; + 4) field=$component ;; + esac + done + unset IFS + + # If field is not set, it may have wrongfully been interpreted as the section. + if [ -z "$field" ]; then + field="$section" + section="" + fi + + echo "Loading item $item from vault $vault..." + item_json=$(curl -sSf -H "Content-Type: application/json" -H "Authorization: Bearer $OP_CONNECT_TOKEN" "$OP_CONNECT_HOST/v1/vaults/$vault/items/$item") + jq_field_selector=".id == \"$field\" or .label == \"$field\"" + jq_section_selector=".section == null" + + # If the reference contains a section, edit the jq selector to take that into account. + if [ -n "$section" ]; then + echo "Looking for section: $section" + section_id=$(echo "$item_json" | jq -r ".sections[] | select(.id == \"$section\" or .label == \"$section\") | .id") + jq_section_selector=".section.id == \"$section_id\"" + fi + + jq_secret_selector="$jq_section_selector and ($jq_field_selector)" + + echo "Looking for field: $field" + secret_field_json=$(echo "$item_json" | jq -r "first(.fields[] | select($jq_secret_selector))") + + field_type=$(echo "$secret_field_json" | jq -r '.type') + field_purpose=$(echo "$secret_field_json" | jq -r '.purpose') + secret_value=$(echo "$secret_field_json" | jq -r '.value') + + if [ -z "$secret_value" ]; then + echo "Could not find or access secret $ref" + exit 1 + fi + + # If the field is marked as concealed or is a note, register a mask + # for the secret to prevent accidental log exposure. + if [ "$field_type" == "CONCEALED" ] || [ "$field_purpose" == "NOTES" ]; then + # 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 + fi + + # 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 diff --git a/tests/assert-env-set.sh b/tests/assert-env-set.sh new file mode 100755 index 0000000..1871707 --- /dev/null +++ b/tests/assert-env-set.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# shellcheck disable=SC2086 +set -e + +assert_env_equals() { + if [ "$(printenv $1)" != "$2" ]; then + echo -e "Expected $1 to be set to:\n$2\nBut got:\n$(printenv $1)" + exit 1 + fi +} + +assert_env_equals "SECRET" "RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu" + +assert_env_equals "SECRET_IN_SECTION" "RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu" + +assert_env_equals "MULTILINE_SECRET" "$(cat << EOF +-----BEGIN PRIVATE KEY----- +RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLApXaGls +ZSB3ZSBkZWVwbHkgYXBwcmVjaWF0ZSB5b3VyIHZp +Z2lsYW5jZSBhbmQgZWZmb3J0cyB0byBtYWtlIHRo +ZSB3b3JsZCBtb3JlIHNlY3VyZSwgSSdtIGFmcmFp +ZCBJIG11c3QgdGVsbCB5b3UgdGhhdCB0aGlzIHZh +bHVlIGlzIG5vdCBhIGFjdHVhbCBwcml2YXRlIGtl +eS4gCkl0J3MgYSBqdXN0IGEgZHVtbXkgc2VjcmV0 +IHRoYXQgd2UgdXNlIHRvIHRlc3QgdmFyaW91cyAx +UGFzc3dvcmQgc2VjcmV0cyBpbnRlZ3JhdGlvbnMu +IApTbyBwbGVhc2UgZG9uJ3QgcmVwb3J0IGl0IQo= +-----END PRIVATE KEY----- +EOF +)" diff --git a/tests/assert-env-unset.sh b/tests/assert-env-unset.sh new file mode 100755 index 0000000..e4c6448 --- /dev/null +++ b/tests/assert-env-unset.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# shellcheck disable=SC2086 +set -e + +assert_env_unset() { + if [ -n "$(printenv $1)" ]; then + echo "Expected secret $1 to be unset" + exit 1 + fi +} + +assert_env_unset "SECRET" +assert_env_unset "SECRET_IN_SECTION" +assert_env_unset "MULTILINE_SECRET" diff --git a/tests/fixtures/docker-compose.yml b/tests/fixtures/docker-compose.yml new file mode 100644 index 0000000..cd2f518 --- /dev/null +++ b/tests/fixtures/docker-compose.yml @@ -0,0 +1,20 @@ +version: "3.4" + +services: + op-connect-api: + image: 1password/connect-api:latest + ports: + - "8080:8080" + volumes: + - "$PWD/1password-credentials.json:/home/opuser/.op/1password-credentials.json" + - "data:/home/opuser/.op/data" + op-connect-sync: + image: 1password/connect-sync:latest + ports: + - "8081:8080" + volumes: + - "$PWD/1password-credentials.json:/home/opuser/.op/1password-credentials.json" + - "data:/home/opuser/.op/data" + +volumes: + data: