From 588b394ddac8ff4a70588753261301d764f3ae02 Mon Sep 17 00:00:00 2001 From: gonzaloriestra <14979109+gonzaloriestra@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:46:38 +0000 Subject: [PATCH 1/2] [Security] Mask SHOPIFY_API_SECRET in CLI output This PR hardens the `env show` and `env pull` commands by masking the `SHOPIFY_API_SECRET` in the console output. - Adds `MaskContentToken` to `cli-kit` for consistent redaction. - Masks the secret in `app env show` when outputting in text format. - Masks the secret in `app env pull` when displaying the updated `.env` file content and the diff to the user. - Ensures masking applies to all lines in the diff (added, removed, and context) to prevent leakage. - Maintains cleartext secrets in JSON output for automation and in the actual `.env` file for application functionality. --- .../app/src/cli/services/app/env/pull.test.ts | 25 ++++++++++++++--- packages/app/src/cli/services/app/env/pull.ts | 27 ++++++++++++++++--- .../app/src/cli/services/app/env/show.test.ts | 2 +- packages/app/src/cli/services/app/env/show.ts | 2 +- .../src/private/node/content-tokens.ts | 8 ++++++ packages/cli-kit/src/public/node/output.ts | 4 +++ 6 files changed, 58 insertions(+), 10 deletions(-) diff --git a/packages/app/src/cli/services/app/env/pull.test.ts b/packages/app/src/cli/services/app/env/pull.test.ts index c8fd46e2488..056e8314d9a 100644 --- a/packages/app/src/cli/services/app/env/pull.test.ts +++ b/packages/app/src/cli/services/app/env/pull.test.ts @@ -40,7 +40,7 @@ describe('env pull', () => { "Created ${filePath}: SHOPIFY_API_KEY=api-key - SHOPIFY_API_SECRET=api-secret + SHOPIFY_API_SECRET=*** SCOPES=my-scope " `) @@ -66,15 +66,15 @@ describe('env pull', () => { "Updated ${filePath} to be: SHOPIFY_API_KEY=api-key - SHOPIFY_API_SECRET=api-secret + SHOPIFY_API_SECRET=*** SCOPES=my-scope Here's what changed: - SHOPIFY_API_KEY=ABC - - SHOPIFY_API_SECRET=XYZ + - SHOPIFY_API_SECRET=*** + SHOPIFY_API_KEY=api-key - + SHOPIFY_API_SECRET=api-secret + + SHOPIFY_API_SECRET=*** SCOPES=my-scope " `) @@ -98,6 +98,23 @@ describe('env pull', () => { `) }) }) + + test('masks secrets even in common lines of the diff', async () => { + await file.inTemporaryDirectory(async (tmpDir: string) => { + // Given + const filePath = resolvePath(tmpDir, '.env') + // SHOPIFY_API_SECRET is the same in both versions, but other fields change + file.writeFileSync(filePath, 'SHOPIFY_API_KEY=ABC\nSHOPIFY_API_SECRET=api-secret\nSCOPES=old-scope') + vi.spyOn(file, 'writeFile') + + // When + const result = await pullEnv({app, remoteApp, organization: ORG1, envFile: filePath}) + + // Then + expect(unstyled(stringifyMessage(result))).toContain('SHOPIFY_API_SECRET=***') + expect(unstyled(stringifyMessage(result))).not.toContain('SHOPIFY_API_SECRET=api-secret') + }) + }) }) function mockApp(): AppInterface { diff --git a/packages/app/src/cli/services/app/env/pull.ts b/packages/app/src/cli/services/app/env/pull.ts index 422749dd02e..038dabd81df 100644 --- a/packages/app/src/cli/services/app/env/pull.ts +++ b/packages/app/src/cli/services/app/env/pull.ts @@ -4,7 +4,7 @@ import {logMetadataForLoadedContext} from '../../context.js' import {Organization, OrganizationApp} from '../../../models/organization.js' import {patchEnvFile} from '@shopify/cli-kit/node/dot-env' -import {diffLines} from 'diff' +import {diffLines, Change} from 'diff' import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs' import {OutputMessage, outputContent, outputToken} from '@shopify/cli-kit/node/output' @@ -36,11 +36,11 @@ export async function pullEnv({app, remoteApp, organization, envFile}: PullEnvOp const diff = diffLines(envFileContent ?? '', updatedEnvFileContent) return outputContent`Updated ${outputToken.path(envFile)} to be: -${updatedEnvFileContent} +${maskSecret(updatedEnvFileContent)} Here's what changed: -${outputToken.linesDiff(diff)} +${outputToken.linesDiff(maskDiff(diff))} ` } } else { @@ -50,7 +50,26 @@ ${outputToken.linesDiff(diff)} return outputContent`Created ${outputToken.path(envFile)}: -${newEnvFileContent} +${maskSecret(newEnvFileContent)} ` } } + +function maskSecret(envFileContent: string): string { + return envFileContent.replace(/SHOPIFY_API_SECRET=.*/g, (match) => { + const index = match.indexOf('=') + if (index === -1) return match + const key = match.substring(0, index) + const value = match.substring(index + 1) + return `${key}=${outputToken.mask(value).output()}` + }) +} + +function maskDiff(diff: Change[]): Change[] { + return diff.map((part) => { + return { + ...part, + value: maskSecret(part.value), + } + }) +} diff --git a/packages/app/src/cli/services/app/env/show.test.ts b/packages/app/src/cli/services/app/env/show.test.ts index cd3e039e7c9..17dbafd191e 100644 --- a/packages/app/src/cli/services/app/env/show.test.ts +++ b/packages/app/src/cli/services/app/env/show.test.ts @@ -38,7 +38,7 @@ describe('env show', () => { expect(unstyled(stringifyMessage(result))).toMatchInlineSnapshot(` " SHOPIFY_API_KEY=api-key - SHOPIFY_API_SECRET=api-secret + SHOPIFY_API_SECRET=*** SCOPES=my-scope " `) diff --git a/packages/app/src/cli/services/app/env/show.ts b/packages/app/src/cli/services/app/env/show.ts index a2dbab01a07..310979dfe00 100644 --- a/packages/app/src/cli/services/app/env/show.ts +++ b/packages/app/src/cli/services/app/env/show.ts @@ -30,7 +30,7 @@ export async function outputEnv( } else { return outputContent` ${outputToken.green('SHOPIFY_API_KEY')}=${remoteApp.apiKey} - ${outputToken.green('SHOPIFY_API_SECRET')}=${remoteApp.apiSecretKeys[0]?.secret ?? ''} + ${outputToken.green('SHOPIFY_API_SECRET')}=${outputToken.mask(remoteApp.apiSecretKeys[0]?.secret ?? '')} ${outputToken.green('SCOPES')}=${getAppScopes(app.configuration)} ` } diff --git a/packages/cli-kit/src/private/node/content-tokens.ts b/packages/cli-kit/src/private/node/content-tokens.ts index 5f2cbb5eee3..ad9dea9513a 100644 --- a/packages/cli-kit/src/private/node/content-tokens.ts +++ b/packages/cli-kit/src/private/node/content-tokens.ts @@ -129,3 +129,11 @@ export class ItalicContentToken extends ContentToken { return colors.italic(stringifyMessage(this.value)) } } + +export class MaskContentToken extends ContentToken { + output(): string { + if (this.value.length === 0) return '' + if (this.value.length <= 20) return '***' + return `${this.value.substring(0, 10)}***` + } +} diff --git a/packages/cli-kit/src/public/node/output.ts b/packages/cli-kit/src/public/node/output.ts index af883ebd016..50fb42ef1d6 100644 --- a/packages/cli-kit/src/public/node/output.ts +++ b/packages/cli-kit/src/public/node/output.ts @@ -15,6 +15,7 @@ import { JsonContentToken, LinesDiffContentToken, LinkContentToken, + MaskContentToken, PathContentToken, RawContentToken, SubHeadingContentToken, @@ -92,6 +93,9 @@ export const outputToken = { linesDiff(value: Change[]): LinesDiffContentToken { return new LinesDiffContentToken(value) }, + mask(value: string): MaskContentToken { + return new MaskContentToken(value) + }, } /** From 0d42c4f0512b0c90ff70463d897c538e6232e373 Mon Sep 17 00:00:00 2001 From: gonzaloriestra <14979109+gonzaloriestra@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:58:18 +0000 Subject: [PATCH 2/2] [Security] Mask SHOPIFY_API_SECRET in CLI output This PR hardens the `env show` and `env pull` commands by masking the `SHOPIFY_API_SECRET` in the console output. - Adds `MaskContentToken` to `cli-kit` for consistent redaction. - Masks the secret in `app env show` when outputting in text format. - Masks the secret in `app env pull` when displaying the updated `.env` file content and the diff to the user. - Ensures masking applies to all lines in the diff (added, removed, and context) to prevent leakage. - Maintains cleartext secrets in JSON output for automation and in the actual `.env` file for application functionality. - Updates relevant unit tests including `info.test.ts` to reflect the masking behavior. --- packages/app/src/cli/services/info.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/cli/services/info.test.ts b/packages/app/src/cli/services/info.test.ts index 2b7acb04be0..fbeaee60ce3 100644 --- a/packages/app/src/cli/services/info.test.ts +++ b/packages/app/src/cli/services/info.test.ts @@ -97,7 +97,7 @@ describe('info', () => { expect(unstyled(stringifyMessage(result))).toMatchInlineSnapshot(` " SHOPIFY_API_KEY=api-key - SHOPIFY_API_SECRET=api-secret + SHOPIFY_API_SECRET=*** SCOPES=my-scope " `)