Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"homepage": "https://github.com/1Password/load-secrets-action#readme",
"dependencies": {
"@1password/op-js": "^0.1.11",
"@1password/sdk": "^0.4.0",
"@actions/core": "^1.10.1",
"@actions/exec": "^1.1.1",
"@actions/tool-cache": "^2.0.2",
Expand Down
10 changes: 7 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as core from "@actions/core";
import { validateCli } from "@1password/op-js";
import { installCliOnGithubActionRunner } from "./op-cli-installer";
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
import { envFilePath } from "./constants";
import { envFilePath, envConnectHost, envConnectToken } from "./constants";

const loadSecretsAction = async () => {
try {
Expand All @@ -26,8 +26,12 @@ const loadSecretsAction = async () => {
dotenv.config({ path: file });
}

// Download and install the CLI
await installCLI();
const isConnect =
process.env[envConnectHost] && process.env[envConnectToken];
// If Connect is used, download and install the CLI
if (isConnect) {
await installCLI();
}

// Load secrets
await loadSecrets(shouldExportEnv);
Expand Down
172 changes: 171 additions & 1 deletion src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import { read, setClientInfo } from "@1password/op-js";
import { createClient } from "@1password/sdk";
import {
extractSecret,
loadSecrets,
Expand All @@ -22,6 +23,9 @@ jest.mock("@actions/exec", () => ({
})),
}));
jest.mock("@1password/op-js");
jest.mock("@1password/sdk", () => ({
createClient: jest.fn(),
}));

beforeEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -143,7 +147,13 @@ describe("extractSecret", () => {
});
});

describe("loadSecrets", () => {
describe("loadSecrets when using Connect", () => {
beforeEach(() => {
process.env[envConnectHost] = "https://localhost:8000";
process.env[envConnectToken] = "token";
process.env[envServiceAccountToken] = "";
});

it("sets the client info and gets the executed output", async () => {
await loadSecrets(true);

Expand Down Expand Up @@ -181,6 +191,166 @@ describe("loadSecrets", () => {
});
});

describe("loadSecrets when using Service Account", () => {
const mockResolve = jest.fn();

beforeEach(() => {
process.env[envConnectHost] = "";
process.env[envConnectToken] = "";
process.env[envServiceAccountToken] = "ops_token";

Object.keys(process.env).forEach((key) => {
if (
typeof process.env[key] === "string" &&
process.env[key]?.startsWith("op://")
) {
delete process.env[key];
}
});
process.env.MY_SECRET = "op://vault/item/field";

(createClient as jest.Mock).mockResolvedValue({
secrets: { resolve: mockResolve },
});

mockResolve.mockResolvedValue("resolved-secret-value");
});

it("does not call op env ls when using Service Account", async () => {
await loadSecrets(false);
expect(exec.getExecOutput).not.toHaveBeenCalled();
});

it("sets step output with resolved value when export-env is false", async () => {
await loadSecrets(false);
expect(core.setOutput).toHaveBeenCalledTimes(1);
expect(core.setOutput).toHaveBeenCalledWith(
"MY_SECRET",
"resolved-secret-value",
);
});

it("masks secret with setSecret when export-env is false", async () => {
await loadSecrets(false);
expect(core.setSecret).toHaveBeenCalledTimes(1);
expect(core.setSecret).toHaveBeenCalledWith("resolved-secret-value");
});

it("does not call exportVariable when export-env is false", async () => {
await loadSecrets(false);
expect(core.exportVariable).not.toHaveBeenCalled();
});

it("exports env and sets OP_MANAGED_VARIABLES when export-env is true", async () => {
await loadSecrets(true);
expect(core.exportVariable).toHaveBeenCalledWith(
"MY_SECRET",
"resolved-secret-value",
);
expect(core.exportVariable).toHaveBeenCalledWith(
envManagedVariables,
"MY_SECRET",
);
});

it("does not set step output when export-env is true", async () => {
await loadSecrets(true);
expect(core.setOutput).not.toHaveBeenCalledWith(
"MY_SECRET",
expect.anything(),
);
});

it("masks secret with setSecret when export-env is true", async () => {
await loadSecrets(true);
expect(core.setSecret).toHaveBeenCalledTimes(1);
expect(core.setSecret).toHaveBeenCalledWith("resolved-secret-value");
});

it("returns early when no env vars have op:// refs", async () => {
Object.keys(process.env).forEach((key) => {
if (
typeof process.env[key] === "string" &&
process.env[key]?.startsWith("op://")
) {
delete process.env[key];
}
});
await loadSecrets(true);
expect(exec.getExecOutput).not.toHaveBeenCalled();
expect(core.exportVariable).not.toHaveBeenCalled();
});

it("wraps createClient errors with a descriptive message", async () => {
(createClient as jest.Mock).mockRejectedValue(
new Error("invalid token format"),
);
await expect(loadSecrets(false)).rejects.toThrow(
"Service account authentication failed: invalid token format",
);
});

describe("multiple refs", () => {
const ref1 = "op://vault/item/field";
const ref2 = "op://vault/other/item";
const ref3 = "op://vault/file/secret";

beforeEach(() => {
process.env.MY_SECRET = ref1;
process.env.ANOTHER_SECRET = ref2;
process.env.FILE_SECRET = ref3;

mockResolve
.mockResolvedValueOnce("value1")
.mockResolvedValueOnce("value2")
.mockResolvedValueOnce("value3");
});

it("resolves each ref and sets step output for each when export-env is false", async () => {
await loadSecrets(false);

expect(mockResolve).toHaveBeenCalledTimes(3);
expect(mockResolve).toHaveBeenCalledWith(ref1);
expect(mockResolve).toHaveBeenCalledWith(ref2);
expect(mockResolve).toHaveBeenCalledWith(ref3);

expect(core.setOutput).toHaveBeenCalledTimes(3);
expect(core.setOutput).toHaveBeenCalledWith("MY_SECRET", "value1");
expect(core.setOutput).toHaveBeenCalledWith("ANOTHER_SECRET", "value2");
expect(core.setOutput).toHaveBeenCalledWith("FILE_SECRET", "value3");

expect(core.setSecret).toHaveBeenCalledTimes(3);
});

it("resolves each ref and exports each and sets OP_MANAGED_VARIABLES when export-env is true", async () => {
await loadSecrets(true);

expect(mockResolve).toHaveBeenCalledTimes(3);

expect(core.exportVariable).toHaveBeenCalledWith("MY_SECRET", "value1");
expect(core.exportVariable).toHaveBeenCalledWith(
"ANOTHER_SECRET",
"value2",
);
expect(core.exportVariable).toHaveBeenCalledWith("FILE_SECRET", "value3");

const exportVariableCalls = (core.exportVariable as jest.Mock).mock
.calls as [string, string][];
const managedVarsCall = exportVariableCalls.find(
([name]) => name === envManagedVariables,
);
expect(managedVarsCall).toBeDefined();
const managedList = (managedVarsCall as [string, string])[1].split(",");
expect(managedList).toContain("MY_SECRET");
expect(managedList).toContain("ANOTHER_SECRET");
expect(managedList).toContain("FILE_SECRET");
expect(managedList).toHaveLength(3);

expect(core.setSecret).toHaveBeenCalledTimes(3);
});
});
});

describe("unsetPrevious", () => {
const testManagedEnv = "TEST_SECRET";
const testSecretValue = "MyS3cr#T";
Expand Down
97 changes: 84 additions & 13 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import { read, setClientInfo, semverToInt } from "@1password/op-js";
import { createClient } from "@1password/sdk";
import { version } from "../package.json";
import {
authErr,
Expand Down Expand Up @@ -29,12 +30,34 @@ export const validateAuth = (): void => {
core.info(`Authenticated with ${authType}.`);
};

export const extractSecret = (
const getEnvVarNamesWithSecretRefs = (): string[] =>
Object.keys(process.env).filter(
(key) =>
typeof process.env[key] === "string" &&
process.env[key]?.startsWith("op://"),
);

const setResolvedSecret = (
envName: string,
secretValue: string,
shouldExportEnv: boolean,
): void => {
core.info(`Populating variable: ${envName}`);

if (shouldExportEnv) {
core.exportVariable(envName, secretValue);
} else {
core.setOutput(envName, secretValue);
}
if (secretValue) {
core.setSecret(secretValue);
}
};

export const extractSecret = (
envName: string,
shouldExportEnv: boolean,
): void => {
const ref = process.env[envName];
if (!ref) {
return;
Expand All @@ -45,20 +68,13 @@ export const extractSecret = (
return;
}

if (shouldExportEnv) {
core.exportVariable(envName, secretValue);
} else {
core.setOutput(envName, secretValue);
}
// Skip setSecret for empty strings to avoid the warning:
// "Can't add secret mask for empty string in ##[add-mask] command."
if (secretValue) {
core.setSecret(secretValue);
}
setResolvedSecret(envName, secretValue, shouldExportEnv);
};

export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
// Pass User-Agent Information to the 1Password CLI
// Connect loads secrets via the 1Password CLI
const loadSecretsViaConnect = async (
shouldExportEnv: boolean,
): Promise<void> => {
setClientInfo({
name: "1Password GitHub Action",
id: "GHA",
Expand All @@ -83,6 +99,61 @@ export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
}
};

// Service Account loads secrets via the 1Password SDK
const loadSecretsViaServiceAccount = async (
shouldExportEnv: boolean,
): Promise<void> => {
const envs = getEnvVarNamesWithSecretRefs();
if (envs.length === 0) {
return;
}

const token = process.env[envServiceAccountToken];
if (!token) {
throw new Error(authErr);
}

// Authenticate with the 1Password SDK
let client;
try {
client = await createClient({
auth: token,
integrationName: "1Password GitHub Action",
integrationVersion: version,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Service account authentication failed: ${message}`);
}

for (const envName of envs) {
const ref = process.env[envName];
if (!ref) {
continue;
}

// Resolve the secret value using the 1Password SDK
// and make it available either as step outputs or as environment variables
const secretValue = await client.secrets.resolve(ref);
setResolvedSecret(envName, secretValue, shouldExportEnv);
}

if (shouldExportEnv) {
core.exportVariable(envManagedVariables, envs.join());
}
};

export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];

if (isConnect) {
await loadSecretsViaConnect(shouldExportEnv);
return;
}

await loadSecretsViaServiceAccount(shouldExportEnv);
};

export const unsetPrevious = (): void => {
if (process.env[envManagedVariables]) {
core.info("Unsetting previous values ...");
Expand Down
Loading