Skip to content

Commit 4ee967d

Browse files
authored
fix(runtimes): add PII filtering for process crash error reporting
1 parent c4c2bc4 commit 4ee967d

File tree

4 files changed

+182
-3
lines changed

4 files changed

+182
-3
lines changed

runtimes/runtimes/standalone.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import { getClientInitializeParamsHandlerFactory } from './util/lspCacheUtil'
8383
import { makeProxyConfigv2Standalone, makeProxyConfigv3Standalone } from './util/standalone/proxyUtil'
8484
import { newAgent } from './agent'
8585
import { ShowSaveFileDialogRequestType } from '../protocol/window'
86+
import { getTelemetryReasonDesc } from './util/shared'
8687

8788
// Honor shared aws config file
8889
if (checkAWSConfigFile()) {
@@ -110,8 +111,10 @@ function setupCrashMonitoring(telemetryEmitter?: (metric: MetricEvent) => void)
110111
name: 'runtime_processCrash',
111112
result: 'Failed',
112113
errorData: {
113-
reason: err.toString(),
114-
errorCode: origin,
114+
reason: origin,
115+
},
116+
data: {
117+
reasonDesc: getTelemetryReasonDesc(err),
115118
},
116119
})
117120
} catch (telemetryError) {

runtimes/runtimes/util/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { getTelemetryReasonDesc } from './shared'

runtimes/runtimes/util/shared.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* Names of directories relevant to the crash reporting functionality.
3+
*
4+
* Moved here to resolve circular dependency issues.
5+
*/
6+
export const crashMonitoringDirName = 'crashMonitoring'
7+
8+
/** Matches Windows drive letter ("C:"). */
9+
export const driveLetterRegex = /^[a-zA-Z]\:/
10+
11+
/**
12+
* Returns the identifier the given error.
13+
* Depending on the implementation, the identifier may exist on a
14+
* different property.
15+
*/
16+
export function getErrorId(error: Error): string {
17+
// prioritize code over the name
18+
return hasCode(error) ? error.code : error.name
19+
}
20+
21+
/**
22+
* Derives an error message from the given error object.
23+
* Depending on the Error, the property used to derive the message can vary.
24+
*
25+
* @param withCause Append the message(s) from the cause chain, recursively.
26+
* The message(s) are delimited by ' | '. Eg: msg1 | causeMsg1 | causeMsg2
27+
*/
28+
export function getErrorMsg(err: Error | undefined, withCause: boolean = false): string | undefined {
29+
if (err === undefined) {
30+
return undefined
31+
}
32+
33+
// Non-standard SDK fields added by the OIDC service, to conform to the OAuth spec
34+
// (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1) :
35+
// - error: code per the OAuth spec
36+
// - error_description: improved error message provided by OIDC service. Prefer this to
37+
// `message` if present.
38+
// https://github.com/aws/aws-toolkit-jetbrains/commit/cc9ed87fa9391dd39ac05cbf99b4437112fa3d10
39+
// - error_uri: not provided by OIDC currently?
40+
//
41+
// Example:
42+
//
43+
// [error] API response (oidc.us-east-1.amazonaws.com /token): {
44+
// name: 'InvalidGrantException',
45+
// '$fault': 'client',
46+
// '$metadata': {
47+
// httpStatusCode: 400,
48+
// requestId: '7f5af448-5af7-45f2-8e47-5808deaea4ab',
49+
// extendedRequestId: undefined,
50+
// cfId: undefined
51+
// },
52+
// error: 'invalid_grant',
53+
// error_description: 'Invalid refresh token provided',
54+
// message: 'UnknownError'
55+
// }
56+
const anyDesc = (err as any).error_description
57+
const errDesc = typeof anyDesc === 'string' ? anyDesc.trim() : ''
58+
let msg = errDesc !== '' ? errDesc : err.message?.trim()
59+
60+
if (typeof msg !== 'string') {
61+
return undefined
62+
}
63+
64+
// append the cause's message
65+
if (withCause) {
66+
const errorId = getErrorId(err)
67+
// - prepend id to message
68+
// - If a generic error does not have the `name` field explicitly set, it returns a generic 'Error' name. So skip since it is useless.
69+
if (errorId && errorId !== 'Error') {
70+
msg = `${errorId}: ${msg}`
71+
}
72+
73+
const cause = (err as any).cause
74+
return `${msg}${cause ? ' | ' + getErrorMsg(cause, withCause) : ''}`
75+
}
76+
77+
return msg
78+
}
79+
80+
/**
81+
* Removes potential PII from a string, for logging/telemetry.
82+
*
83+
* Examples:
84+
* - "Failed to save c:/fooß/bar/baz.txt" => "Failed to save c:/xß/x/x.txt"
85+
* - "EPERM for dir c:/Users/user1/.aws/sso/cache/abc123.json" => "EPERM for dir c:/Users/x/.aws/sso/cache/x.json"
86+
*/
87+
function scrubNames(s: string, username?: string) {
88+
let r = ''
89+
const fileExtRe = /\.[^.\/]+$/
90+
const slashdot = /^[~.]*[\/\\]*/
91+
92+
/** Allowlisted filepath segments. */
93+
const keep = new Set<string>([
94+
'~',
95+
'.',
96+
'..',
97+
'.aws',
98+
'aws',
99+
'sso',
100+
'cache',
101+
'credentials',
102+
'config',
103+
'Users',
104+
'users',
105+
'home',
106+
'tmp',
107+
'aws-toolkit-vscode',
108+
'globalStorage', // from vscode globalStorageUri
109+
crashMonitoringDirName,
110+
])
111+
112+
if (username && username.length > 2) {
113+
s = s.replaceAll(username, 'x')
114+
}
115+
116+
// Replace contiguous whitespace with 1 space.
117+
s = s.replace(/\s+/g, ' ')
118+
119+
// 1. split on whitespace.
120+
// 2. scrub words that match username or look like filepaths.
121+
const words = s.split(/\s+/)
122+
for (const word of words) {
123+
const pathSegments = word.split(/[\/\\]/)
124+
if (pathSegments.length < 2) {
125+
// Not a filepath.
126+
r += ' ' + word
127+
continue
128+
}
129+
130+
// Replace all (non-allowlisted) ASCII filepath segments with "x".
131+
// "/foo/bar/aws/sso/" => "/x/x/aws/sso/"
132+
let scrubbed = ''
133+
// Get the frontmatter ("/", "../", "~/", or "./").
134+
const start = word.trimStart().match(slashdot)?.[0] ?? ''
135+
pathSegments[0] = pathSegments[0].trimStart().replace(slashdot, '')
136+
for (const seg of pathSegments) {
137+
if (driveLetterRegex.test(seg)) {
138+
scrubbed += seg
139+
} else if (keep.has(seg)) {
140+
scrubbed += '/' + seg
141+
} else {
142+
// Save the first non-ASCII (unicode) char, if any.
143+
const nonAscii = seg.match(/[^\p{ASCII}]/u)?.[0] ?? ''
144+
// Replace all chars (except [^…]) with "x" .
145+
const ascii = seg.replace(/[^$[\](){}:;'" ]+/g, 'x')
146+
scrubbed += `/${ascii}${nonAscii}`
147+
}
148+
}
149+
150+
// includes leading '.', eg: '.json'
151+
const fileExt = pathSegments[pathSegments.length - 1].match(fileExtRe) ?? ''
152+
r += ` ${start.replace(/\\/g, '/')}${scrubbed.replace(/^[\/\\]+/, '')}${fileExt}`
153+
}
154+
155+
return r.trim()
156+
}
157+
158+
// Port of implementation in AWS Toolkit for VSCode
159+
// https://github.com/aws/aws-toolkit-vscode/blob/c22efa03e73b241564c8051c35761eb8620edb83/packages/core/src/shared/errors.ts#L455
160+
/**
161+
* Gets the (partial) error message detail for the `reasonDesc` field.
162+
*
163+
* @param err Error object, or message text
164+
*/
165+
export function getTelemetryReasonDesc(err: unknown | undefined): string | undefined {
166+
const m = typeof err === 'string' ? err : (getErrorMsg(err as Error, true) ?? '')
167+
const msg = scrubNames(m)
168+
169+
// Truncate message as these strings can be very long.
170+
return msg && msg.length > 0 ? msg.substring(0, 350) : undefined
171+
}
172+
173+
function hasCode<T>(error: T): error is T & { code: string } {
174+
return typeof (error as { code?: unknown }).code === 'string'
175+
}

tsconfig.packages.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"compilerOptions": {
33
"module": "CommonJS",
4-
"target": "ES2016",
4+
"target": "ES2021",
55
"declaration": true,
66
"sourceMap": true,
77
"skipLibCheck": true,

0 commit comments

Comments
 (0)