Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
68 changes: 55 additions & 13 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<profile-name>`

**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 <profile>` 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 <profileName>
```

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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src/commands/profile/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ class Module extends IModule {
command.command("default <profile>")
.description("Command to set a profile as default")
.action(this.defaultProfile);

command.command("secure <profile>")
.description("Migrate a profile's secrets to secure system keychain storage")
.action(this.secureProfile);

}

private async defaultProfile(context: Context, command: Command): Promise<void> {
Expand All @@ -41,6 +46,12 @@ class Module extends IModule {
logger.debug("List profiles");
await new ProfileCommandService().listProfiles();
}

private async secureProfile(context: Context, command: Command): Promise<void> {
const profile = command.args[0];
await new ProfileCommandService().secureProfile(profile);
}

}

export = Module;
20 changes: 19 additions & 1 deletion src/commands/profile/profile-command.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -63,4 +63,22 @@ export class ProfileCommandService {
await this.profileService.makeDefaultProfile(profile);
logger.info("Default profile: " + profile);
}

public async secureProfile(profileName: string): Promise<void> {
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.`);
}
}
}
12 changes: 8 additions & 4 deletions src/core/profile/profile.interface.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down
47 changes: 41 additions & 6 deletions src/core/profile/profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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
Expand All @@ -26,6 +27,8 @@
private profileContainerPath = path.resolve(homedir, ".celonis-content-cli-profiles");
private configContainer = path.resolve(this.profileContainerPath, "config.json");

private secureSecretStorageService = new SecureSecretStorageService();

Check warning on line 30 in src/core/profile/profile.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'secureSecretStorageService' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=celonis_content-cli&issues=AZz8KK-9aGxXXtxMklvL&open=AZz8KK-9aGxXXtxMklvL&pullRequest=323

public async findProfile(profileName: string): Promise<Profile> {
return new Promise<Profile>((resolve, reject) => {
try {
Expand All @@ -35,8 +38,27 @@
{ 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.`));

Check warning on line 51 in src/core/profile/profile.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected the Promise rejection reason to be an Error.

See more on https://sonarcloud.io/project/issues?id=celonis_content-cli&issues=AZz8KK-9aGxXXtxMklvM&open=AZz8KK-9aGxXXtxMklvM&pullRequest=323
} else {
reject("Failed to read secrets from system keychain.");

Check warning on line 53 in src/core/profile/profile.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected the Promise rejection reason to be an Error.

See more on https://sonarcloud.io/project/issues?id=celonis_content-cli&issues=AZz8KK-9aGxXXtxMklvN&open=AZz8KK-9aGxXXtxMklvN&pullRequest=323
}
})
.catch(() => reject(`The profile ${profileName} couldn't be resolved.`));

Check warning on line 56 in src/core/profile/profile.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected the Promise rejection reason to be an Error.

See more on https://sonarcloud.io/project/issues?id=celonis_content-cli&issues=AZz8KK-9aGxXXtxMklvO&open=AZz8KK-9aGxXXtxMklvO&pullRequest=323
} else {
this.refreshProfile(profile)
.then(() => resolve(profile))
.catch(() => reject(`The profile ${profileName} couldn't be resolved.`));

Check warning on line 60 in src/core/profile/profile.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected the Promise rejection reason to be an Error.

See more on https://sonarcloud.io/project/issues?id=celonis_content-cli&issues=AZz8KK-9aGxXXtxMklvP&open=AZz8KK-9aGxXXtxMklvP&pullRequest=323
}
} else if (process.env.TEAM_URL && process.env.API_TOKEN) {
resolve(this.buildProfileFromEnvVariables());
} else if (process.env.CELONIS_URL && process.env.CELONIS_API_TOKEN) {
Expand Down Expand Up @@ -75,11 +97,25 @@
}
}

public storeProfile(profile: Profile): void {
public async storeProfile(profile: Profile): Promise<void> {
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",
});
}
Expand Down Expand Up @@ -258,7 +294,7 @@
}
}

this.storeProfile(profile);
await this.storeProfile(profile);
}

private getProfileEnvVariables(): any {
Expand Down Expand Up @@ -401,4 +437,3 @@
}
}

export const profileService = new ProfileService();
105 changes: 105 additions & 0 deletions src/core/profile/secret-storage.service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<ProfileSecrets | undefined> {
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;
}

Check warning on line 85 in src/core/profile/secret-storage.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=celonis_content-cli&issues=AZz8QBYg8kKnt5SDr-Nm&open=AZz8QBYg8kKnt5SDr-Nm&pullRequest=323
}

private getSecretServiceName(profileName: string): string {
return `${CONTENT_CLI_SERVICE_NAME}:${profileName}`;
}

private async storeSecret(service: string, account: string, secret: string): Promise<boolean> {
const keytarModule = getKeytar();
if (!keytarModule) {
return false;
}

try {
await keytarModule.setPassword(service, account, secret);
return true;
} catch (err) {
return false;
}

Check warning on line 103 in src/core/profile/secret-storage.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=celonis_content-cli&issues=AZz8KK6IaGxXXtxMklvK&open=AZz8KK6IaGxXXtxMklvK&pullRequest=323
}
}
Loading
Loading