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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions packages/eas-cli/src/metadata/utils/__tests__/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}');
Expand Down
27 changes: 19 additions & 8 deletions packages/eas-cli/src/metadata/utils/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -74,14 +75,12 @@ export async function makeDataScrubberAsync({
}: TelemetryContext): Promise<<T>(data: T) => string> {
const token = await getAuthTokenStringAsync(auth);
const patterns: Record<string, RegExp | null> = {
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);
Expand All @@ -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<string | null> {
if (!auth.context?.token) {
return null;
Expand Down
22 changes: 22 additions & 0 deletions packages/eas-cli/src/utils/expodash/__tests__/escapeRegExp-test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
6 changes: 6 additions & 0 deletions packages/eas-cli/src/utils/expodash/escapeRegExp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** `lodash.escapeRegExp` */
const REGEXP_SPECIAL_CHARACTERS = /[\\^$.*+?()[\]{}|]/g;

export default function escapeRegExp(value: string): string {
return value.replace(REGEXP_SPECIAL_CHARACTERS, '\\$&');
}
Loading