Skip to content

Commit b5f785f

Browse files
authored
anonymize account id and error details for telemetry (#2519)
* anonymize account id and error details for telemetry * PR feedback and fix lint * expand anonymizePaths to serializable error details
1 parent f498280 commit b5f785f

File tree

6 files changed

+194
-17
lines changed

6 files changed

+194
-17
lines changed

.changeset/good-falcons-attend.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@aws-amplify/platform-core': patch
3+
---
4+
5+
anonymize account id and file paths/stacks in error details for telemetry

.eslint_dictionary.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"amazoncognito",
77
"amplifyconfiguration",
88
"ampx",
9+
"anonymize",
910
"anthropic",
1011
"apns",
1112
"apollo",

packages/platform-core/src/usage-data/account_id_fetcher.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { AccountIdFetcher } from './account_id_fetcher';
22
import { GetCallerIdentityCommandOutput, STSClient } from '@aws-sdk/client-sts';
33
import { describe, mock, test } from 'node:test';
44
import assert from 'node:assert';
5+
import { validate } from 'uuid';
56

67
void describe('AccountIdFetcher', async () => {
7-
void test('fetches account ID successfully', async () => {
8+
void test('fetches a valid account UUID successfully', async () => {
89
const mockSend = mock.method(STSClient.prototype, 'send', () =>
910
Promise.resolve({
1011
Account: '123456789012',
@@ -14,11 +15,11 @@ void describe('AccountIdFetcher', async () => {
1415
const accountIdFetcher = new AccountIdFetcher(new STSClient({}));
1516
const accountId = await accountIdFetcher.fetch();
1617

17-
assert.strictEqual(accountId, '123456789012');
18+
assert.ok(validate(accountId), `${accountId} is not a valid UUID string`);
1819
mockSend.mock.resetCalls();
1920
});
2021

21-
void test('returns default account ID when STS fails', async () => {
22+
void test('returns no account ID when STS fails', async () => {
2223
const mockSend = mock.method(STSClient.prototype, 'send', () =>
2324
Promise.reject(new Error('STS error'))
2425
);
@@ -30,7 +31,7 @@ void describe('AccountIdFetcher', async () => {
3031
mockSend.mock.resetCalls();
3132
});
3233

33-
void test('returns cached account ID on subsequent calls', async () => {
34+
void test('returns cached account UUID on subsequent calls', async () => {
3435
const mockSend = mock.method(STSClient.prototype, 'send', () =>
3536
Promise.resolve({
3637
Account: '123456789012',
@@ -41,8 +42,7 @@ void describe('AccountIdFetcher', async () => {
4142
const accountId1 = await accountIdFetcher.fetch();
4243
const accountId2 = await accountIdFetcher.fetch();
4344

44-
assert.strictEqual(accountId1, '123456789012');
45-
assert.strictEqual(accountId2, '123456789012');
45+
assert.strictEqual(accountId1, accountId2);
4646

4747
// we only call the service once.
4848
assert.strictEqual(mockSend.mock.callCount(), 1);

packages/platform-core/src/usage-data/account_id_fetcher.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts';
2+
import { v5 as uuidV5 } from 'uuid';
23

34
const NO_ACCOUNT_ID = 'NO_ACCOUNT_ID';
5+
6+
// eslint-disable-next-line spellcheck/spell-checker
7+
const AMPLIFY_CLI_UUID_NAMESPACE = '283cae3e-c611-4659-9044-6796e5d696ec'; // A random v4 UUID
8+
49
/**
510
* Retrieves the account ID of the user
611
*/
@@ -19,7 +24,11 @@ export class AccountIdFetcher {
1924
new GetCallerIdentityCommand({})
2025
);
2126
if (stsResponse && stsResponse.Account) {
22-
this.accountId = stsResponse.Account;
27+
const accountIdBucket = Number(stsResponse.Account) / 100;
28+
this.accountId = uuidV5(
29+
accountIdBucket.toString(),
30+
AMPLIFY_CLI_UUID_NAMESPACE
31+
);
2332
return this.accountId;
2433
}
2534
// We failed to get the account Id. Most likely the user doesn't have credentials

packages/platform-core/src/usage-data/serializable_error.test.ts

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, test } from 'node:test';
22
import assert from 'node:assert';
33
import os from 'os';
44
import { SerializableError } from './serializable_error';
5+
import { pathToFileURL } from 'node:url';
56

67
void describe('serializable error', () => {
78
class ErrorWithDetailsAndCode extends Error {
@@ -26,6 +27,21 @@ void describe('serializable error', () => {
2627
});
2728
});
2829

30+
void test('that regular stack trace does not contain user homedir for file url paths', () => {
31+
const error = new Error('test error');
32+
error.stack = `at methodName (${pathToFileURL(
33+
process.cwd()
34+
).toString()}/node_modules/@aws-amplify/test-package/lib/test.js:12:34)\n`;
35+
const serializableError = new SerializableError(error);
36+
assert.ok(serializableError.trace);
37+
serializableError.trace?.forEach((trace) => {
38+
assert.ok(
39+
trace.file.includes(os.homedir()) == false,
40+
`${os.homedir()} is included in the ${trace.file}`
41+
);
42+
});
43+
});
44+
2945
void test('that if code is available it is used as the error name', () => {
3046
const error = new ErrorWithDetailsAndCode(
3147
'some error message',
@@ -42,15 +58,15 @@ void describe('serializable error', () => {
4258
assert.deepStrictEqual(serializableError.name, 'Error');
4359
});
4460

45-
void test('that no change in error details that does not have AWS ARNs', () => {
61+
void test('that no change in error details that does not have AWS ARNs or stacks', () => {
4662
const error = new ErrorWithDetailsAndCode(
4763
'some error message',
48-
'some error details that do not have ARNs'
64+
'some error details that do not have ARNs or stacks'
4965
);
5066
const serializableError = new SerializableError(error);
5167
assert.deepStrictEqual(
5268
serializableError.details,
53-
'some error details that do not have ARNs'
69+
'some error details that do not have ARNs or stacks'
5470
);
5571
});
5672

@@ -66,9 +82,112 @@ void describe('serializable error', () => {
6682
);
6783
});
6884

85+
void test('that stacks are escaped when error details has two AWS stacks', () => {
86+
const error = new ErrorWithDetailsAndCode(
87+
'some error message',
88+
// eslint-disable-next-line spellcheck/spell-checker
89+
'some error details with stack: amplify-testapp-test-sandbox-1234abcd and stack: amplify-testapp-test-branch-1234abcd and something else'
90+
);
91+
const serializableError = new SerializableError(error);
92+
assert.deepStrictEqual(
93+
serializableError.details,
94+
'some error details with stack: <escaped stack> and stack: <escaped stack> and something else'
95+
);
96+
});
97+
6998
void test('that error message is sanitized by removing invalid characters', () => {
7099
const error = new ErrorWithDetailsAndCode('some" er❌ror ""m"es❌sage❌');
71100
const serializableError = new SerializableError(error);
72101
assert.deepStrictEqual(serializableError.message, 'some error message');
73102
});
103+
104+
void test('that error message does not contain AWS ARNs or stacks', () => {
105+
const error = new ErrorWithDetailsAndCode(
106+
// eslint-disable-next-line spellcheck/spell-checker
107+
'test error with stack: amplify-testapp-test-branch-1234abcd and arn: arn:aws-iso:service:region::res'
108+
);
109+
const serializableError = new SerializableError(error);
110+
assert.deepStrictEqual(
111+
serializableError.message,
112+
'test error with stack: <escaped stack> and arn: <escaped ARN>'
113+
);
114+
});
115+
116+
void test('that error message does not contain user homedir', () => {
117+
const error = new ErrorWithDetailsAndCode(`${process.cwd()} test error`);
118+
const serializableError = new SerializableError(error);
119+
const matches = [
120+
...serializableError.message.matchAll(new RegExp(os.homedir(), 'g')),
121+
];
122+
assert.ok(
123+
matches.length === 0,
124+
`${os.homedir()} is included in ${serializableError.message}`
125+
);
126+
});
127+
128+
void test('that error message does not contain file url path with user homedir', () => {
129+
const error = new ErrorWithDetailsAndCode(
130+
`${pathToFileURL(process.cwd()).toString()} test error`
131+
);
132+
const serializableError = new SerializableError(error);
133+
const matches = [
134+
...serializableError.message.matchAll(new RegExp(os.homedir(), 'g')),
135+
];
136+
assert.ok(
137+
matches.length === 0,
138+
`${os.homedir()} is included in ${serializableError.message}`
139+
);
140+
});
141+
142+
void test('that error details do not contain user homedir', () => {
143+
const error = new ErrorWithDetailsAndCode(
144+
'test error',
145+
`${process.cwd()} test details`
146+
);
147+
const serializableError = new SerializableError(error);
148+
const matches = serializableError.details
149+
? [...serializableError.details.matchAll(new RegExp(os.homedir(), 'g'))]
150+
: [];
151+
assert.ok(
152+
serializableError.details && matches.length === 0,
153+
`${os.homedir()} is included in ${serializableError.details}`
154+
);
155+
});
156+
157+
void test('that error details do not contain file url path with user homedir', () => {
158+
const error = new ErrorWithDetailsAndCode(
159+
'test error',
160+
`${pathToFileURL(process.cwd()).toString()} test details`
161+
);
162+
const serializableError = new SerializableError(error);
163+
const matches = serializableError.details
164+
? [...serializableError.details.matchAll(new RegExp(os.homedir(), 'g'))]
165+
: [];
166+
assert.ok(
167+
serializableError.details && matches.length === 0,
168+
`${os.homedir()} is included in ${serializableError.details}`
169+
);
170+
});
171+
172+
void test('that error details do not contain AWS ARNs or stacks', () => {
173+
const error = new ErrorWithDetailsAndCode(
174+
'test error',
175+
// eslint-disable-next-line spellcheck/spell-checker
176+
'test error with stack: amplify-testapp-test-branch-1234abcd and arn: arn:aws-iso:service:region::res'
177+
);
178+
const serializableError = new SerializableError(error);
179+
assert.deepStrictEqual(
180+
serializableError.details,
181+
'test error with stack: <escaped stack> and arn: <escaped ARN>'
182+
);
183+
});
184+
185+
void test('that error details is sanitized by removing invalid characters', () => {
186+
const error = new ErrorWithDetailsAndCode(
187+
'test error',
188+
'some" er❌ror ""m"es❌sage❌'
189+
);
190+
const serializableError = new SerializableError(error);
191+
assert.deepStrictEqual(serializableError.details, 'some error message');
192+
});
74193
});

packages/platform-core/src/usage-data/serializable_error.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import path from 'path';
2+
import { fileURLToPath } from 'url';
3+
import { homedir } from 'os';
24

35
/**
46
* Wrapper around Error for serialization for usage metrics
@@ -9,11 +11,19 @@ export class SerializableError {
911
details?: string;
1012
trace?: Trace[];
1113

14+
// breakdown of filePathRegex:
15+
// (file:/+)? -> matches optional file url prefix
16+
// homedir() -> users home directory, replacing \ with /
17+
// [\\w.\\-_@\\\\/]+ -> matches nested directories and file name
18+
private filePathRegex = new RegExp(
19+
`(file:/+)?${homedir().replaceAll('\\', '/')}[\\w.\\-_@\\\\/]+`,
20+
'g'
21+
);
1222
private stackTraceRegex =
1323
/^\s*at (?:((?:\[object object\])?[^\\/]+(?: \[as \S+\])?) )?\(?(.*?):(\d+)(?::(\d+))?\)?\s*$/i;
1424
private arnRegex =
1525
/arn:[a-z0-9][-.a-z0-9]{0,62}:[A-Za-z0-9][A-Za-z0-9_/.-]{0,62}:[A-Za-z0-9_/.-]{0,63}:[A-Za-z0-9_/.-]{0,63}:[A-Za-z0-9][A-Za-z0-9:_/+=,@.-]{0,1023}/g;
16-
26+
private stackRegex = /amplify-[a-zA-Z0-9-]+/g;
1727
/**
1828
* constructor for SerializableError
1929
*/
@@ -22,9 +32,11 @@ export class SerializableError {
2232
'code' in error && error.code
2333
? this.sanitize(error.code as string)
2434
: error.name;
25-
this.message = this.sanitize(error.message);
35+
this.message = this.anonymizePaths(this.sanitize(error.message));
2636
this.details =
27-
'details' in error ? this.sanitize(error.details as string) : undefined;
37+
'details' in error
38+
? this.anonymizePaths(this.sanitize(error.details as string))
39+
: undefined;
2840
this.trace = this.extractStackTrace(error);
2941
}
3042

@@ -54,21 +66,52 @@ export class SerializableError {
5466
return result;
5567
};
5668

69+
private anonymizePaths = (str: string): string => {
70+
let result = str;
71+
const matches = [...result.matchAll(this.filePathRegex)];
72+
for (const match of matches) {
73+
result = result.replace(match[0], this.processPaths([match[0]])[0]);
74+
}
75+
76+
return result;
77+
};
78+
5779
private processPaths = (paths: string[]): string[] => {
5880
return paths.map((tracePath) => {
59-
if (path.isAbsolute(tracePath)) {
60-
return path.relative(process.cwd(), tracePath);
81+
let result = tracePath;
82+
if (this.isURLFilePath(result)) {
83+
result = fileURLToPath(result);
84+
}
85+
if (path.isAbsolute(result)) {
86+
return path.relative(process.cwd(), result);
6187
}
62-
return tracePath;
88+
89+
return result;
6390
});
6491
};
6592

6693
private removeARN = (str?: string): string => {
6794
return str?.replace(this.arnRegex, '<escaped ARN>') ?? '';
6895
};
6996

97+
private removeStackIdentifier = (str?: string): string => {
98+
return str?.replace(this.stackRegex, '<escaped stack>') ?? '';
99+
};
100+
70101
private sanitize = (str: string) => {
71-
return this.removeARN(str)?.replaceAll(/["]/g, '');
102+
let result = str;
103+
result = this.removeARN(result);
104+
result = this.removeStackIdentifier(result);
105+
return result.replaceAll(/["]/g, '');
106+
};
107+
108+
private isURLFilePath = (path: string): boolean => {
109+
try {
110+
new URL(path);
111+
return path.startsWith('file:');
112+
} catch {
113+
return false;
114+
}
72115
};
73116
}
74117

0 commit comments

Comments
 (0)