diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 486f400e..a76b2ee1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -12,3 +12,5 @@ /src/commands/cpm4/ @celonis/cpm4 /src/commands/data-pipeline/ @Dusan-r @IvanGandacov @EktaCelonis @gorasoCelonis /src/commands/studio/ @celonis/astro +/package.json @celonis/astro @aocelo @siavash-celonis +/yarn.lock @celonis/astro @aocelo @siavash-celonis diff --git a/docs/getting-started.md b/docs/getting-started.md index 4c931672..f7e2b8b6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -66,26 +66,68 @@ permissions based on how much power you want to give to the key owner. ### Security Considerations | ⚠️ **IMPORTANT SECURITY WARNING** | -|-----------------------------------| -| Profile credentials (API tokens, OAuth client secrets, and access tokens) are **stored in plaintext** on your local filesystem. **No encryption is applied** to these credentials. | +|---| +| The CLI attempts to store profile credentials (API tokens, OAuth client secrets, and access tokens) securely using your system's native keychain/credential store. However, if keychain storage fails, credentials **will fall back to plaintext storage** on your local filesystem. | + +**Secure Storage (Preferred):** + +- When creating profiles, the CLI automatically attempts to store secrets in your system's secure credential store: + - **macOS**: Keychain Access + - **Windows**: Windows Credential Manager + - **Linux**: libsecret (requires a secret service like GNOME Keyring or KWallet) +- If successful, secrets are **removed from the profile file** and stored securely in the system keychain + +**Fallback to Plaintext Storage:** + +- If keychain storage fails (e.g., keychain unavailable, permission denied, or unsupported system), secrets **will be stored in plaintext** in the profile file +- A warning message will be displayed: `⚠️ Failed to store secrets securely. They will be stored in plain text file.` +- For profiles with plaintext secrets, you may see a warning when accessing them: `⚠️ Profile secrets are stored as plain-text insecurely. Consider re-creating the profile to save the secrets securely.` **Storage Location:** -- **Linux/macOS:** - - `~/.celonis-content-cli-profiles` - - `~/.celonis-content-cli-git-profiles` -- **Windows:** - - `%USERPROFILE%\.celonis-content-cli-profiles` - - `%USERPROFILE%\.celonis-content-cli-git-profiles` +- **Profile files** (may contain non-sensitive data if secrets are stored securely): + - **Linux/macOS**: `~/.celonis-content-cli-profiles` + - **Windows**: `%USERPROFILE%\.celonis-content-cli-profiles` +- **Secure secrets** (when successfully stored): + - Stored in your system's native credential manager/keychain + - Service name: `celonis-content-cli:` **Protection Mechanisms:** -The security of your credentials relies **entirely on native operating system filesystem permissions**. The CLI does not provide additional encryption. -Ensure that: +- **For securely stored profiles**: Secrets are protected by your system's keychain security (typically requires user authentication or system-level access) +- **For plaintext profiles**: Security relies **entirely on native operating system filesystem permissions** + +**Best Practices:** + +- Ensure your system keychain is properly configured and accessible +- If you see warnings about plaintext storage, use `content-cli profile secure ` to migrate secrets to the system keychain +- Ensure that: + - Your user account and filesystem are properly secured + - File permissions restrict access to your user account only + - You use appropriate security measures on shared or multi-user systems + - Your system keychain is locked when not in use + +### Migrating Existing Profiles to Secure Storage + +If you have existing profiles with secrets stored in plaintext, you can migrate them +to secure system keychain storage without re-creating the profile: + +``` +content-cli profile secure +``` + +This command will: + +1. Read the existing profile and its plaintext secrets +2. Store the secrets in your system's native keychain/credential store +3. Remove the secrets from the plaintext profile file + +If the profile is already using secure storage, the command will inform you that no +migration is needed. If keychain storage is unavailable, the command will warn you +that secrets remain in plaintext. -- Your user account and filesystem are properly secured -- File permissions restrict access to your user account only -- You use appropriate security measures on shared or multi-user systems +You can verify the migration was successful by checking that no warning is shown +when using the profile in subsequent commands. ### When to Create Profiles diff --git a/package.json b/package.json index 319a5ab0..c01fd308 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "form-data": "4.0.4", "openid-client": "5.6.1", "hpagent": "1.2.0", + "@github/keytar": "7.10.6", "semver": "7.6.3", "simple-git": "3.32.3", "valid-url": "1.0.9", diff --git a/src/commands/profile/module.ts b/src/commands/profile/module.ts index 5f8c07a9..164097ee 100644 --- a/src/commands/profile/module.ts +++ b/src/commands/profile/module.ts @@ -26,6 +26,11 @@ class Module extends IModule { command.command("default ") .description("Command to set a profile as default") .action(this.defaultProfile); + + command.command("secure ") + .description("Migrate a profile's secrets to secure system keychain storage") + .action(this.secureProfile); + } private async defaultProfile(context: Context, command: Command): Promise { @@ -41,6 +46,12 @@ class Module extends IModule { logger.debug("List profiles"); await new ProfileCommandService().listProfiles(); } + + private async secureProfile(context: Context, command: Command): Promise { + const profile = command.args[0]; + await new ProfileCommandService().secureProfile(profile); + } + } export = Module; \ No newline at end of file diff --git a/src/commands/profile/profile-command.service.ts b/src/commands/profile/profile-command.service.ts index 253bb70a..675e49a5 100644 --- a/src/commands/profile/profile-command.service.ts +++ b/src/commands/profile/profile-command.service.ts @@ -34,7 +34,7 @@ export class ProfileCommandService { profile.authenticationType = await ProfileValidator.validateProfile(profile); await this.profileService.authorizeProfile(profile); - this.profileService.storeProfile(profile); + await this.profileService.storeProfile(profile); if (setAsDefault) { await this.makeDefaultProfile(profile.name); } @@ -63,4 +63,22 @@ export class ProfileCommandService { await this.profileService.makeDefaultProfile(profile); logger.info("Default profile: " + profile); } + + public async secureProfile(profileName: string): Promise { + const profile = await this.profileService.findProfile(profileName); + + if (profile.secretsStoredSecurely) { + logger.info(`Profile "${profileName}" is already using secure storage.`); + return; + } + + await this.profileService.storeProfile(profile); + + const updatedProfile = await this.profileService.findProfile(profileName); + if (updatedProfile.secretsStoredSecurely) { + logger.info(`Profile "${profileName}" secrets have been migrated to secure storage.`); + } else { + logger.warn(`Failed to migrate profile "${profileName}" to secure storage. Secrets remain in plaintext.`); + } + } } diff --git a/src/core/profile/profile.interface.ts b/src/core/profile/profile.interface.ts index ae21f547..53db04f6 100644 --- a/src/core/profile/profile.interface.ts +++ b/src/core/profile/profile.interface.ts @@ -1,14 +1,18 @@ -export interface Profile { +export interface ProfileSecrets { + apiToken: string; + clientSecret?: string; + refreshToken?: string; + secretsStoredSecurely?: boolean; +} + +export interface Profile extends ProfileSecrets { name: string; team: string; type: ProfileType; - apiToken: string; authenticationType: AuthenticationType; clientId?: string; - clientSecret?: string; scopes?: string[]; clientAuthenticationMethod?: ClientAuthenticationMethod; - refreshToken?: string; expiresAt?: number; } diff --git a/src/core/profile/profile.service.ts b/src/core/profile/profile.service.ts index 72a9017d..5c0509de 100644 --- a/src/core/profile/profile.service.ts +++ b/src/core/profile/profile.service.ts @@ -9,6 +9,7 @@ import { FatalError, logger } from "../utils/logger"; import { Client, Issuer } from "openid-client"; import axios from "axios"; import os = require("os"); +import { SecureSecretStorageService } from "./secret-storage.service"; const homedir = os.homedir(); // use 5 seconds buffer to avoid rare cases when accessToken is just about to expire before the command is sent @@ -26,6 +27,8 @@ export class ProfileService { private profileContainerPath = path.resolve(homedir, ".celonis-content-cli-profiles"); private configContainer = path.resolve(this.profileContainerPath, "config.json"); + private secureSecretStorageService = new SecureSecretStorageService(); + public async findProfile(profileName: string): Promise { return new Promise((resolve, reject) => { try { @@ -35,8 +38,27 @@ export class ProfileService { { encoding: "utf-8" } ); const profile : Profile = JSON.parse(file); - this.refreshProfile(profile) - .then(() => resolve(profile)); + + if (profile.secretsStoredSecurely) { + this.secureSecretStorageService.getSecrets(profileName) + .then(profileSecureSecrets => { + if (profileSecureSecrets) { + profile.apiToken = profileSecureSecrets.apiToken; + profile.refreshToken = profileSecureSecrets.refreshToken; + profile.clientSecret = profileSecureSecrets.clientSecret; + this.refreshProfile(profile) + .then(() => resolve(profile)) + .catch(() => reject(`The profile ${profileName} couldn't be resolved.`)); + } else { + reject("Failed to read secrets from system keychain."); + } + }) + .catch(() => reject(`The profile ${profileName} couldn't be resolved.`)); + } else { + this.refreshProfile(profile) + .then(() => resolve(profile)) + .catch(() => reject(`The profile ${profileName} couldn't be resolved.`)); + } } else if (process.env.TEAM_URL && process.env.API_TOKEN) { resolve(this.buildProfileFromEnvVariables()); } else if (process.env.CELONIS_URL && process.env.CELONIS_API_TOKEN) { @@ -75,11 +97,25 @@ export class ProfileService { } } - public storeProfile(profile: Profile): void { + public async storeProfile(profile: Profile): Promise { this.createProfileContainerIfNotExists(); const newProfileFileName = this.constructProfileFileName(profile.name); profile.team = this.getBaseTeamUrl(profile.team); - fs.writeFileSync(path.resolve(this.profileContainerPath, newProfileFileName), JSON.stringify(profile), { + + const secretsStoredInKeychain = await this.secureSecretStorageService.storeSecrets(profile); + + const profileToStore: Profile = { ...profile }; + + if (secretsStoredInKeychain) { + profileToStore.secretsStoredSecurely = true; + delete profileToStore.apiToken; + delete profileToStore.refreshToken; + delete profileToStore.clientSecret; + } else { + profileToStore.secretsStoredSecurely = false; + } + + fs.writeFileSync(path.resolve(this.profileContainerPath, newProfileFileName), JSON.stringify(profileToStore), { encoding: "utf-8", }); } @@ -258,7 +294,7 @@ export class ProfileService { } } - this.storeProfile(profile); + await this.storeProfile(profile); } private getProfileEnvVariables(): any { @@ -401,4 +437,3 @@ export class ProfileService { } } -export const profileService = new ProfileService(); diff --git a/src/core/profile/secret-storage.service.ts b/src/core/profile/secret-storage.service.ts new file mode 100644 index 00000000..827ecc7b --- /dev/null +++ b/src/core/profile/secret-storage.service.ts @@ -0,0 +1,105 @@ +import { Profile, ProfileSecrets } from "./profile.interface"; +import { logger } from "../utils/logger"; + +enum PROFILE_SECRET_TYPE { + API_TOKEN = "apiToken", + CLIENT_SECRET = "clientSecret", + REFRESH_TOKEN = "refreshToken", +} + +const CONTENT_CLI_SERVICE_NAME = "celonis-content-cli"; + +// Lazy load keytar to handle cases where native dependencies are not available +let keytar: any = null; +let keytarLoadError: Error | null = null; + +function getKeytar(): any { + if (keytar !== null) { + return keytar; + } + if (keytarLoadError !== null) { + return null; + } + try { + keytar = require("@github/keytar"); + return keytar; + } catch (error) { + keytarLoadError = error as Error; + return null; + } +} + +export class SecureSecretStorageService { + + public async storeSecrets(profile: Profile): Promise { + let secretsStoredInKeychain = true; + const secretEntries = [ + { type: PROFILE_SECRET_TYPE.API_TOKEN, value: profile.apiToken }, + { type: PROFILE_SECRET_TYPE.CLIENT_SECRET, value: profile.clientSecret }, + { type: PROFILE_SECRET_TYPE.REFRESH_TOKEN, value: profile.refreshToken }, + ]; + + for (const secretEntry of secretEntries) { + if (secretEntry.value) { + const stored = await this.storeSecret( + this.getSecretServiceName(profile.name), + secretEntry.type, + secretEntry.value + ); + if (!stored) { + secretsStoredInKeychain = false; + } + } + } + + if (!secretsStoredInKeychain) { + logger.warn("⚠️ Failed to store secrets securely. They will be stored in plain text file."); + return false; + } + + return true; + } + + public async getSecrets(profileName: string): Promise { + const keytarModule = getKeytar(); + if (!keytarModule) { + return undefined; + } + + try { + const secrets = await keytarModule.findCredentials(this.getSecretServiceName(profileName)); + + if (!secrets.length) { + return undefined; + } + + const profileSecrets = {}; + + for (const secret of secrets) { + profileSecrets[secret.account] = secret.password + } + + return profileSecrets as ProfileSecrets; + } catch (err) { + return undefined; + } + } + + private getSecretServiceName(profileName: string): string { + return `${CONTENT_CLI_SERVICE_NAME}:${profileName}`; + } + + private async storeSecret(service: string, account: string, secret: string): Promise { + const keytarModule = getKeytar(); + if (!keytarModule) { + return false; + } + + try { + await keytarModule.setPassword(service, account, secret); + return true; + } catch (err) { + return false; + } + } +} diff --git a/tests/commands/profile/profile-command.service.spec.ts b/tests/commands/profile/profile-command.service.spec.ts new file mode 100644 index 00000000..652b963b --- /dev/null +++ b/tests/commands/profile/profile-command.service.spec.ts @@ -0,0 +1,122 @@ +import { ProfileService } from "../../../src/core/profile/profile.service"; +import { Profile, ProfileType, AuthenticationType } from "../../../src/core/profile/profile.interface"; +import { logger } from "../../../src/core/utils/logger"; + +jest.mock("../../../src/core/profile/profile.service"); +jest.mock("../../../src/core/utils/logger", () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + FatalError: jest.fn().mockImplementation((msg) => new Error(msg)), +})); + +import { ProfileCommandService } from "../../../src/commands/profile/profile-command.service"; + +describe("ProfileCommandService - secureProfile", () => { + let service: ProfileCommandService; + let mockFindProfile: jest.Mock; + let mockStoreProfile: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + service = new ProfileCommandService(); + + mockFindProfile = jest.fn(); + mockStoreProfile = jest.fn(); + + (service as any).profileService = { + findProfile: mockFindProfile, + storeProfile: mockStoreProfile, + readAllProfiles: jest.fn(), + getDefaultProfile: jest.fn(), + makeDefaultProfile: jest.fn(), + authorizeProfile: jest.fn(), + } as unknown as ProfileService; + }); + + it("should skip migration when profile is already using secure storage", async () => { + const profile: Profile = { + name: "already-secure", + team: "https://test.celonis.cloud", + type: ProfileType.KEY, + apiToken: "token", + authenticationType: AuthenticationType.BEARER, + secretsStoredSecurely: true, + }; + + mockFindProfile.mockResolvedValue(profile); + + await service.secureProfile("already-secure"); + + expect(mockFindProfile).toHaveBeenCalledWith("already-secure"); + expect(mockStoreProfile).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + 'Profile "already-secure" is already using secure storage.' + ); + }); + + it("should migrate plaintext profile to secure storage successfully", async () => { + const plaintextProfile: Profile = { + name: "plaintext-profile", + team: "https://test.celonis.cloud", + type: ProfileType.KEY, + apiToken: "my-token", + authenticationType: AuthenticationType.BEARER, + }; + + const securedProfile: Profile = { + ...plaintextProfile, + secretsStoredSecurely: true, + }; + + mockFindProfile + .mockResolvedValueOnce(plaintextProfile) + .mockResolvedValueOnce(securedProfile); + mockStoreProfile.mockResolvedValue(undefined); + + await service.secureProfile("plaintext-profile"); + + expect(mockFindProfile).toHaveBeenCalledTimes(2); + expect(mockStoreProfile).toHaveBeenCalledWith(plaintextProfile); + expect(logger.info).toHaveBeenCalledWith( + 'Profile "plaintext-profile" secrets have been migrated to secure storage.' + ); + }); + + it("should warn when migration to secure storage fails", async () => { + const plaintextProfile: Profile = { + name: "fail-profile", + team: "https://test.celonis.cloud", + type: ProfileType.KEY, + apiToken: "my-token", + authenticationType: AuthenticationType.BEARER, + }; + + const stillPlaintextProfile: Profile = { + ...plaintextProfile, + secretsStoredSecurely: false, + }; + + mockFindProfile + .mockResolvedValueOnce(plaintextProfile) + .mockResolvedValueOnce(stillPlaintextProfile); + mockStoreProfile.mockResolvedValue(undefined); + + await service.secureProfile("fail-profile"); + + expect(mockStoreProfile).toHaveBeenCalledWith(plaintextProfile); + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to migrate profile "fail-profile" to secure storage. Secrets remain in plaintext.' + ); + }); + + it("should propagate errors from findProfile", async () => { + mockFindProfile.mockRejectedValue("Profile not found"); + + await expect(service.secureProfile("nonexistent")).rejects.toBe("Profile not found"); + expect(mockStoreProfile).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/core/profile/profile.service.spec.ts b/tests/core/profile/profile.service.spec.ts index c96b8671..fe2f60db 100644 --- a/tests/core/profile/profile.service.spec.ts +++ b/tests/core/profile/profile.service.spec.ts @@ -4,8 +4,20 @@ import * as os from "os"; import { ProfileValidator } from "../../../src/core/profile/profile.validator"; import { Profile, ProfileType, AuthenticationType } from "../../../src/core/profile/profile.interface"; +const mockHomedir = "/mock/home"; + jest.mock("os", () => ({ - homedir: jest.fn(() => "/mock/home") + homedir: jest.fn(() => mockHomedir) +})); + +const mockStoreSecrets = jest.fn(); +const mockGetSecrets = jest.fn(); + +jest.mock("../../../src/core/profile/secret-storage.service", () => ({ + SecureSecretStorageService: jest.fn().mockImplementation(() => ({ + storeSecrets: mockStoreSecrets, + getSecrets: mockGetSecrets + })) })); const mockIssuerDiscover = jest.fn(); @@ -36,6 +48,10 @@ describe("ProfileService - mapCelonisEnvProfile", () => { beforeEach(() => { profileService = new ProfileService(); + + mockStoreSecrets.mockClear(); + mockGetSecrets.mockClear(); + originalCelonisUrl = process.env.CELONIS_URL; originalCelonisApiToken = process.env.CELONIS_API_TOKEN; originalTeamUrl = process.env.TEAM_URL; @@ -229,12 +245,15 @@ describe("ProfileService - findProfile", () => { let originalCelonisApiToken: string | undefined; let originalTeamUrl: string | undefined; let originalApiToken: string | undefined; - const mockHomedir = "/mock/home"; const mockProfilePath = path.resolve(mockHomedir, ".celonis-content-cli-profiles"); beforeEach(() => { (os.homedir as jest.Mock).mockReturnValue(mockHomedir); profileService = new ProfileService(); + + mockStoreSecrets.mockClear(); + mockGetSecrets.mockClear(); + originalCelonisUrl = process.env.CELONIS_URL; originalCelonisApiToken = process.env.CELONIS_API_TOKEN; originalTeamUrl = process.env.TEAM_URL; @@ -908,7 +927,7 @@ describe("ProfileService - storeProfile", () => { (fs.writeFileSync as jest.Mock).mockImplementation(() => {}); }); - it("should create profile container if not exists and write profile with normalized team URL", () => { + it("should create profile container if not exists and write profile with normalized team URL", async () => { (fs.existsSync as jest.Mock).mockReturnValue(false); const profile: Profile = { @@ -919,7 +938,7 @@ describe("ProfileService - storeProfile", () => { type: ProfileType.KEY, }; - profileService.storeProfile(profile); + await profileService.storeProfile(profile); expect(fs.mkdirSync).toHaveBeenCalledWith(mockProfilePath); expect(fs.writeFileSync).toHaveBeenCalledWith( @@ -930,7 +949,7 @@ describe("ProfileService - storeProfile", () => { expect(profile.team).toBe("https://example.celonis.cloud"); }); - it("should write profile with correct filename", () => { + it("should write profile with correct filename", async () => { const profile: Profile = { name: "my-profile", team: "https://team.celonis.cloud", @@ -939,7 +958,7 @@ describe("ProfileService - storeProfile", () => { type: ProfileType.KEY, }; - profileService.storeProfile(profile); + await profileService.storeProfile(profile); expect(fs.writeFileSync).toHaveBeenCalledWith( path.resolve(mockProfilePath, "my-profile.json"), @@ -1122,7 +1141,7 @@ describe("ProfileService - refreshProfile", () => { clientAuthenticationMethod: "client_secret_basic", expiresAt: Math.floor(Date.now() / 1000) + 3600, }; - const storeSpy = jest.spyOn(profileService, "storeProfile").mockImplementation(() => {}); + const storeSpy = jest.spyOn(profileService, "storeProfile").mockImplementation(async () => {}); await profileService.refreshProfile(profile); @@ -1152,7 +1171,7 @@ describe("ProfileService - refreshProfile", () => { grant: jest.fn().mockResolvedValue(newTokenSet), })), }); - const storeSpy = jest.spyOn(profileService, "storeProfile").mockImplementation(() => {}); + const storeSpy = jest.spyOn(profileService, "storeProfile").mockImplementation(async () => {}); await profileService.refreshProfile(profile); @@ -1162,3 +1181,182 @@ describe("ProfileService - refreshProfile", () => { }); }); +describe("Profile Service - Store Profile", () => { + let profileService: ProfileService; + const mockProfilePath = path.resolve(mockHomedir, ".celonis-content-cli-profiles"); + + beforeEach(() => { + profileService = new ProfileService(); + + mockStoreSecrets.mockClear(); + mockGetSecrets.mockClear(); + + (fs.existsSync as jest.Mock).mockReturnValue(false); + (fs.mkdirSync as jest.Mock).mockImplementation(() => void 0); + (fs.writeFileSync as jest.Mock).mockImplementation(() => void 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("Should store secrets in plain text if keychain storage fails", async () => { + const profile: Profile = { + name: "plain-text-profile", + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + apiToken: "test-token", + clientSecret: "client-secret", + refreshToken: "refresh-token", + authenticationType: AuthenticationType.BEARER, + secretsStoredSecurely: false + }; + + (fs.existsSync as jest.Mock).mockReturnValue(true); + mockStoreSecrets.mockResolvedValue(false); + + await profileService.storeProfile(profile); + + const expectedProfile = { + ...profile, + team: "https://test-team.celonis.cloud" + }; + + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.resolve(mockProfilePath, "plain-text-profile.json"), + JSON.stringify(expectedProfile), + { encoding: "utf-8" } + ); + }); + + it("Should remove all secrets from profile when stored securely", async () => { + const profile: Profile = { + name: "secure-profile", + team: "https://test-team.celonis.cloud", + type: ProfileType.CLIENT_CREDENTIALS, + clientId: "test-client-id", + clientSecret: "test-client-secret", + apiToken: "test-token", + refreshToken: "test-refresh-token", + authenticationType: AuthenticationType.BEARER + }; + + (fs.existsSync as jest.Mock).mockReturnValue(true); + mockStoreSecrets.mockResolvedValue(true); + + await profileService.storeProfile(profile); + + const writeCall = (fs.writeFileSync as jest.Mock).mock.calls[0]; + const storedProfile = JSON.parse(writeCall[1]); + + expect(storedProfile.apiToken).toBeUndefined(); + expect(storedProfile.clientSecret).toBeUndefined(); + expect(storedProfile.refreshToken).toBeUndefined(); + expect(storedProfile.secretsStoredSecurely).toBe(true); + }); +}); + +describe("Profile Service - Find Profile", () => { + let profileService: ProfileService; + const mockProfilePath = path.resolve(mockHomedir, ".celonis-content-cli-profiles"); + + beforeEach(() => { + profileService = new ProfileService(); + + mockStoreSecrets.mockClear(); + mockGetSecrets.mockClear(); + + // Clear environment variables to avoid env-based profile resolution + delete process.env.TEAM_URL; + delete process.env.API_TOKEN; + delete process.env.CELONIS_URL; + delete process.env.CELONIS_API_TOKEN; + + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockImplementation(() => "{}"); + + // Mock refreshProfile to avoid OAuth calls + jest.spyOn(ProfileService.prototype, "refreshProfile").mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + delete process.env.TEAM_URL; + delete process.env.API_TOKEN; + delete process.env.CELONIS_URL; + delete process.env.CELONIS_API_TOKEN; + }); + + it("Should find profile with secrets stored securely", async () => { + const profileName = "secure-profile"; + const storedProfile = { + name: profileName, + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + authenticationType: AuthenticationType.BEARER, + secretsStoredSecurely: true + }; + + const secureSecrets = { + apiToken: "secure-api-token", + refreshToken: "secure-refresh-token", + clientSecret: "secure-client-secret" + }; + + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(storedProfile)); + mockGetSecrets.mockResolvedValue(secureSecrets); + + const profile = await profileService.findProfile(profileName); + + expect(mockGetSecrets).toHaveBeenCalledWith(profileName); + expect(profile.name).toBe(profileName); + expect(profile.team).toBe("https://test-team.celonis.cloud"); + expect(profile.apiToken).toBe(secureSecrets.apiToken); + expect(profile.refreshToken).toBe(secureSecrets.refreshToken); + expect(profile.clientSecret).toBe(secureSecrets.clientSecret); + expect(profile.secretsStoredSecurely).toBe(true); + }); + + it("Should find profile with secrets not stored securely", async () => { + const profileName = "plain-text-profile"; + const storedProfile = { + name: profileName, + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + authenticationType: AuthenticationType.BEARER, + apiToken: "plain-api-token", + refreshToken: "plain-refresh-token", + clientSecret: "plain-client-secret" + }; + + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(storedProfile)); + + const profile = await profileService.findProfile(profileName); + + expect(mockGetSecrets).not.toHaveBeenCalled(); + expect(profile.name).toBe(profileName); + expect(profile.team).toBe("https://test-team.celonis.cloud"); + expect(profile.apiToken).toBe(storedProfile.apiToken); + expect(profile.refreshToken).toBe(storedProfile.refreshToken); + expect(profile.clientSecret).toBe(storedProfile.clientSecret); + expect(profile.secretsStoredSecurely).toBeUndefined(); + }); + + it("Should throw error when secrets are stored securely but could not be retrieved", async () => { + const profileName = "secure-profile-fail"; + const storedProfile = { + name: profileName, + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + authenticationType: AuthenticationType.BEARER, + secretsStoredSecurely: true + }; + + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(storedProfile)); + mockGetSecrets.mockResolvedValue(undefined); + + await expect(profileService.findProfile(profileName)).rejects.toBe("Failed to read secrets from system keychain."); + expect(mockGetSecrets).toHaveBeenCalledWith(profileName); + }); +}); + diff --git a/tests/core/profile/secret-storage.service.keytar-unavailable.spec.ts b/tests/core/profile/secret-storage.service.keytar-unavailable.spec.ts new file mode 100644 index 00000000..ffd9a293 --- /dev/null +++ b/tests/core/profile/secret-storage.service.keytar-unavailable.spec.ts @@ -0,0 +1,47 @@ +import { Profile, ProfileType, AuthenticationType } from "../../../src/core/profile/profile.interface"; + +jest.doMock("@github/keytar", () => { + throw new Error("Mock failure in loading lib"); +}, { virtual: true }); + +import { SecureSecretStorageService } from "../../../src/core/profile/secret-storage.service"; + +describe("SecureSecretStorageService - Keytar Unavailable", () => { + let service: SecureSecretStorageService; + + beforeEach(() => { + service = new SecureSecretStorageService(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("storeSecrets", () => { + it("Should return false when keytar module cannot be loaded", async () => { + const profile: Profile = { + name: "no-keytar-profile", + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + apiToken: "test-token", + clientSecret: "test-client-secret", + refreshToken: "test-refresh-token", + authenticationType: AuthenticationType.BEARER + }; + + const result = await service.storeSecrets(profile); + + expect(result).toBe(false); + }); + }); + + describe("getSecrets", () => { + it("Should return undefined when keytar module cannot be loaded", async () => { + const profileName = "no-keytar-profile"; + const result = await service.getSecrets(profileName); + + expect(result).toBeUndefined(); + }); + }); +}); + diff --git a/tests/core/profile/secret-storage.service.spec.ts b/tests/core/profile/secret-storage.service.spec.ts new file mode 100644 index 00000000..b332ef98 --- /dev/null +++ b/tests/core/profile/secret-storage.service.spec.ts @@ -0,0 +1,202 @@ +import { Profile, ProfileType, AuthenticationType } from "../../../src/core/profile/profile.interface"; +import { + SecureSecretStorageService, +} from "../../../src/core/profile/secret-storage.service"; + +const mockKeytar = { + setPassword: jest.fn(), + findCredentials: jest.fn() +}; + +jest.mock("@github/keytar", () => { + return mockKeytar; +}, { virtual: true }); + +describe("SecureSecretStorageService", () => { + let service: any; + + beforeEach(() => { + jest.resetModules(); + + jest.clearAllMocks(); + + mockKeytar.setPassword.mockClear(); + mockKeytar.findCredentials.mockClear(); + + service = new SecureSecretStorageService(); + + const keytarModule = require("@github/keytar"); + expect(keytarModule).toBe(mockKeytar); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("storeSecrets", () => { + it("Should store all secrets successfully when keytar is available", async () => { + const profile: Profile = { + name: "test-profile", + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + apiToken: "test-api-token", + clientSecret: "test-client-secret", + refreshToken: "test-refresh-token", + authenticationType: AuthenticationType.BEARER + }; + + // keytar.setPassword returns Promise (resolves to undefined on success) + mockKeytar.setPassword.mockResolvedValue(undefined); + + const result = await service.storeSecrets(profile); + + expect(result).toBe(true); + expect(mockKeytar.setPassword).toHaveBeenCalledTimes(3); + expect(mockKeytar.setPassword).toHaveBeenCalledWith( + "celonis-content-cli:test-profile", + "apiToken", + "test-api-token" + ); + expect(mockKeytar.setPassword).toHaveBeenCalledWith( + "celonis-content-cli:test-profile", + "clientSecret", + "test-client-secret" + ); + expect(mockKeytar.setPassword).toHaveBeenCalledWith( + "celonis-content-cli:test-profile", + "refreshToken", + "test-refresh-token" + ); + }); + + it("Should return false when keytar setPassword throws for any secret", async () => { + const profile: Profile = { + name: "throwing-profile", + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + apiToken: "test-api-token", + authenticationType: AuthenticationType.BEARER + }; + + mockKeytar.setPassword.mockRejectedValue(new Error("Keychain unavailable")); + + const result = await service.storeSecrets(profile); + + expect(result).toBe(false); + }); + + it("Should skip undefined or null secret values", async () => { + const profile: Profile = { + name: "partial-secrets-profile", + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + apiToken: "test-api-token", + clientSecret: undefined, + refreshToken: null as any, + authenticationType: AuthenticationType.BEARER + }; + + mockKeytar.setPassword.mockResolvedValue(undefined); + + const result = await service.storeSecrets(profile); + + expect(result).toBe(true); + expect(mockKeytar.setPassword).toHaveBeenCalledTimes(1); + expect(mockKeytar.setPassword).toHaveBeenCalledWith( + "celonis-content-cli:partial-secrets-profile", + "apiToken", + "test-api-token" + ); + }); + + }); + + describe("getSecrets", () => { + it("Should retrieve secrets successfully when keytar is available", async () => { + const profileName = "test-profile"; + const mockSecrets = [ + { account: "apiToken", password: "retrieved-api-token" }, + { account: "clientSecret", password: "retrieved-client-secret" }, + { account: "refreshToken", password: "retrieved-refresh-token" } + ]; + + mockKeytar.findCredentials.mockResolvedValue(mockSecrets); + + const result = await service.getSecrets(profileName); + + expect(result).toEqual({ + apiToken: "retrieved-api-token", + clientSecret: "retrieved-client-secret", + refreshToken: "retrieved-refresh-token" + }); + expect(mockKeytar.findCredentials).toHaveBeenCalledWith("celonis-content-cli:test-profile"); + }); + + it("Should return undefined when no secrets are found", async () => { + const profileName = "non-existent-profile"; + + mockKeytar.findCredentials.mockResolvedValue([]); + + const result = await service.getSecrets(profileName); + + expect(result).toBeUndefined(); + expect(mockKeytar.findCredentials).toHaveBeenCalledWith("celonis-content-cli:non-existent-profile"); + }); + + it("Should return undefined when findCredentials throws", async () => { + const profileName = "locked-keychain-profile"; + + mockKeytar.findCredentials.mockRejectedValue(new Error("Keychain locked")); + + const result = await service.getSecrets(profileName); + + expect(result).toBeUndefined(); + expect(mockKeytar.findCredentials).toHaveBeenCalledWith("celonis-content-cli:locked-keychain-profile"); + }); + + it("Should map account names to profile secrets correctly", async () => { + const profileName = "mapping-test-profile"; + const mockSecrets = [ + { account: "apiToken", password: "token-123" }, + { account: "clientSecret", password: "secret-456" }, + { account: "refreshToken", password: "refresh-789" } + ]; + + mockKeytar.findCredentials.mockResolvedValue(mockSecrets); + + const result = await service.getSecrets(profileName); + + expect(result).toEqual({ + apiToken: "token-123", + clientSecret: "secret-456", + refreshToken: "refresh-789" + }); + }); + }); + + describe("service name construction", () => { + it("Should construct correct service name", async () => { + const expectedService = { profileName: "profile1", serviceName: "celonis-content-cli:profile1" }; + + const profile: Profile = { + name: expectedService.profileName, + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + apiToken: "test-token", + authenticationType: AuthenticationType.BEARER + }; + + mockKeytar.setPassword.mockResolvedValue(undefined); + jest.clearAllMocks(); + + await service.storeSecrets(profile); + + expect(mockKeytar.setPassword).toHaveBeenCalledWith( + expectedService.serviceName, + "apiToken", + profile.apiToken + ); + }); + }); +}); + diff --git a/yarn.lock b/yarn.lock index 39f5cf89..190c9b29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19,6 +19,15 @@ js-tokens "^4.0.0" picocolors "^1.0.0" +"@babel/code-frame@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== + dependencies: + "@babel/helper-validator-identifier" "^7.27.1" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.26.5": version "7.26.5" resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.5.tgz" @@ -94,23 +103,33 @@ resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz" integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + "@babel/helper-validator-identifier@^7.25.9": version "7.25.9" resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz" integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== +"@babel/helper-validator-identifier@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" + integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== + "@babel/helper-validator-option@^7.25.9": version "7.25.9" resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz" integrity sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw== "@babel/helpers@^7.26.0": - version "7.26.0" - resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz" - integrity sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw== + version "7.28.3" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.3.tgz#b83156c0a2232c133d1b535dd5d3452119c7e441" + integrity sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw== dependencies: - "@babel/template" "^7.25.9" - "@babel/types" "^7.26.0" + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.2" "@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.25.9", "@babel/parser@^7.26.0", "@babel/parser@^7.26.5": version "7.26.5" @@ -119,6 +138,13 @@ dependencies: "@babel/types" "^7.26.5" +"@babel/parser@^7.27.2": + version "7.28.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.3.tgz#d2d25b814621bca5fe9d172bc93792547e7a2a71" + integrity sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA== + dependencies: + "@babel/types" "^7.28.2" + "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz" @@ -247,6 +273,15 @@ "@babel/parser" "^7.25.9" "@babel/types" "^7.25.9" +"@babel/template@^7.27.2": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" + integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/parser" "^7.27.2" + "@babel/types" "^7.27.1" + "@babel/traverse@^7.25.9": version "7.26.5" resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.5.tgz" @@ -268,6 +303,14 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" +"@babel/types@^7.27.1", "@babel/types@^7.28.2": + version "7.28.2" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.2.tgz#da9db0856a9a88e0a13b019881d7513588cf712b" + integrity sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz" @@ -313,6 +356,13 @@ reflect-metadata "^0.1.12" tslib "^1.8.1" +"@github/keytar@7.10.6": + version "7.10.6" + resolved "https://registry.yarnpkg.com/@github/keytar/-/keytar-7.10.6.tgz#528f2c9f8c55a58e38ca271288cc59a2d7aec269" + integrity sha512-mRW6cUsSG+nj4jp5gp8e91zPySaT73r+2JM6VyMZfrEgksjPmjSMr+tPGNOK3HUHV+GUU9B1LAiiYy/wmAnIxA== + dependencies: + node-addon-api "^8.3.0" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz" @@ -812,7 +862,7 @@ asynckit@^0.4.0: axios@1.13.5: version "1.13.5" - resolved "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.5.tgz#5e464688fa127e11a660a2c49441c009f6567a43" integrity sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q== dependencies: follow-redirects "^1.15.11" @@ -945,7 +995,7 @@ builtin-modules@^1.1.1: call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" - resolved "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== dependencies: es-errors "^1.3.0" @@ -973,7 +1023,7 @@ caniuse-lite@^1.0.30001688: chalk@4.1.2, chalk@^4.0.0, chalk@^4.0.2: version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== dependencies: ansi-styles "^4.1.0" @@ -1193,7 +1243,7 @@ diff@^4.0.1: dunder-proto@^1.0.1: version "1.0.1" - resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== dependencies: call-bind-apply-helpers "^1.0.1" @@ -1246,24 +1296,24 @@ error-ex@^1.3.1: es-define-property@^1.0.1: version "1.0.1" - resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== es-errors@^1.3.0: version "1.3.0" - resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" - resolved "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== dependencies: es-errors "^1.3.0" es-set-tostringtag@^2.1.0: version "2.1.0" - resolved "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== dependencies: es-errors "^1.3.0" @@ -1388,12 +1438,12 @@ fn.name@1.x.x: follow-redirects@^1.15.11: version "1.15.11" - resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== form-data@4.0.4: version "4.0.4" - resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== dependencies: asynckit "^0.4.0" @@ -1404,7 +1454,7 @@ form-data@4.0.4: form-data@^4.0.5: version "4.0.5" - resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== dependencies: asynckit "^0.4.0" @@ -1445,7 +1495,7 @@ get-east-asian-width@^1.0.0: get-intrinsic@^1.2.6: version "1.3.0" - resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== dependencies: call-bind-apply-helpers "^1.0.2" @@ -1466,7 +1516,7 @@ get-package-type@^0.1.0: get-proto@^1.0.1: version "1.0.1" - resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== dependencies: dunder-proto "^1.0.1" @@ -1499,7 +1549,7 @@ globals@^11.1.0: gopd@^1.2.0: version "1.2.0" - resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== graceful-fs@^4.2.9: @@ -1519,12 +1569,12 @@ has-flag@^4.0.0: has-symbols@^1.0.3, has-symbols@^1.1.0: version "1.1.0" - resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== has-tostringtag@^1.0.2: version "1.0.2" - resolved "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== dependencies: has-symbols "^1.0.3" @@ -2070,9 +2120,9 @@ js-tokens@^4.0.0: integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== js-yaml@^3.13.1: - version "3.14.2" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.2.tgz#77485ce1dd7f33c061fd1b16ecea23b55fcb04b0" - integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg== + version "3.14.1" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== dependencies: argparse "^1.0.7" esprima "^4.0.0" @@ -2220,7 +2270,7 @@ makeerror@1.0.12: math-intrinsics@^1.1.0: version "1.1.0" - resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== merge-stream@^2.0.0: @@ -2295,9 +2345,9 @@ minipass@^5.0.0: integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== "minipass@^5.0.0 || ^6.0.2 || ^7.0.0": - version "7.1.3" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" - integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== mkdirp@^0.5.1: version "0.5.6" @@ -2316,6 +2366,11 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +node-addon-api@^8.3.0: + version "8.6.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.6.0.tgz#b22497201b465cd0a92ef2c01074ee5068c79a6d" + integrity sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz"