diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b30dd53b3..dfaaa07fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes +- [eas-cli] Escape Apple credentials before scrubbing them from `eas metadata` telemetry, so a value containing `+`, `.` or `(` is redacted instead of being sent verbatim or throwing. ([#4256](https://github.com/expo/eas-cli/pull/4256) by [@dennytosp](https://github.com/dennytosp)) + ### ๐Ÿงน Chores ## [22.2.0](https://github.com/expo/eas-cli/releases/tag/v22.2.0) - 2026-08-20 diff --git a/packages/eas-cli/src/metadata/utils/__tests__/telemetry.test.ts b/packages/eas-cli/src/metadata/utils/__tests__/telemetry.test.ts index f6f2f2391b..3139feb049 100644 --- a/packages/eas-cli/src/metadata/utils/__tests__/telemetry.test.ts +++ b/packages/eas-cli/src/metadata/utils/__tests__/telemetry.test.ts @@ -79,6 +79,30 @@ describe(makeDataScrubberAsync, () => { ).toBe('{APPLE_TOKEN} {APPLE_USERNAME} {APPLE_PASSWORD}'); }); + it('scrubs credentials that contain characters with a meaning in a pattern', async () => { + const scrubber = await makeDataScrubberAsync({ + ...stub, + auth: { + ...stub.auth, + username: 'user+eas@icloud.com', + password: 'S3cret(1)', + }, + }); + + expect(scrubber('login user+eas@icloud.com with S3cret(1)')).toBe( + 'login {APPLE_USERNAME} with {APPLE_PASSWORD}' + ); + }); + + it('leaves text that only looks like a credential alone', async () => { + const scrubber = await makeDataScrubberAsync({ + ...stub, + auth: { ...stub.auth, password: 'a.c' }, + }); + + expect(scrubber('abc')).toBe('abc'); + }); + it('scrubs json and transforms it to string', async () => { const scrubber = await makeDataScrubberAsync(stub); expect(scrubber({ foo: 'bar' })).toBe('{"foo":"bar"}'); diff --git a/packages/eas-cli/src/metadata/utils/telemetry.ts b/packages/eas-cli/src/metadata/utils/telemetry.ts index 4a721214b4..c608c7052a 100644 --- a/packages/eas-cli/src/metadata/utils/telemetry.ts +++ b/packages/eas-cli/src/metadata/utils/telemetry.ts @@ -3,6 +3,7 @@ import type { AxiosError } from 'axios'; import { v4 as uuidv4 } from 'uuid'; import { Analytics, MetadataEvent } from '../../analytics/AnalyticsManager'; +import escapeRegExp from '../../utils/expodash/escapeRegExp'; export type TelemetryContext = { app: App; @@ -74,14 +75,12 @@ export async function makeDataScrubberAsync({ }: TelemetryContext): Promise<(data: T) => string> { const token = await getAuthTokenStringAsync(auth); const patterns: Record = { - APPLE_APP_ID: new RegExp(app.id, 'gi'), - APPLE_USERNAME: auth.username ? new RegExp(auth.username, 'gi') : null, - APPLE_PASSWORD: auth.password ? new RegExp(auth.password, 'gi') : null, - APPLE_TOKEN: token ? new RegExp(token, 'gi') : null, - APPLE_TEAM_ID: auth.context?.teamId ? new RegExp(auth.context.teamId, 'gi') : null, - APPLE_PROVIDER_ID: auth.context?.providerId - ? new RegExp(String(auth.context.providerId), 'gi') - : null, + APPLE_APP_ID: literalPattern(app.id), + APPLE_USERNAME: literalPattern(auth.username), + APPLE_PASSWORD: literalPattern(auth.password), + APPLE_TOKEN: literalPattern(token), + APPLE_TEAM_ID: literalPattern(auth.context?.teamId), + APPLE_PROVIDER_ID: literalPattern(auth.context?.providerId), }; const iterator = Object.entries(patterns); @@ -101,6 +100,18 @@ export async function makeDataScrubberAsync({ }; } +/** + * A pattern matching the value itself, and nothing else. + * + * The values scrubbed here are chosen by the user, so they routinely contain characters that mean + * something in a pattern. Left unescaped, an Apple ID like `user+eas@icloud.com` or a password like + * `p+ssw0rd` is not matched by the pattern built from it and reaches the telemetry unscrubbed, and + * one containing `(` or `[` makes `new RegExp` throw before the first request goes out. + */ +function literalPattern(value: string | number | null | undefined): RegExp | null { + return value || value === 0 ? new RegExp(escapeRegExp(String(value)), 'gi') : null; +} + async function getAuthTokenStringAsync(auth: TelemetryContext['auth']): Promise { if (!auth.context?.token) { return null; diff --git a/packages/eas-cli/src/utils/expodash/__tests__/escapeRegExp-test.ts b/packages/eas-cli/src/utils/expodash/__tests__/escapeRegExp-test.ts new file mode 100644 index 0000000000..fc4aaf9667 --- /dev/null +++ b/packages/eas-cli/src/utils/expodash/__tests__/escapeRegExp-test.ts @@ -0,0 +1,22 @@ +import escapeRegExp from '../escapeRegExp'; + +describe(escapeRegExp, () => { + it('leaves a value without special characters alone', () => { + expect(escapeRegExp('SECRET_PASSWORD')).toBe('SECRET_PASSWORD'); + }); + + it('escapes every character with a meaning in a pattern', () => { + expect(escapeRegExp('\\^$.*+?()[]{}|')).toBe('\\\\\\^\\$\\.\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|'); + }); + + it('makes a pattern that matches the value itself', () => { + for (const value of ['user+eas@icloud.com', 'p+ssw0rd', 'S3cret(1', 'a.c', '^end$']) { + expect(new RegExp(escapeRegExp(value)).test(value)).toBe(true); + } + }); + + it('makes a pattern that matches nothing else', () => { + expect(new RegExp(escapeRegExp('a.c')).test('abc')).toBe(false); + expect(new RegExp(escapeRegExp('a+b')).test('aab')).toBe(false); + }); +}); diff --git a/packages/eas-cli/src/utils/expodash/escapeRegExp.ts b/packages/eas-cli/src/utils/expodash/escapeRegExp.ts new file mode 100644 index 0000000000..f578cc1c06 --- /dev/null +++ b/packages/eas-cli/src/utils/expodash/escapeRegExp.ts @@ -0,0 +1,6 @@ +/** `lodash.escapeRegExp` */ +const REGEXP_SPECIAL_CHARACTERS = /[\\^$.*+?()[\]{}|]/g; + +export default function escapeRegExp(value: string): string { + return value.replace(REGEXP_SPECIAL_CHARACTERS, '\\$&'); +}