|
| 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 | +} |
0 commit comments