Compare commits

..

10 Commits

Author SHA1 Message Date
Jill Regan feec3fd0c1 Harden windows check 2026-06-09 10:26:38 -04:00
Jill Regan 7b7cb42941 Add public signing key 2026-05-21 15:14:34 -04:00
Jill Regan cc789f0882 Verify valid signature and signer 2026-05-21 13:24:48 -04:00
Jill Regan d1dad6d749 Add strict mode 2026-05-21 11:46:38 -04:00
Jill Regan d463472f19 Add gpg fallback 2026-05-21 11:22:18 -04:00
Jill Regan da7c7c6490 Fix lintng error 2026-05-21 09:46:00 -04:00
Jill Regan d367634d5e Add windows check 2026-05-21 09:32:54 -04:00
Jill Regan 1953bd007b Add verify complete log 2026-05-20 16:46:18 -04:00
Jill Regan f3b8e180f2 Add linux check 2026-05-20 16:38:44 -04:00
Jill Regan 6ec01615e5 Add check for macos signature 2026-05-20 14:35:25 -04:00
30 changed files with 732 additions and 3487 deletions
-76
View File
@@ -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
-3
View File
@@ -92,9 +92,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
-31
View File
@@ -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!
[![Using 1Password Service Accounts with GitHub Actions - showcase](https://img.youtube.com/vi/kVBl5iQYgSA/maxresdefault.jpg)](https://www.youtube.com/watch?v=kVBl5iQYgSA "Using 1Password Service Accounts with GitHub Actions")
@@ -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.
-1
View File
@@ -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": [
BIN
View File
Binary file not shown.
+216 -2952
View File
File diff suppressed because one or more lines are too long
+49
View File
@@ -0,0 +1,49 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
P4I7jHwe0BN3iNAG1nKbGg==
=+LeX
-----END PGP PUBLIC KEY BLOCK-----
+2 -18
View File
@@ -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
View File
@@ -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
View File
@@ -1 +0,0 @@
export const createClient = jest.fn();
-2
View File
@@ -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
View File
@@ -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}.`;
+12 -40
View File
@@ -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,42 +16,21 @@ const loadSecretsAction = async () => {
unsetPrevious();
}
const workloadConfig = getWorkloadIdentityConfig();
// Validate that a proper authentication configuration is set for the CLI
validateAuth();
// `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;
// Set environment variables from OP_ENV_FILE
const file = process.env[envFilePath];
if (file) {
core.info(`Loading environment variables from file: ${file}`);
dotenv.config({ path: file });
}
if (workloadConfig) {
await loadSecretsFromSDK(
workloadConfig.workloadId,
workloadConfig.environmentId,
workloadConfig.integrationKey,
shouldExportEnv,
);
} else {
// Validate that a proper authentication configuration is set for the CLI
validateAuth();
// Download and install the CLI
await installCLI();
// Set environment variables from OP_ENV_FILE
const file = process.env[envFilePath];
if (file) {
core.info(`Loading environment variables from file: ${file}`);
dotenv.config({ path: file });
}
// Download and install the CLI
await installCLI();
// Load secrets
await loadSecrets(shouldExportEnv);
}
// 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,62 @@
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 = "Agilebits";
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. Trailing comma
// anchors the CN value so e.g. "CN=AgilebitsAttacker, ..." cannot match.
const subject = fieldValue("Subject=") ?? "";
const expectedCn = `CN=${WINDOWS_SIGNER_SUBJECT_CN},`;
if (!subject.includes(expectedCn)) {
throw new Error(
`1Password CLI signature verification failed: signer Subject (${subject}) does not contain ${expectedCn} ` +
"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");
}
-114
View File
@@ -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();
});
});
-52
View File
@@ -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());
}
};
-115
View File
@@ -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
View File
@@ -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.
-16
View File
@@ -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"