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
|
||||
OP_SERVICE_ACCOUNT_TOKEN:
|
||||
required: true
|
||||
OP_WORKLOAD_ID:
|
||||
required: true
|
||||
OP_ENVIRONMENT_ID:
|
||||
required: true
|
||||
OP_INTEGRATION_KEY:
|
||||
required: true
|
||||
VAULT:
|
||||
description: "1Password vault name or UUID"
|
||||
required: true
|
||||
@@ -254,73 +248,3 @@ jobs:
|
||||
- name: Assert removed secrets [exported env]
|
||||
if: ${{ matrix.export-env }}
|
||||
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:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, jill/test-verification]
|
||||
paths-ignore: &ignore_paths
|
||||
- "docs/**"
|
||||
- "config/**"
|
||||
@@ -69,6 +69,10 @@ jobs:
|
||||
echo "condition=push-to-main" >> $GITHUB_OUTPUT
|
||||
echo "Setting condition=push-to-main (push to main)"
|
||||
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
|
||||
# Unknown event type
|
||||
echo "condition=skip" >> $GITHUB_OUTPUT
|
||||
@@ -85,6 +89,8 @@ jobs:
|
||||
(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-test-branch'
|
||||
uses: ./.github/workflows/e2e-tests.yml
|
||||
with:
|
||||
ref: ${{ needs.check-external-pr.outputs.ref }}
|
||||
@@ -92,9 +98,6 @@ jobs:
|
||||
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_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 }}
|
||||
|
||||
# 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).
|
||||
|
||||
_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!
|
||||
|
||||
[](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).
|
||||
|
||||
## 🧪 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
|
||||
|
||||
- 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/tool-cache$": "<rootDir>/__mocks__/actions-tool-cache.ts",
|
||||
"^@actions/exec$": "<rootDir>/__mocks__/actions-exec.ts",
|
||||
"^@1password/sdk$": "<rootDir>/__mocks__/1password-sdk.ts",
|
||||
},
|
||||
transform: {
|
||||
".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",
|
||||
"version": "5.0.0-beta.1",
|
||||
"version": "4.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "load-secrets-action",
|
||||
"version": "5.0.0-beta.1",
|
||||
"version": "4.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@1password/op-js": "^0.1.11",
|
||||
"@1password/sdk": "0.5.0-beta.1",
|
||||
"@actions/core": "^3.0.0",
|
||||
"@actions/exec": "^3.0.0",
|
||||
"@actions/tool-cache": "^4.0.0",
|
||||
@@ -73,21 +72,6 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz",
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "load-secrets-action",
|
||||
"version": "5.0.0-beta.1",
|
||||
"version": "4.0.0",
|
||||
"description": "Load Secrets from 1Password",
|
||||
"main": "dist/index.js",
|
||||
"directories": {
|
||||
@@ -40,7 +40,6 @@
|
||||
},
|
||||
"homepage": "https://github.com/1Password/load-secrets-action#readme",
|
||||
"dependencies": {
|
||||
"@1password/sdk": "0.5.0-beta.1",
|
||||
"@1password/op-js": "^0.1.11",
|
||||
"@actions/core": "^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(),
|
||||
addPath: jest.fn(),
|
||||
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 envManagedVariables = "OP_MANAGED_VARIABLES";
|
||||
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}.`;
|
||||
|
||||
+1
-29
@@ -2,14 +2,7 @@ import dotenv from "dotenv";
|
||||
import * as core from "@actions/core";
|
||||
import { validateCli } from "@1password/op-js";
|
||||
import { installCliOnGithubActionRunner } from "./op-cli-installer";
|
||||
import {
|
||||
getWorkloadIdentityConfig,
|
||||
hasCliAuth,
|
||||
loadSecrets,
|
||||
unsetPrevious,
|
||||
validateAuth,
|
||||
} from "./utils";
|
||||
import { loadSecretsFromSDK } from "./sdk-client";
|
||||
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
|
||||
import { envFilePath } from "./constants";
|
||||
|
||||
const loadSecretsAction = async () => {
|
||||
@@ -23,26 +16,6 @@ const loadSecretsAction = async () => {
|
||||
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
|
||||
validateAuth();
|
||||
|
||||
@@ -58,7 +31,6 @@ const loadSecretsAction = async () => {
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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 {
|
||||
archMap,
|
||||
CliInstaller,
|
||||
cliUrlBuilder,
|
||||
type SupportedPlatform,
|
||||
} from "./cli-installer";
|
||||
@@ -25,9 +24,7 @@ describe("LinuxInstaller", () => {
|
||||
|
||||
it("should call install with correct URL", async () => {
|
||||
const installer = new LinuxInstaller(version);
|
||||
const installMock = jest
|
||||
.spyOn(CliInstaller.prototype, "install")
|
||||
.mockResolvedValue();
|
||||
const installMock = jest.spyOn(installer, "install").mockResolvedValue();
|
||||
|
||||
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 {
|
||||
CliInstaller,
|
||||
cliUrlBuilder,
|
||||
type SupportedPlatform,
|
||||
} from "./cli-installer";
|
||||
import type { Installer } from "./installer";
|
||||
import { verifyLinuxSignature } from "./linux-signature";
|
||||
|
||||
export class LinuxInstaller extends CliInstaller implements Installer {
|
||||
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> {
|
||||
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,
|
||||
} from "./cli-installer";
|
||||
import { type Installer } from "./installer";
|
||||
import { verifyMacOsPackageSignature } from "./macos-signature";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -34,6 +35,10 @@ export class MacOsInstaller extends CliInstaller implements Installer {
|
||||
const pkgWithExtension = `${pkgPath}.pkg`;
|
||||
fs.renameSync(pkgPath, pkgWithExtension);
|
||||
|
||||
core.info("Verifying 1Password CLI signature");
|
||||
await verifyMacOsPackageSignature(pkgWithExtension);
|
||||
core.info("1Password CLI signature verified");
|
||||
|
||||
const expandDir = "temp-pkg";
|
||||
await execFileAsync("pkgutil", ["--expand", pkgWithExtension, expandDir]);
|
||||
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";
|
||||
|
||||
jest.mock("fs");
|
||||
jest.mock("./windows-signature", () => ({
|
||||
verifyAuthenticodeSignature: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import * as tc from "@actions/tool-cache";
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
type SupportedPlatform,
|
||||
} from "./cli-installer";
|
||||
import type { Installer } from "./installer";
|
||||
import { verifyAuthenticodeSignature } from "./windows-signature";
|
||||
|
||||
export class WindowsInstaller extends CliInstaller implements Installer {
|
||||
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);
|
||||
console.info("Installing 1Password CLI");
|
||||
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.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 {
|
||||
extractSecret,
|
||||
getWorkloadIdentityConfig,
|
||||
hasCliAuth,
|
||||
loadSecrets,
|
||||
unsetPrevious,
|
||||
validateAuth,
|
||||
@@ -13,11 +11,8 @@ import {
|
||||
authErr,
|
||||
envConnectHost,
|
||||
envConnectToken,
|
||||
envEnvironmentId,
|
||||
envIntegrationKey,
|
||||
envManagedVariables,
|
||||
envServiceAccountToken,
|
||||
envWorkloadId,
|
||||
} from "./constants";
|
||||
|
||||
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", () => {
|
||||
const envTestSecretEnv = "TEST_SECRET";
|
||||
const testSecretRef = "op://vault/item/secret";
|
||||
@@ -285,24 +190,4 @@ describe("unsetPrevious", () => {
|
||||
expect(core.info).toHaveBeenCalledWith("Unsetting 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,
|
||||
envServiceAccountToken,
|
||||
envManagedVariables,
|
||||
envWorkloadId,
|
||||
envEnvironmentId,
|
||||
envIntegrationKey,
|
||||
} 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 => {
|
||||
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
|
||||
const isServiceAccount = process.env[envServiceAccountToken];
|
||||
@@ -111,12 +58,11 @@ export const extractSecret = (
|
||||
};
|
||||
|
||||
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
|
||||
// Strip any prerelease suffix; semverToInt only accepts MAJOR.MINOR.PATCH.
|
||||
const [releaseVersion] = version.split("-");
|
||||
// Pass User-Agent Information to the 1Password CLI
|
||||
setClientInfo({
|
||||
name: "1Password GitHub Action",
|
||||
id: "GHA",
|
||||
build: semverToInt(releaseVersion ?? version),
|
||||
build: semverToInt(version),
|
||||
});
|
||||
|
||||
// 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