mirror of
https://github.com/1Password/load-secrets-action.git
synced 2026-06-21 14:23:48 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86d5645f22 | |||
| 24aa37b1b0 | |||
| 40256dc361 | |||
| 7b7cb42941 | |||
| cc789f0882 | |||
| d1dad6d749 | |||
| d463472f19 | |||
| da7c7c6490 | |||
| d367634d5e | |||
| 1953bd007b | |||
| f3b8e180f2 | |||
| 6ec01615e5 |
@@ -20,12 +20,6 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
OP_SERVICE_ACCOUNT_TOKEN:
|
OP_SERVICE_ACCOUNT_TOKEN:
|
||||||
required: true
|
required: true
|
||||||
OP_WORKLOAD_ID:
|
|
||||||
required: true
|
|
||||||
OP_ENVIRONMENT_ID:
|
|
||||||
required: true
|
|
||||||
OP_INTEGRATION_KEY:
|
|
||||||
required: true
|
|
||||||
VAULT:
|
VAULT:
|
||||||
description: "1Password vault name or UUID"
|
description: "1Password vault name or UUID"
|
||||||
required: true
|
required: true
|
||||||
@@ -254,73 +248,3 @@ jobs:
|
|||||||
- name: Assert removed secrets [exported env]
|
- name: Assert removed secrets [exported env]
|
||||||
if: ${{ matrix.export-env }}
|
if: ${{ matrix.export-env }}
|
||||||
run: ./tests/assert-env-unset.sh
|
run: ./tests/assert-env-unset.sh
|
||||||
|
|
||||||
test-workload-identity:
|
|
||||||
name: Workload Identity (ubuntu-latest, export-env=${{ matrix.export-env }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
strategy:
|
|
||||||
fail-fast: true
|
|
||||||
matrix:
|
|
||||||
export-env: [true, false]
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ inputs.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build actions
|
|
||||||
run: npm run build:all
|
|
||||||
|
|
||||||
- name: Load secrets
|
|
||||||
id: load_secrets
|
|
||||||
uses: ./
|
|
||||||
with:
|
|
||||||
export-env: ${{ matrix.export-env }}
|
|
||||||
env:
|
|
||||||
OP_WORKLOAD_ID: ${{ secrets.OP_WORKLOAD_ID }}
|
|
||||||
OP_ENVIRONMENT_ID: ${{ secrets.OP_ENVIRONMENT_ID }}
|
|
||||||
OP_INTEGRATION_KEY: ${{ secrets.OP_INTEGRATION_KEY }}
|
|
||||||
|
|
||||||
- name: Assert test secret values [step output]
|
|
||||||
if: ${{ !matrix.export-env }}
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
ANOTHER_TEST: ${{ steps.load_secrets.outputs.ANOTHER_TEST }}
|
|
||||||
SUPER_SECRET: ${{ steps.load_secrets.outputs.SUPER_SECRET }}
|
|
||||||
TEST_SECRET: ${{ steps.load_secrets.outputs.TEST_SECRET }}
|
|
||||||
run: ./tests/assert-workload-identity.sh
|
|
||||||
|
|
||||||
- name: Assert test secret values [exported env]
|
|
||||||
if: ${{ matrix.export-env }}
|
|
||||||
shell: bash
|
|
||||||
run: ./tests/assert-workload-identity.sh
|
|
||||||
|
|
||||||
- name: Remove secrets [exported env]
|
|
||||||
if: ${{ matrix.export-env }}
|
|
||||||
uses: ./
|
|
||||||
with:
|
|
||||||
unset-previous: true
|
|
||||||
|
|
||||||
- name: Assert removed secrets [exported env]
|
|
||||||
if: ${{ matrix.export-env }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
for var in ANOTHER_TEST SUPER_SECRET TEST_SECRET; do
|
|
||||||
if [ -n "$(printenv "$var")" ]; then
|
|
||||||
echo "Expected secret $var to be unset"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: E2E Tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, jill/test-verification]
|
||||||
paths-ignore: &ignore_paths
|
paths-ignore: &ignore_paths
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
- "config/**"
|
- "config/**"
|
||||||
@@ -69,6 +69,10 @@ jobs:
|
|||||||
echo "condition=push-to-main" >> $GITHUB_OUTPUT
|
echo "condition=push-to-main" >> $GITHUB_OUTPUT
|
||||||
echo "Setting condition=push-to-main (push to main)"
|
echo "Setting condition=push-to-main (push to main)"
|
||||||
echo "ref=${{ github.sha }}" >> $GITHUB_OUTPUT
|
echo "ref=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||||
|
elif [ "${{ github.event_name }}" == "push" ] && [ "${REF_NAME}" == "jill/test-verification" ]; then
|
||||||
|
echo "condition=push-to-test-branch" >> $GITHUB_OUTPUT
|
||||||
|
echo "Setting condition=push-to-test-branch (push to jill/test-verification)"
|
||||||
|
echo "ref=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
# Unknown event type
|
# Unknown event type
|
||||||
echo "condition=skip" >> $GITHUB_OUTPUT
|
echo "condition=skip" >> $GITHUB_OUTPUT
|
||||||
@@ -85,6 +89,8 @@ jobs:
|
|||||||
(needs.check-external-pr.outputs.condition == 'dispatch-event')
|
(needs.check-external-pr.outputs.condition == 'dispatch-event')
|
||||||
||
|
||
|
||||||
needs.check-external-pr.outputs.condition == 'push-to-main'
|
needs.check-external-pr.outputs.condition == 'push-to-main'
|
||||||
|
||
|
||||||
|
needs.check-external-pr.outputs.condition == 'push-to-test-branch'
|
||||||
uses: ./.github/workflows/e2e-tests.yml
|
uses: ./.github/workflows/e2e-tests.yml
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.check-external-pr.outputs.ref }}
|
ref: ${{ needs.check-external-pr.outputs.ref }}
|
||||||
@@ -92,9 +98,6 @@ jobs:
|
|||||||
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
OP_WORKLOAD_ID: ${{ secrets.OP_WORKLOAD_ID }}
|
|
||||||
OP_ENVIRONMENT_ID: ${{ secrets.OP_ENVIRONMENT_ID }}
|
|
||||||
OP_INTEGRATION_KEY: ${{ secrets.OP_INTEGRATION_KEY }}
|
|
||||||
VAULT: ${{ secrets.VAULT }}
|
VAULT: ${{ secrets.VAULT }}
|
||||||
|
|
||||||
# Post comment on fork PRs after /ok-to-test
|
# Post comment on fork PRs after /ok-to-test
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ Specify in your workflow YAML file which secrets from 1Password should be loaded
|
|||||||
|
|
||||||
Read more on the [1Password Developer Portal](https://developer.1password.com/docs/ci-cd/github-actions).
|
Read more on the [1Password Developer Portal](https://developer.1password.com/docs/ci-cd/github-actions).
|
||||||
|
|
||||||
_This project is licensed under [MIT](./LICENSE). Use of the 1Password APIs and services accessed through these tools is governed by the [1Password API Terms of Service](https://1password.com/legal/api-sdk-terms-of-service)._
|
|
||||||
|
|
||||||
## 🪄 See it in action!
|
## 🪄 See it in action!
|
||||||
|
|
||||||
[](https://www.youtube.com/watch?v=kVBl5iQYgSA "Using 1Password Service Accounts with GitHub Actions")
|
[](https://www.youtube.com/watch?v=kVBl5iQYgSA "Using 1Password Service Accounts with GitHub Actions")
|
||||||
@@ -88,35 +86,6 @@ When loading SSH keys, you can specify the format using the `ssh-format` query p
|
|||||||
|
|
||||||
For more details on secret reference syntax, see the [1Password CLI documentation](https://developer.1password.com/docs/cli/secret-reference-syntax/#ssh-format-parameter).
|
For more details on secret reference syntax, see the [1Password CLI documentation](https://developer.1password.com/docs/cli/secret-reference-syntax/#ssh-format-parameter).
|
||||||
|
|
||||||
## 🧪 Workload Identity (private beta)
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Workload Identity is in **private beta**. It's available to invited participants only. [Contact 1Password](https://developer.1password.com/joinslack) if you're interested in joining the beta.
|
|
||||||
|
|
||||||
Instead of a Service Account token or Connect credentials, you can authenticate using Workload Identity, which exchanges your GitHub Actions OIDC token for short-lived 1Password access. To use it, set all three of the following environment variables (and do not set the Service Account token or the Connect variables):
|
|
||||||
|
|
||||||
```yml
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
hello-world:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
id-token: write # required for the action to request a GitHub OIDC token
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Load secret
|
|
||||||
id: load_secrets
|
|
||||||
uses: 1password/load-secrets-action@v5beta
|
|
||||||
env:
|
|
||||||
OP_WORKLOAD_ID: ${{ vars.OP_WORKLOAD_ID }}
|
|
||||||
OP_ENVIRONMENT_ID: ${{ vars.OP_ENVIRONMENT_ID }}
|
|
||||||
OP_INTEGRATION_KEY: ${{ secrets.OP_INTEGRATION_KEY }}
|
|
||||||
```
|
|
||||||
|
|
||||||
Unlike the Service Account and Connect flows, you don't select secrets with individual `op://` references. Instead, **all variables defined in the configured 1Password environment are loaded** and each one is exported as an environment variable (or set as a step output). Scope your environment to only the variables you want available to the job.
|
|
||||||
|
|
||||||
If only some of the three variables are set, or if they're combined with another authentication method, the action fails with a configuration error.
|
|
||||||
|
|
||||||
## 💙 Community & Support
|
## 💙 Community & Support
|
||||||
|
|
||||||
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.
|
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const jestConfig = {
|
|||||||
"^@actions/core$": "<rootDir>/__mocks__/actions-core.ts",
|
"^@actions/core$": "<rootDir>/__mocks__/actions-core.ts",
|
||||||
"^@actions/tool-cache$": "<rootDir>/__mocks__/actions-tool-cache.ts",
|
"^@actions/tool-cache$": "<rootDir>/__mocks__/actions-tool-cache.ts",
|
||||||
"^@actions/exec$": "<rootDir>/__mocks__/actions-exec.ts",
|
"^@actions/exec$": "<rootDir>/__mocks__/actions-exec.ts",
|
||||||
"^@1password/sdk$": "<rootDir>/__mocks__/1password-sdk.ts",
|
|
||||||
},
|
},
|
||||||
transform: {
|
transform: {
|
||||||
".ts": [
|
".ts": [
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
+203
-2941
File diff suppressed because one or more lines are too long
Vendored
+49
@@ -0,0 +1,49 @@
|
|||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
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
|
||||||
|
P4I7jHwe0BN3iNAG1nKbGg==
|
||||||
|
=+LeX
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
Generated
+2
-18
@@ -1,16 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "load-secrets-action",
|
"name": "load-secrets-action",
|
||||||
"version": "5.0.0-beta.1",
|
"version": "4.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "load-secrets-action",
|
"name": "load-secrets-action",
|
||||||
"version": "5.0.0-beta.1",
|
"version": "4.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@1password/op-js": "^0.1.11",
|
"@1password/op-js": "^0.1.11",
|
||||||
"@1password/sdk": "0.5.0-beta.1",
|
|
||||||
"@actions/core": "^3.0.0",
|
"@actions/core": "^3.0.0",
|
||||||
"@actions/exec": "^3.0.0",
|
"@actions/exec": "^3.0.0",
|
||||||
"@actions/tool-cache": "^4.0.0",
|
"@actions/tool-cache": "^4.0.0",
|
||||||
@@ -73,21 +72,6 @@
|
|||||||
"prettier": "^2.0.0 || ^3.0.0"
|
"prettier": "^2.0.0 || ^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@1password/sdk": {
|
|
||||||
"version": "0.5.0-beta.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@1password/sdk/-/sdk-0.5.0-beta.1.tgz",
|
|
||||||
"integrity": "sha512-GY1kcn86qkb39jt20AyOftEu5Tw/Kyq4f84GOHXKRjur4TvqvzdhapynBBosRcBL+kBrc+E8cx7Tp7GEfqAomw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@1password/sdk-core": "0.5.0-beta.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@1password/sdk-core": {
|
|
||||||
"version": "0.5.0-beta.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@1password/sdk-core/-/sdk-core-0.5.0-beta.1.tgz",
|
|
||||||
"integrity": "sha512-61Q2n0kKYXBVAbW5ZVFqtbK1KX3lUfFi8wdsv+UjIVtbFd+X1GpFbLFs+nPtPgX+Z7oc2tTN/czK0S9Cz4oF/A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@actions/core": {
|
"node_modules/@actions/core": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz",
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "load-secrets-action",
|
"name": "load-secrets-action",
|
||||||
"version": "5.0.0-beta.1",
|
"version": "4.0.0",
|
||||||
"description": "Load Secrets from 1Password",
|
"description": "Load Secrets from 1Password",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
@@ -40,7 +40,6 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/1Password/load-secrets-action#readme",
|
"homepage": "https://github.com/1Password/load-secrets-action#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@1password/sdk": "0.5.0-beta.1",
|
|
||||||
"@1password/op-js": "^0.1.11",
|
"@1password/op-js": "^0.1.11",
|
||||||
"@actions/core": "^3.0.0",
|
"@actions/core": "^3.0.0",
|
||||||
"@actions/exec": "^3.0.0",
|
"@actions/exec": "^3.0.0",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const createClient = jest.fn();
|
|
||||||
@@ -11,6 +11,4 @@ module.exports = {
|
|||||||
debug: jest.fn(),
|
debug: jest.fn(),
|
||||||
addPath: jest.fn(),
|
addPath: jest.fn(),
|
||||||
isDebug: jest.fn(() => false),
|
isDebug: jest.fn(() => false),
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
getIDToken: jest.fn().mockResolvedValue("mock-oidc-token"),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,5 @@ export const envConnectToken = "OP_CONNECT_TOKEN";
|
|||||||
export const envServiceAccountToken = "OP_SERVICE_ACCOUNT_TOKEN";
|
export const envServiceAccountToken = "OP_SERVICE_ACCOUNT_TOKEN";
|
||||||
export const envManagedVariables = "OP_MANAGED_VARIABLES";
|
export const envManagedVariables = "OP_MANAGED_VARIABLES";
|
||||||
export const envFilePath = "OP_ENV_FILE";
|
export const envFilePath = "OP_ENV_FILE";
|
||||||
export const envWorkloadId = "OP_WORKLOAD_ID";
|
|
||||||
export const envEnvironmentId = "OP_ENVIRONMENT_ID";
|
|
||||||
export const envIntegrationKey = "OP_INTEGRATION_KEY";
|
|
||||||
|
|
||||||
export const authErr = `Authentication error with environment variables: you must set either 1) ${envServiceAccountToken}, or 2) both ${envConnectHost} and ${envConnectToken}.`;
|
export const authErr = `Authentication error with environment variables: you must set either 1) ${envServiceAccountToken}, or 2) both ${envConnectHost} and ${envConnectToken}.`;
|
||||||
|
|||||||
+1
-29
@@ -2,14 +2,7 @@ import dotenv from "dotenv";
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { validateCli } from "@1password/op-js";
|
import { validateCli } from "@1password/op-js";
|
||||||
import { installCliOnGithubActionRunner } from "./op-cli-installer";
|
import { installCliOnGithubActionRunner } from "./op-cli-installer";
|
||||||
import {
|
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
|
||||||
getWorkloadIdentityConfig,
|
|
||||||
hasCliAuth,
|
|
||||||
loadSecrets,
|
|
||||||
unsetPrevious,
|
|
||||||
validateAuth,
|
|
||||||
} from "./utils";
|
|
||||||
import { loadSecretsFromSDK } from "./sdk-client";
|
|
||||||
import { envFilePath } from "./constants";
|
import { envFilePath } from "./constants";
|
||||||
|
|
||||||
const loadSecretsAction = async () => {
|
const loadSecretsAction = async () => {
|
||||||
@@ -23,26 +16,6 @@ const loadSecretsAction = async () => {
|
|||||||
unsetPrevious();
|
unsetPrevious();
|
||||||
}
|
}
|
||||||
|
|
||||||
const workloadConfig = getWorkloadIdentityConfig();
|
|
||||||
|
|
||||||
// `unset-previous` can run with no credentials present: Workload Identity creds
|
|
||||||
// are inline per-step and intentionally not persisted (persisting them would make
|
|
||||||
// every later step re-load all variables). Nothing to auth or load, we're done.
|
|
||||||
if (shouldUnsetPrevious && !workloadConfig && !hasCliAuth()) {
|
|
||||||
core.info(
|
|
||||||
"No authentication configured; unset previously managed variables. No secrets were loaded.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workloadConfig) {
|
|
||||||
await loadSecretsFromSDK(
|
|
||||||
workloadConfig.workloadId,
|
|
||||||
workloadConfig.environmentId,
|
|
||||||
workloadConfig.integrationKey,
|
|
||||||
shouldExportEnv,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Validate that a proper authentication configuration is set for the CLI
|
// Validate that a proper authentication configuration is set for the CLI
|
||||||
validateAuth();
|
validateAuth();
|
||||||
|
|
||||||
@@ -58,7 +31,6 @@ const loadSecretsAction = async () => {
|
|||||||
|
|
||||||
// Load secrets
|
// Load secrets
|
||||||
await loadSecrets(shouldExportEnv);
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
ONEPASSWORD_GPG_KEY_FINGERPRINT,
|
||||||
|
verifyLinuxSignature,
|
||||||
|
} from "./linux-signature";
|
||||||
|
|
||||||
|
describe("verifyLinuxSignature", () => {
|
||||||
|
const OP_PATH = "/tmp/op";
|
||||||
|
const SIG_PATH = `${OP_PATH}.sig`;
|
||||||
|
const CORRECT_FPR = `fpr:::::::::${ONEPASSWORD_GPG_KEY_FINGERPRINT}:\n`;
|
||||||
|
const WRONG_FPR = `fpr:::::::::DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF:\n`;
|
||||||
|
|
||||||
|
const gpgRunner = (...responses: (string | Error)[]) => {
|
||||||
|
const runner = jest.fn<Promise<string>, [readonly string[]]>();
|
||||||
|
for (const r of responses) {
|
||||||
|
if (r instanceof Error) {
|
||||||
|
runner.mockRejectedValueOnce(r);
|
||||||
|
} else {
|
||||||
|
runner.mockResolvedValueOnce(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runner;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subcommandsCalled = (runner: ReturnType<typeof gpgRunner>) =>
|
||||||
|
runner.mock.calls.map(([args]: [readonly string[]]) =>
|
||||||
|
args.find(
|
||||||
|
(a) => a === "--import" || a === "--list-keys" || a === "--verify",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("imports the bundled key and verifies the signature", async () => {
|
||||||
|
const runner = gpgRunner("", CORRECT_FPR, "");
|
||||||
|
await expect(
|
||||||
|
verifyLinuxSignature(OP_PATH, SIG_PATH, runner),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(subcommandsCalled(runner)).toEqual([
|
||||||
|
"--import",
|
||||||
|
"--list-keys",
|
||||||
|
"--verify",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws and skips --verify when the imported key has the wrong fingerprint", async () => {
|
||||||
|
const runner = gpgRunner("", WRONG_FPR);
|
||||||
|
await expect(
|
||||||
|
verifyLinuxSignature(OP_PATH, SIG_PATH, runner),
|
||||||
|
).rejects.toThrow(/does not match expected/);
|
||||||
|
expect(subcommandsCalled(runner)).toEqual(["--import", "--list-keys"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when gpg --verify rejects the signature", async () => {
|
||||||
|
const runner = gpgRunner("", CORRECT_FPR, new Error("BAD signature"));
|
||||||
|
await expect(
|
||||||
|
verifyLinuxSignature(OP_PATH, SIG_PATH, runner),
|
||||||
|
).rejects.toThrow(/BAD signature/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { execFile } from "child_process";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
// 1Password's code-signing GPG key fingerprint. See
|
||||||
|
// https://www.1password.dev/cli/verify.
|
||||||
|
export const ONEPASSWORD_GPG_KEY_FINGERPRINT =
|
||||||
|
"3FEF9748469ADBE15DA7CA80AC2D62742012EA22";
|
||||||
|
|
||||||
|
// Bundled 1Password code-signing public key `linux-signing-key.asc` in
|
||||||
|
// this directory. Bundled to avoid a runtime keyserver/URL dependency.
|
||||||
|
// Source: https://downloads.1password.com/linux/keys/1password.asc
|
||||||
|
const ONEPASSWORD_GPG_PUBLIC_KEY_PATH = path.join(
|
||||||
|
__dirname,
|
||||||
|
"linux-signing-key.asc",
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultGpgRunner = async (args: readonly string[]): Promise<string> => {
|
||||||
|
const { stdout } = await execFileAsync("gpg", args);
|
||||||
|
return stdout;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Throws unless the binary at opPath carries a valid GPG signature (at
|
||||||
|
// sigPath) from the pinned 1Password key. The key is bundled with the action
|
||||||
|
export const verifyLinuxSignature = async (
|
||||||
|
opPath: string,
|
||||||
|
sigPath: string,
|
||||||
|
runGpg: (args: readonly string[]) => Promise<string> = defaultGpgRunner,
|
||||||
|
): Promise<void> => {
|
||||||
|
const gpgHome = fs.mkdtempSync(path.join(os.tmpdir(), "op-verify-"));
|
||||||
|
try {
|
||||||
|
const baseArgs = ["--homedir", gpgHome, "--batch", "--no-tty"];
|
||||||
|
|
||||||
|
// Import the bundled key into the temp keyring.
|
||||||
|
await runGpg([...baseArgs, "--import", ONEPASSWORD_GPG_PUBLIC_KEY_PATH]);
|
||||||
|
|
||||||
|
// Confirm we imported the pinned key.
|
||||||
|
const keyringListing = await runGpg([
|
||||||
|
...baseArgs,
|
||||||
|
"--list-keys",
|
||||||
|
"--with-colons",
|
||||||
|
]);
|
||||||
|
if (!keyringListing.includes(`${ONEPASSWORD_GPG_KEY_FINGERPRINT}:`)) {
|
||||||
|
throw new Error(
|
||||||
|
`bundled GPG key does not match expected fingerprint ${ONEPASSWORD_GPG_KEY_FINGERPRINT}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify op.sig against op using the imported key.
|
||||||
|
await runGpg([...baseArgs, "--verify", sigPath, opPath]);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new Error(
|
||||||
|
`1Password CLI signature verification failed: ${message}. ` +
|
||||||
|
"If 1Password has rotated their GPG signing key, this action needs to be updated — please file an issue at https://github.com/1Password/load-secrets-action/issues.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(gpgHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
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
|
||||||
|
P4I7jHwe0BN3iNAG1nKbGg==
|
||||||
|
=+LeX
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
@@ -2,7 +2,6 @@ import os from "os";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
archMap,
|
archMap,
|
||||||
CliInstaller,
|
|
||||||
cliUrlBuilder,
|
cliUrlBuilder,
|
||||||
type SupportedPlatform,
|
type SupportedPlatform,
|
||||||
} from "./cli-installer";
|
} from "./cli-installer";
|
||||||
@@ -25,9 +24,7 @@ describe("LinuxInstaller", () => {
|
|||||||
|
|
||||||
it("should call install with correct URL", async () => {
|
it("should call install with correct URL", async () => {
|
||||||
const installer = new LinuxInstaller(version);
|
const installer = new LinuxInstaller(version);
|
||||||
const installMock = jest
|
const installMock = jest.spyOn(installer, "install").mockResolvedValue();
|
||||||
.spyOn(CliInstaller.prototype, "install")
|
|
||||||
.mockResolvedValue();
|
|
||||||
|
|
||||||
await installer.installCli();
|
await installer.installCli();
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
import * as tc from "@actions/tool-cache";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CliInstaller,
|
CliInstaller,
|
||||||
cliUrlBuilder,
|
cliUrlBuilder,
|
||||||
type SupportedPlatform,
|
type SupportedPlatform,
|
||||||
} from "./cli-installer";
|
} from "./cli-installer";
|
||||||
import type { Installer } from "./installer";
|
import type { Installer } from "./installer";
|
||||||
|
import { verifyLinuxSignature } from "./linux-signature";
|
||||||
|
|
||||||
export class LinuxInstaller extends CliInstaller implements Installer {
|
export class LinuxInstaller extends CliInstaller implements Installer {
|
||||||
private readonly platform: SupportedPlatform = "linux"; // Node.js platform identifier for Linux
|
private readonly platform: SupportedPlatform = "linux"; // Node.js platform identifier for Linux
|
||||||
@@ -14,6 +20,23 @@ export class LinuxInstaller extends CliInstaller implements Installer {
|
|||||||
|
|
||||||
public async installCli(): Promise<void> {
|
public async installCli(): Promise<void> {
|
||||||
const urlBuilder = cliUrlBuilder[this.platform];
|
const urlBuilder = cliUrlBuilder[this.platform];
|
||||||
await super.install(urlBuilder(this.version, this.arch));
|
await this.install(urlBuilder(this.version, this.arch));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async install(url: string): Promise<void> {
|
||||||
|
console.info(`Downloading 1Password CLI from: ${url}`);
|
||||||
|
const downloadPath = await tc.downloadTool(url);
|
||||||
|
console.info("Installing 1Password CLI");
|
||||||
|
const extractedPath = await tc.extractZip(downloadPath);
|
||||||
|
|
||||||
|
core.info("Verifying 1Password CLI signature");
|
||||||
|
await verifyLinuxSignature(
|
||||||
|
path.join(extractedPath, "op"),
|
||||||
|
path.join(extractedPath, "op.sig"),
|
||||||
|
);
|
||||||
|
core.info("1Password CLI signature verified");
|
||||||
|
|
||||||
|
core.addPath(extractedPath);
|
||||||
|
core.info("1Password CLI installed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS,
|
||||||
|
APPLE_DEVELOPER_TEAM_ID,
|
||||||
|
verifyMacOsPackageSignature,
|
||||||
|
} from "./macos-signature";
|
||||||
|
|
||||||
|
const VALID_FINGERPRINT = ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS[0]!;
|
||||||
|
|
||||||
|
const buildPkgutilOutput = ({
|
||||||
|
teamId = APPLE_DEVELOPER_TEAM_ID,
|
||||||
|
signerFingerprint = VALID_FINGERPRINT,
|
||||||
|
}: {
|
||||||
|
teamId?: string;
|
||||||
|
signerFingerprint?: string;
|
||||||
|
} = {}): string => {
|
||||||
|
const bytes = signerFingerprint.match(/.{2}/g)!;
|
||||||
|
const fprLines = ` ${bytes.slice(0, 24).join(" ")}\n ${bytes.slice(24).join(" ")}`;
|
||||||
|
return `Package "op.pkg":
|
||||||
|
Certificate Chain:
|
||||||
|
1. Developer ID Installer: AgileBits Inc. (${teamId})
|
||||||
|
SHA256 Fingerprint:
|
||||||
|
${fprLines}
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
2. Developer ID Certification Authority
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pkgutilRunner = (output: string) =>
|
||||||
|
jest.fn<Promise<string>, [string]>().mockResolvedValue(output);
|
||||||
|
|
||||||
|
describe("verifyMacOsPackageSignature", () => {
|
||||||
|
it("passes for a pkg signed by AgileBits with an allowlisted cert", async () => {
|
||||||
|
const runner = pkgutilRunner(buildPkgutilOutput());
|
||||||
|
await expect(
|
||||||
|
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if the signer is not under the AgileBits team ID", async () => {
|
||||||
|
const runner = pkgutilRunner(buildPkgutilOutput({ teamId: "ATTACKER" }));
|
||||||
|
await expect(
|
||||||
|
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
|
||||||
|
).rejects.toThrow(/expected developer team ID 2BUA8C4S2C not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if the signer cert fingerprint is not on the allowlist", async () => {
|
||||||
|
const runner = pkgutilRunner(
|
||||||
|
buildPkgutilOutput({
|
||||||
|
signerFingerprint:
|
||||||
|
"DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
|
||||||
|
).rejects.toThrow(/not on the allowlist/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { execFile } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
// See https://www.1password.dev/cli/verify.
|
||||||
|
export const APPLE_DEVELOPER_TEAM_ID = "2BUA8C4S2C";
|
||||||
|
|
||||||
|
// Append-only: old certs stay listed so historical `op` versions still verify.
|
||||||
|
// See https://www.1password.dev/cli/verify.
|
||||||
|
export const ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS = [
|
||||||
|
"CAB578061B0209FB70934DA344EF6FEBCD3279B1C074C54B0D7D555743B9D89F",
|
||||||
|
"141DD87B2B231211F1440849798007DF621DE6EB3DAB985BC964EE9704C4A1C1",
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultPkgutilRunner = async (pkgPath: string): Promise<string> => {
|
||||||
|
const { stdout } = await execFileAsync("pkgutil", [
|
||||||
|
"--check-signature",
|
||||||
|
pkgPath,
|
||||||
|
]);
|
||||||
|
return stdout;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns just entry 1 (the signer cert) from the chain.
|
||||||
|
const extractSignerCertSection = (pkgutilOutput: string): string | null => {
|
||||||
|
const chainStart = pkgutilOutput.indexOf("Certificate Chain:");
|
||||||
|
if (chainStart === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const chainBody = pkgutilOutput.slice(chainStart);
|
||||||
|
const secondCert = /\n\s*2\.\s/.exec(chainBody);
|
||||||
|
return secondCert ? chainBody.slice(0, secondCert.index) : chainBody;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSignerFingerprint = (signerSection: string): string | null => {
|
||||||
|
const match = /SHA256 Fingerprint:\s*\n((?:[ \t]+[0-9A-Fa-f ]+\n?)+)/.exec(
|
||||||
|
signerSection,
|
||||||
|
);
|
||||||
|
const captured = match?.[1];
|
||||||
|
return captured ? captured.replace(/\s+/g, "").toUpperCase() : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hard-fails if the .pkg at pkgPath is not signed by AgileBits Inc.
|
||||||
|
// (2BUA8C4S2C) with a certificate on the allowlist above. Must run
|
||||||
|
// before any extraction of the .pkg contents.
|
||||||
|
export const verifyMacOsPackageSignature = async (
|
||||||
|
pkgPath: string,
|
||||||
|
runPkgutil: (pkgPath: string) => Promise<string> = defaultPkgutilRunner,
|
||||||
|
): Promise<void> => {
|
||||||
|
const stdout = await runPkgutil(pkgPath);
|
||||||
|
|
||||||
|
const signerSection = extractSignerCertSection(stdout);
|
||||||
|
if (!signerSection) {
|
||||||
|
throw new Error(
|
||||||
|
`1Password CLI signature verification failed: could not locate certificate chain in pkgutil output.\npkgutil output:\n${stdout}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signerSection.includes(`(${APPLE_DEVELOPER_TEAM_ID})`)) {
|
||||||
|
throw new Error(
|
||||||
|
`1Password CLI signature verification failed: expected developer team ID ${APPLE_DEVELOPER_TEAM_ID} not found in signer certificate.\npkgutil output:\n${stdout}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signerFingerprint = parseSignerFingerprint(signerSection);
|
||||||
|
if (!signerFingerprint) {
|
||||||
|
throw new Error(
|
||||||
|
`1Password CLI signature verification failed: could not parse signer cert SHA-256 fingerprint.\npkgutil output:\n${stdout}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS.includes(signerFingerprint)) {
|
||||||
|
throw new Error(
|
||||||
|
`1Password CLI signature verification failed: signer cert SHA-256 fingerprint ${signerFingerprint} is not on the allowlist. ` +
|
||||||
|
"If 1Password has rotated their installer signing cert, this action needs to be updated — please file an issue at https://github.com/1Password/load-secrets-action/issues.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type SupportedPlatform,
|
type SupportedPlatform,
|
||||||
} from "./cli-installer";
|
} from "./cli-installer";
|
||||||
import { type Installer } from "./installer";
|
import { type Installer } from "./installer";
|
||||||
|
import { verifyMacOsPackageSignature } from "./macos-signature";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@ export class MacOsInstaller extends CliInstaller implements Installer {
|
|||||||
const pkgWithExtension = `${pkgPath}.pkg`;
|
const pkgWithExtension = `${pkgPath}.pkg`;
|
||||||
fs.renameSync(pkgPath, pkgWithExtension);
|
fs.renameSync(pkgPath, pkgWithExtension);
|
||||||
|
|
||||||
|
core.info("Verifying 1Password CLI signature");
|
||||||
|
await verifyMacOsPackageSignature(pkgWithExtension);
|
||||||
|
core.info("1Password CLI signature verified");
|
||||||
|
|
||||||
const expandDir = "temp-pkg";
|
const expandDir = "temp-pkg";
|
||||||
await execFileAsync("pkgutil", ["--expand", pkgWithExtension, expandDir]);
|
await execFileAsync("pkgutil", ["--expand", pkgWithExtension, expandDir]);
|
||||||
const payloadPath = path.join(expandDir, "op.pkg", "Payload");
|
const payloadPath = path.join(expandDir, "op.pkg", "Payload");
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
verifyAuthenticodeSignature,
|
||||||
|
WINDOWS_SIGNER_SUBJECT_CN,
|
||||||
|
} from "./windows-signature";
|
||||||
|
|
||||||
|
describe("verifyAuthenticodeSignature", () => {
|
||||||
|
const OP_EXE = "C:\\op\\op.exe";
|
||||||
|
|
||||||
|
const buildAuthenticodeOutput = ({
|
||||||
|
status = "Valid",
|
||||||
|
subject = `CN=${WINDOWS_SIGNER_SUBJECT_CN}, O=Agilebits, C=CA`,
|
||||||
|
}: { status?: string; subject?: string } = {}): string =>
|
||||||
|
[`Status=${status}`, `Subject=${subject}`].join("\n") + "\n";
|
||||||
|
|
||||||
|
const powershellRunner = (output: string) =>
|
||||||
|
jest.fn<Promise<string>, [string]>().mockResolvedValue(output);
|
||||||
|
|
||||||
|
it("passes for a valid AgileBits-signed binary", async () => {
|
||||||
|
const runner = powershellRunner(buildAuthenticodeOutput());
|
||||||
|
await expect(
|
||||||
|
verifyAuthenticodeSignature(OP_EXE, runner),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if Status is not Valid (unsigned or tampered)", async () => {
|
||||||
|
const runner = powershellRunner(
|
||||||
|
buildAuthenticodeOutput({ status: "HashMismatch" }),
|
||||||
|
);
|
||||||
|
await expect(verifyAuthenticodeSignature(OP_EXE, runner)).rejects.toThrow(
|
||||||
|
/Authenticode status is HashMismatch/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if the signer is not AgileBits", async () => {
|
||||||
|
const runner = powershellRunner(
|
||||||
|
buildAuthenticodeOutput({ subject: "CN=Attacker, O=Attacker, C=US" }),
|
||||||
|
);
|
||||||
|
await expect(verifyAuthenticodeSignature(OP_EXE, runner)).rejects.toThrow(
|
||||||
|
/does not contain CN=Agilebits/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { execFile } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
// Identifying field of 1Password's Authenticode signing cert for op.exe.
|
||||||
|
// See https://www.1password.dev/cli/verify.
|
||||||
|
export const WINDOWS_SIGNER_SUBJECT_CN = "AgilebitsButWrong";
|
||||||
|
|
||||||
|
const defaultPowerShellRunner = async (script: string): Promise<string> => {
|
||||||
|
const { stdout } = await execFileAsync("powershell.exe", [
|
||||||
|
"-NoProfile",
|
||||||
|
"-NonInteractive",
|
||||||
|
"-Command",
|
||||||
|
script,
|
||||||
|
]);
|
||||||
|
return stdout;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verifies op.exe's Authenticode signature against 1Password's signing cert.
|
||||||
|
// Throws unless the signature is cryptographically valid and the signer is AgileBits.
|
||||||
|
export const verifyAuthenticodeSignature = async (
|
||||||
|
opExePath: string,
|
||||||
|
runPowerShell: (script: string) => Promise<string> = defaultPowerShellRunner,
|
||||||
|
): Promise<void> => {
|
||||||
|
const escapedPath = opExePath.replace(/'/g, "''");
|
||||||
|
const script = [
|
||||||
|
`$sig = Get-AuthenticodeSignature -FilePath '${escapedPath}'`,
|
||||||
|
`"Status=$($sig.Status)"`,
|
||||||
|
`"Subject=$($sig.SignerCertificate.Subject)"`,
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
|
const output = await runPowerShell(script);
|
||||||
|
const outputLines = output.split("\n").map((l) => l.trim());
|
||||||
|
|
||||||
|
const fieldValue = (prefix: string): string | undefined => {
|
||||||
|
const matchingLine = outputLines.find((l) => l.startsWith(prefix));
|
||||||
|
if (!matchingLine) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return matchingLine.slice(prefix.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reject unsigned or tampered binaries.
|
||||||
|
const status = fieldValue("Status=");
|
||||||
|
if (status !== "Valid") {
|
||||||
|
throw new Error(
|
||||||
|
`Authenticode status is ${status ?? "unknown"}, expected Valid.\nGet-AuthenticodeSignature output:\n${output}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm the signer is AgileBits, not some other publisher.
|
||||||
|
const subject = fieldValue("Subject=") ?? "";
|
||||||
|
if (!subject.includes(`CN=${WINDOWS_SIGNER_SUBJECT_CN}`)) {
|
||||||
|
throw new Error(
|
||||||
|
`1Password CLI signature verification failed: signer Subject (${subject}) does not contain CN=${WINDOWS_SIGNER_SUBJECT_CN}. ` +
|
||||||
|
"If 1Password has rotated or renamed their signing identity, this action needs to be updated — please file an issue at https://github.com/1Password/load-secrets-action/issues.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
import { WindowsInstaller } from "./windows";
|
import { WindowsInstaller } from "./windows";
|
||||||
|
|
||||||
jest.mock("fs");
|
jest.mock("fs");
|
||||||
|
jest.mock("./windows-signature", () => ({
|
||||||
|
verifyAuthenticodeSignature: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import * as tc from "@actions/tool-cache";
|
import * as tc from "@actions/tool-cache";
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
type SupportedPlatform,
|
type SupportedPlatform,
|
||||||
} from "./cli-installer";
|
} from "./cli-installer";
|
||||||
import type { Installer } from "./installer";
|
import type { Installer } from "./installer";
|
||||||
|
import { verifyAuthenticodeSignature } from "./windows-signature";
|
||||||
|
|
||||||
export class WindowsInstaller extends CliInstaller implements Installer {
|
export class WindowsInstaller extends CliInstaller implements Installer {
|
||||||
private readonly platform: SupportedPlatform = "win32"; // Node.js platform identifier for Windows
|
private readonly platform: SupportedPlatform = "win32"; // Node.js platform identifier for Windows
|
||||||
@@ -31,6 +33,11 @@ export class WindowsInstaller extends CliInstaller implements Installer {
|
|||||||
fs.renameSync(downloadPath, zipPath);
|
fs.renameSync(downloadPath, zipPath);
|
||||||
console.info("Installing 1Password CLI");
|
console.info("Installing 1Password CLI");
|
||||||
const extractedPath = await tc.extractZip(zipPath);
|
const extractedPath = await tc.extractZip(zipPath);
|
||||||
|
|
||||||
|
core.info("Verifying 1Password CLI signature");
|
||||||
|
await verifyAuthenticodeSignature(path.join(extractedPath, "op.exe"));
|
||||||
|
core.info("1Password CLI signature verified");
|
||||||
|
|
||||||
core.addPath(extractedPath);
|
core.addPath(extractedPath);
|
||||||
core.info("1Password CLI installed");
|
core.info("1Password CLI installed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import * as core from "@actions/core";
|
|
||||||
import { createClient } from "@1password/sdk";
|
|
||||||
import { envManagedVariables } from "./constants";
|
|
||||||
import { getOIDCToken, loadSecretsFromSDK } from "./sdk-client";
|
|
||||||
|
|
||||||
jest.mock("@1password/sdk");
|
|
||||||
|
|
||||||
const mockGetVariables = jest.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
(createClient as jest.Mock).mockResolvedValue({
|
|
||||||
environments: {
|
|
||||||
getVariables: mockGetVariables,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getOIDCToken", () => {
|
|
||||||
it("delegates to core.getIDToken", async () => {
|
|
||||||
(core.getIDToken as jest.Mock).mockResolvedValue("oidc-token");
|
|
||||||
|
|
||||||
await expect(getOIDCToken("test-audience")).resolves.toBe("oidc-token");
|
|
||||||
expect(core.getIDToken).toHaveBeenCalledWith("test-audience");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("loadSecretsFromSDK", () => {
|
|
||||||
const workloadId = "workload-uuid";
|
|
||||||
const environmentId = "environment-uuid";
|
|
||||||
const integrationKey = "integration-key";
|
|
||||||
|
|
||||||
const variables = [
|
|
||||||
{ name: "DOCKERHUB_USERNAME", value: "myuser" },
|
|
||||||
{ name: "DOCKERHUB_TOKEN", value: "mypassword" },
|
|
||||||
];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockGetVariables.mockResolvedValue({ variables });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets secrets as step outputs by default", async () => {
|
|
||||||
await loadSecretsFromSDK(workloadId, environmentId, integrationKey, false);
|
|
||||||
|
|
||||||
expect(core.setOutput).toHaveBeenCalledWith("DOCKERHUB_USERNAME", "myuser");
|
|
||||||
expect(core.setOutput).toHaveBeenCalledWith(
|
|
||||||
"DOCKERHUB_TOKEN",
|
|
||||||
"mypassword",
|
|
||||||
);
|
|
||||||
expect(core.exportVariable).not.toHaveBeenCalledWith(
|
|
||||||
"DOCKERHUB_USERNAME",
|
|
||||||
"myuser",
|
|
||||||
);
|
|
||||||
expect(core.setSecret).toHaveBeenCalledWith("myuser");
|
|
||||||
expect(core.setSecret).toHaveBeenCalledWith("mypassword");
|
|
||||||
expect(core.exportVariable).not.toHaveBeenCalledWith(
|
|
||||||
envManagedVariables,
|
|
||||||
expect.any(String),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exports secrets as environment variables when shouldExportEnv is true", async () => {
|
|
||||||
await loadSecretsFromSDK(workloadId, environmentId, integrationKey, true);
|
|
||||||
|
|
||||||
expect(core.exportVariable).toHaveBeenCalledWith(
|
|
||||||
"DOCKERHUB_USERNAME",
|
|
||||||
"myuser",
|
|
||||||
);
|
|
||||||
expect(core.exportVariable).toHaveBeenCalledWith(
|
|
||||||
"DOCKERHUB_TOKEN",
|
|
||||||
"mypassword",
|
|
||||||
);
|
|
||||||
expect(core.setOutput).not.toHaveBeenCalled();
|
|
||||||
expect(core.exportVariable).toHaveBeenCalledWith(
|
|
||||||
envManagedVariables,
|
|
||||||
"DOCKERHUB_USERNAME,DOCKERHUB_TOKEN",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when secret value is empty string", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockGetVariables.mockResolvedValue({
|
|
||||||
variables: [{ name: "EMPTY_SECRET", value: "" }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets empty string as step output", async () => {
|
|
||||||
await loadSecretsFromSDK(
|
|
||||||
workloadId,
|
|
||||||
environmentId,
|
|
||||||
integrationKey,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(core.setOutput).toHaveBeenCalledWith("EMPTY_SECRET", "");
|
|
||||||
expect(core.setSecret).not.toHaveBeenCalledWith("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets empty string as environment variable", async () => {
|
|
||||||
await loadSecretsFromSDK(workloadId, environmentId, integrationKey, true);
|
|
||||||
|
|
||||||
expect(core.exportVariable).toHaveBeenCalledWith("EMPTY_SECRET", "");
|
|
||||||
expect(core.setSecret).not.toHaveBeenCalledWith("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not export OP_MANAGED_VARIABLES when no variables are returned", async () => {
|
|
||||||
mockGetVariables.mockResolvedValue({ variables: [] });
|
|
||||||
|
|
||||||
await loadSecretsFromSDK(workloadId, environmentId, integrationKey, true);
|
|
||||||
|
|
||||||
expect(core.exportVariable).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import * as core from "@actions/core";
|
|
||||||
import { createClient } from "@1password/sdk";
|
|
||||||
import { version } from "../package.json";
|
|
||||||
import { envManagedVariables } from "./constants";
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
export const getOIDCToken = async (audience: string): Promise<string> =>
|
|
||||||
core.getIDToken(audience);
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
export const loadSecretsFromSDK = async (
|
|
||||||
workloadId: string,
|
|
||||||
environmentId: string,
|
|
||||||
integrationKey: string,
|
|
||||||
shouldExportEnv: boolean,
|
|
||||||
): Promise<void> => {
|
|
||||||
// Temporary fix: strip base64 padding from integrationKey — this will eventually be handled by the SDK core itself
|
|
||||||
const customerManagedSecret = integrationKey.replace(/=+$/, "");
|
|
||||||
core.setSecret(customerManagedSecret);
|
|
||||||
|
|
||||||
const client = await createClient({
|
|
||||||
integrationName: "1Password GitHub Action",
|
|
||||||
integrationVersion: version,
|
|
||||||
oidcFetcher: getOIDCToken,
|
|
||||||
workloadDetails: {
|
|
||||||
customerManagedSecret,
|
|
||||||
workloadUuid: workloadId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
core.info("Authenticated with Workload Identity.");
|
|
||||||
|
|
||||||
const { variables } = await client.environments.getVariables(environmentId);
|
|
||||||
|
|
||||||
const envNames: string[] = [];
|
|
||||||
for (const { name, value } of variables) {
|
|
||||||
core.info(`Populating variable: ${name}`);
|
|
||||||
if (shouldExportEnv) {
|
|
||||||
core.exportVariable(name, value);
|
|
||||||
} else {
|
|
||||||
core.setOutput(name, value);
|
|
||||||
}
|
|
||||||
if (value) {
|
|
||||||
core.setSecret(value);
|
|
||||||
}
|
|
||||||
envNames.push(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldExportEnv && envNames.length > 0) {
|
|
||||||
core.exportVariable(envManagedVariables, envNames.join());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -3,8 +3,6 @@ import * as exec from "@actions/exec";
|
|||||||
import { read, setClientInfo } from "@1password/op-js";
|
import { read, setClientInfo } from "@1password/op-js";
|
||||||
import {
|
import {
|
||||||
extractSecret,
|
extractSecret,
|
||||||
getWorkloadIdentityConfig,
|
|
||||||
hasCliAuth,
|
|
||||||
loadSecrets,
|
loadSecrets,
|
||||||
unsetPrevious,
|
unsetPrevious,
|
||||||
validateAuth,
|
validateAuth,
|
||||||
@@ -13,11 +11,8 @@ import {
|
|||||||
authErr,
|
authErr,
|
||||||
envConnectHost,
|
envConnectHost,
|
||||||
envConnectToken,
|
envConnectToken,
|
||||||
envEnvironmentId,
|
|
||||||
envIntegrationKey,
|
|
||||||
envManagedVariables,
|
envManagedVariables,
|
||||||
envServiceAccountToken,
|
envServiceAccountToken,
|
||||||
envWorkloadId,
|
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
|
||||||
jest.mock("@1password/op-js");
|
jest.mock("@1password/op-js");
|
||||||
@@ -71,96 +66,6 @@ describe("validateAuth", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getWorkloadIdentityConfig", () => {
|
|
||||||
const testWorkloadId = "workload-id";
|
|
||||||
const testEnvironmentId = "environment-id";
|
|
||||||
const testIntegrationKey = "integration-key";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env[envWorkloadId] = "";
|
|
||||||
process.env[envEnvironmentId] = "";
|
|
||||||
process.env[envIntegrationKey] = "";
|
|
||||||
process.env[envConnectHost] = "";
|
|
||||||
process.env[envConnectToken] = "";
|
|
||||||
process.env[envServiceAccountToken] = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return null when no variables are set", () => {
|
|
||||||
expect(getWorkloadIdentityConfig()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return the config when all variables are set", () => {
|
|
||||||
process.env[envWorkloadId] = testWorkloadId;
|
|
||||||
process.env[envEnvironmentId] = testEnvironmentId;
|
|
||||||
process.env[envIntegrationKey] = testIntegrationKey;
|
|
||||||
|
|
||||||
expect(getWorkloadIdentityConfig()).toEqual({
|
|
||||||
workloadId: testWorkloadId,
|
|
||||||
environmentId: testEnvironmentId,
|
|
||||||
integrationKey: testIntegrationKey,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw an error when only some variables are set", () => {
|
|
||||||
process.env[envWorkloadId] = testWorkloadId;
|
|
||||||
|
|
||||||
expect(getWorkloadIdentityConfig).toThrow(
|
|
||||||
/Incomplete Workload Identity configuration/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw an error when combined with Connect credentials", () => {
|
|
||||||
process.env[envWorkloadId] = testWorkloadId;
|
|
||||||
process.env[envEnvironmentId] = testEnvironmentId;
|
|
||||||
process.env[envIntegrationKey] = testIntegrationKey;
|
|
||||||
process.env[envConnectHost] = "https://localhost:8000";
|
|
||||||
process.env[envConnectToken] = "token";
|
|
||||||
|
|
||||||
expect(getWorkloadIdentityConfig).toThrow(
|
|
||||||
/Conflicting authentication configuration/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw an error when combined with a service account token", () => {
|
|
||||||
process.env[envWorkloadId] = testWorkloadId;
|
|
||||||
process.env[envEnvironmentId] = testEnvironmentId;
|
|
||||||
process.env[envIntegrationKey] = testIntegrationKey;
|
|
||||||
process.env[envServiceAccountToken] = "ops_token";
|
|
||||||
|
|
||||||
expect(getWorkloadIdentityConfig).toThrow(
|
|
||||||
/Conflicting authentication configuration/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hasCliAuth", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env[envConnectHost] = "";
|
|
||||||
process.env[envConnectToken] = "";
|
|
||||||
process.env[envServiceAccountToken] = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when no CLI auth is configured", () => {
|
|
||||||
expect(hasCliAuth()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when only the Connect host is set", () => {
|
|
||||||
process.env[envConnectHost] = "https://localhost:8000";
|
|
||||||
expect(hasCliAuth()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true with both Connect host and token", () => {
|
|
||||||
process.env[envConnectHost] = "https://localhost:8000";
|
|
||||||
process.env[envConnectToken] = "token";
|
|
||||||
expect(hasCliAuth()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true with a service account token", () => {
|
|
||||||
process.env[envServiceAccountToken] = "ops_token";
|
|
||||||
expect(hasCliAuth()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extractSecret", () => {
|
describe("extractSecret", () => {
|
||||||
const envTestSecretEnv = "TEST_SECRET";
|
const envTestSecretEnv = "TEST_SECRET";
|
||||||
const testSecretRef = "op://vault/item/secret";
|
const testSecretRef = "op://vault/item/secret";
|
||||||
@@ -285,24 +190,4 @@ describe("unsetPrevious", () => {
|
|||||||
expect(core.info).toHaveBeenCalledWith("Unsetting TEST_SECRET");
|
expect(core.info).toHaveBeenCalledWith("Unsetting TEST_SECRET");
|
||||||
expect(core.exportVariable).toHaveBeenCalledWith("TEST_SECRET", "");
|
expect(core.exportVariable).toHaveBeenCalledWith("TEST_SECRET", "");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should unset every variable listed in OP_MANAGED_VARIABLES", () => {
|
|
||||||
process.env[envManagedVariables] = "TEST_SECRET,ANOTHER_TEST,SUPER_SECRET";
|
|
||||||
|
|
||||||
unsetPrevious();
|
|
||||||
|
|
||||||
expect(core.exportVariable).toHaveBeenCalledWith("TEST_SECRET", "");
|
|
||||||
expect(core.exportVariable).toHaveBeenCalledWith("ANOTHER_TEST", "");
|
|
||||||
expect(core.exportVariable).toHaveBeenCalledWith("SUPER_SECRET", "");
|
|
||||||
expect(core.exportVariable).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should do nothing when no variables are managed", () => {
|
|
||||||
process.env[envManagedVariables] = "";
|
|
||||||
|
|
||||||
unsetPrevious();
|
|
||||||
|
|
||||||
expect(core.exportVariable).not.toHaveBeenCalled();
|
|
||||||
expect(core.info).not.toHaveBeenCalledWith("Unsetting previous values ...");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-56
@@ -8,61 +8,8 @@ import {
|
|||||||
envConnectToken,
|
envConnectToken,
|
||||||
envServiceAccountToken,
|
envServiceAccountToken,
|
||||||
envManagedVariables,
|
envManagedVariables,
|
||||||
envWorkloadId,
|
|
||||||
envEnvironmentId,
|
|
||||||
envIntegrationKey,
|
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
|
||||||
export interface WorkloadIdentityConfig {
|
|
||||||
workloadId: string;
|
|
||||||
environmentId: string;
|
|
||||||
integrationKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the Workload Identity configuration when all variables are set,
|
|
||||||
// or null when none are set (so the CLI auth path can be used instead).
|
|
||||||
// Throws if the configuration is only partially set, or if it is combined
|
|
||||||
// with the CLI auth methods (Connect / service account).
|
|
||||||
export const getWorkloadIdentityConfig = (): WorkloadIdentityConfig | null => {
|
|
||||||
const workloadId = process.env[envWorkloadId];
|
|
||||||
const environmentId = process.env[envEnvironmentId];
|
|
||||||
const integrationKey = process.env[envIntegrationKey];
|
|
||||||
|
|
||||||
// None set: fall back to the CLI auth path.
|
|
||||||
if (!workloadId && !environmentId && !integrationKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some but not all set: configuration is incomplete.
|
|
||||||
if (!workloadId || !environmentId || !integrationKey) {
|
|
||||||
throw new Error(
|
|
||||||
`Incomplete Workload Identity configuration. To use Workload Identity, set all of ${envWorkloadId}, ${envEnvironmentId}, and ${envIntegrationKey}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workload Identity is fully configured, so it must not be combined with the
|
|
||||||
// CLI auth methods (Connect / service account), which are mutually exclusive.
|
|
||||||
if (
|
|
||||||
process.env[envConnectHost] ||
|
|
||||||
process.env[envConnectToken] ||
|
|
||||||
process.env[envServiceAccountToken]
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Conflicting authentication configuration: Workload Identity cannot be combined with Connect (${envConnectHost}/${envConnectToken}) or a service account (${envServiceAccountToken}). Set only one authentication method.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { workloadId, environmentId, integrationKey };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Whether CLI authentication (1Password Connect or a service account) is
|
|
||||||
// configured via environment variables.
|
|
||||||
export const hasCliAuth = (): boolean =>
|
|
||||||
Boolean(
|
|
||||||
(process.env[envConnectHost] && process.env[envConnectToken]) ||
|
|
||||||
process.env[envServiceAccountToken],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const validateAuth = (): void => {
|
export const validateAuth = (): void => {
|
||||||
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
|
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
|
||||||
const isServiceAccount = process.env[envServiceAccountToken];
|
const isServiceAccount = process.env[envServiceAccountToken];
|
||||||
@@ -111,12 +58,11 @@ export const extractSecret = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
|
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
|
||||||
// Strip any prerelease suffix; semverToInt only accepts MAJOR.MINOR.PATCH.
|
// Pass User-Agent Information to the 1Password CLI
|
||||||
const [releaseVersion] = version.split("-");
|
|
||||||
setClientInfo({
|
setClientInfo({
|
||||||
name: "1Password GitHub Action",
|
name: "1Password GitHub Action",
|
||||||
id: "GHA",
|
id: "GHA",
|
||||||
build: semverToInt(releaseVersion ?? version),
|
build: semverToInt(version),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load secrets from environment variables using 1Password CLI.
|
// Load secrets from environment variables using 1Password CLI.
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Asserts the secrets loaded via Workload Identity.
|
|
||||||
|
|
||||||
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 "ANOTHER_TEST" "anothertest123"
|
|
||||||
assert_env_equals "SUPER_SECRET" "supersecret"
|
|
||||||
assert_env_equals "TEST_SECRET" "thisisatest"
|
|
||||||
Reference in New Issue
Block a user