@sussudio/platform
Version:
Internal APIs for VS Code's service injection the base services.
344 lines (343 loc) • 12.6 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { cloneAndChange, safeStringify } from '@sussudio/base/common/objects.mjs';
import { isObject } from '@sussudio/base/common/types.mjs';
import { Event } from '@sussudio/base/common/event.mjs';
import { ConfigurationTargetToString } from '../../configuration/common/configuration.mjs';
import { getRemoteName } from '../../remote/common/remoteHosts.mjs';
import { verifyMicrosoftInternalDomain } from './commonProperties.mjs';
import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from './telemetry.mjs';
/**
* A special class used to denoting a telemetry value which should not be clean.
* This is because that value is "Trusted" not to contain identifiable information such as paths.
* NOTE: This is used as an API type as well, and should not be changed.
*/
export class TrustedTelemetryValue {
value;
constructor(value) {
this.value = value;
}
}
export class NullTelemetryServiceShape {
sendErrorTelemetry = false;
publicLog(eventName, data) {
return Promise.resolve(undefined);
}
publicLog2(eventName, data) {
return this.publicLog(eventName, data);
}
publicLogError(eventName, data) {
return Promise.resolve(undefined);
}
publicLogError2(eventName, data) {
return this.publicLogError(eventName, data);
}
setExperimentProperty() {}
telemetryLevel = 0 /* TelemetryLevel.NONE */;
getTelemetryInfo() {
return Promise.resolve({
instanceId: 'someValue.instanceId',
sessionId: 'someValue.sessionId',
machineId: 'someValue.machineId',
firstSessionDate: 'someValue.firstSessionDate',
});
}
}
export const NullTelemetryService = new NullTelemetryServiceShape();
export class NullEndpointTelemetryService {
_serviceBrand;
async publicLog(_endpoint, _eventName, _data) {
// noop
}
async publicLogError(_endpoint, _errorEventName, _data) {
// noop
}
}
export const NullAppender = { log: () => null, flush: () => Promise.resolve(null) };
export function configurationTelemetry(telemetryService, configurationService) {
// Debounce the event by 1000 ms and merge all affected keys into one event
const debouncedConfigService = Event.debounce(
configurationService.onDidChangeConfiguration,
(last, cur) => {
const newAffectedKeys = last ? new Set([...last.affectedKeys, ...cur.affectedKeys]) : cur.affectedKeys;
return { ...cur, affectedKeys: newAffectedKeys };
},
1000,
true,
);
return debouncedConfigService((event) => {
if (event.source !== 7 /* ConfigurationTarget.DEFAULT */) {
telemetryService.publicLog2('updateConfiguration', {
configurationSource: ConfigurationTargetToString(event.source),
configurationKeys: flattenKeys(event.affectedKeys),
});
}
});
}
/**
* Determines whether or not we support logging telemetry.
* This checks if the product is capable of collecting telemetry but not whether or not it can send it
* For checking the user setting and what telemetry you can send please check `getTelemetryLevel`.
* This returns true if `--disable-telemetry` wasn't used, the product.json allows for telemetry, and we're not testing an extension
* If false telemetry is disabled throughout the product
* @param productService
* @param environmentService
* @returns false - telemetry is completely disabled, true - telemetry is logged locally, but may not be sent
*/
export function supportsTelemetry(productService, environmentService) {
// If it's OSS and telemetry isn't disabled via the CLI we will allow it for logging only purposes
if (!environmentService.isBuilt && !environmentService.disableTelemetry) {
return true;
}
return !(
environmentService.disableTelemetry ||
!productService.enableTelemetry ||
environmentService.extensionTestsLocationURI
);
}
/**
* Checks to see if we're in logging only mode to debug telemetry.
* This is if telemetry is enabled and we're in OSS, but no telemetry key is provided so it's not being sent just logged.
* @param productService
* @param environmentService
* @returns True if telemetry is actually disabled and we're only logging for debug purposes
*/
export function isLoggingOnly(productService, environmentService) {
// Logging only mode is only for OSS
if (environmentService.isBuilt) {
return false;
}
if (environmentService.disableTelemetry) {
return false;
}
if (productService.enableTelemetry && productService.aiConfig?.ariaKey) {
return false;
}
return true;
}
/**
* Determines how telemetry is handled based on the user's configuration.
*
* @param configurationService
* @returns OFF, ERROR, ON
*/
export function getTelemetryLevel(configurationService) {
const newConfig = configurationService.getValue(TELEMETRY_SETTING_ID);
const crashReporterConfig = configurationService.getValue(TELEMETRY_CRASH_REPORTER_SETTING_ID);
const oldConfig = configurationService.getValue(TELEMETRY_OLD_SETTING_ID);
// If `telemetry.enableCrashReporter` is false or `telemetry.enableTelemetry' is false, disable telemetry
if (oldConfig === false || crashReporterConfig === false) {
return 0 /* TelemetryLevel.NONE */;
}
// Maps new telemetry setting to a telemetry level
switch (newConfig ?? 'all' /* TelemetryConfiguration.ON */) {
case 'all' /* TelemetryConfiguration.ON */:
return 3 /* TelemetryLevel.USAGE */;
case 'error' /* TelemetryConfiguration.ERROR */:
return 2 /* TelemetryLevel.ERROR */;
case 'crash' /* TelemetryConfiguration.CRASH */:
return 1 /* TelemetryLevel.CRASH */;
case 'off' /* TelemetryConfiguration.OFF */:
return 0 /* TelemetryLevel.NONE */;
}
}
export function validateTelemetryData(data) {
const properties = {};
const measurements = {};
const flat = {};
flatten(data, flat);
for (let prop in flat) {
// enforce property names less than 150 char, take the last 150 char
prop = prop.length > 150 ? prop.substr(prop.length - 149) : prop;
const value = flat[prop];
if (typeof value === 'number') {
measurements[prop] = value;
} else if (typeof value === 'boolean') {
measurements[prop] = value ? 1 : 0;
} else if (typeof value === 'string') {
if (value.length > 8192) {
console.warn(`Telemetry property: ${prop} has been trimmed to 8192, the original length is ${value.length}`);
}
//enforce property value to be less than 8192 char, take the first 8192 char
// https://docs.microsoft.com/en-us/azure/azure-monitor/app/api-custom-events-metrics#limits
properties[prop] = value.substring(0, 8191);
} else if (typeof value !== 'undefined' && value !== null) {
properties[prop] = value;
}
}
return {
properties,
measurements,
};
}
const telemetryAllowedAuthorities = new Set([
'ssh-remote',
'dev-container',
'attached-container',
'wsl',
'tunnel',
'codespaces',
]);
export function cleanRemoteAuthority(remoteAuthority) {
if (!remoteAuthority) {
return 'none';
}
const remoteName = getRemoteName(remoteAuthority);
return telemetryAllowedAuthorities.has(remoteName) ? remoteName : 'other';
}
function flatten(obj, result, order = 0, prefix) {
if (!obj) {
return;
}
for (const item of Object.getOwnPropertyNames(obj)) {
const value = obj[item];
const index = prefix ? prefix + item : item;
if (Array.isArray(value)) {
result[index] = safeStringify(value);
} else if (value instanceof Date) {
// TODO unsure why this is here and not in _getData
result[index] = value.toISOString();
} else if (isObject(value)) {
if (order < 2) {
flatten(value, result, order + 1, index + '.');
} else {
result[index] = safeStringify(value);
}
} else {
result[index] = value;
}
}
}
function flattenKeys(value) {
if (!value) {
return [];
}
const result = [];
flatKeys(result, '', value);
return result;
}
function flatKeys(result, prefix, value) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
Object.keys(value).forEach((key) => flatKeys(result, prefix ? `${prefix}.${key}` : key, value[key]));
} else {
result.push(prefix);
}
}
/**
* Whether or not this is an internal user
* @param productService The product service
* @param configService The config servivce
* @returns true if internal, false otherwise
*/
export function isInternalTelemetry(productService, configService) {
const msftInternalDomains = productService.msftInternalDomains || [];
const internalTesting = configService.getValue('telemetry.internalTesting');
return verifyMicrosoftInternalDomain(msftInternalDomains) || internalTesting;
}
export function getPiiPathsFromEnvironment(paths) {
return [paths.appRoot, paths.extensionsPath, paths.userHome.fsPath, paths.tmpDir.fsPath, paths.userDataPath];
}
//#region Telemetry Cleaning
/**
* Cleans a given stack of possible paths
* @param stack The stack to sanitize
* @param cleanupPatterns Cleanup patterns to remove from the stack
* @returns The cleaned stack
*/
function anonymizeFilePaths(stack, cleanupPatterns) {
// Fast check to see if it is a file path to avoid doing unnecessary heavy regex work
if (!stack || (!stack.includes('/') && !stack.includes('\\'))) {
return stack;
}
let updatedStack = stack;
const cleanUpIndexes = [];
for (const regexp of cleanupPatterns) {
while (true) {
const result = regexp.exec(stack);
if (!result) {
break;
}
cleanUpIndexes.push([result.index, regexp.lastIndex]);
}
}
const nodeModulesRegex = /^[\\\/]?(node_modules|node_modules\.asar)[\\\/]/;
const fileRegex = /(file:\/\/)?([a-zA-Z]:(\\\\|\\|\/)|(\\\\|\\|\/))?([\w-\._]+(\\\\|\\|\/))+[\w-\._]*/g;
let lastIndex = 0;
updatedStack = '';
while (true) {
const result = fileRegex.exec(stack);
if (!result) {
break;
}
// Check to see if the any cleanupIndexes partially overlap with this match
const overlappingRange = cleanUpIndexes.some(([start, end]) => result.index < end && start < fileRegex.lastIndex);
// anoynimize user file paths that do not need to be retained or cleaned up.
if (!nodeModulesRegex.test(result[0]) && !overlappingRange) {
updatedStack += stack.substring(lastIndex, result.index) + '<REDACTED: user-file-path>';
lastIndex = fileRegex.lastIndex;
}
}
if (lastIndex < stack.length) {
updatedStack += stack.substr(lastIndex);
}
return updatedStack;
}
/**
* Attempts to remove commonly leaked PII
* @param property The property which will be removed if it contains user data
* @returns The new value for the property
*/
function removePropertiesWithPossibleUserInfo(property) {
// If for some reason it is undefined we skip it (this shouldn't be possible);
if (!property) {
return property;
}
const value = property.toLowerCase();
const userDataRegexes = [
{ label: 'Google API Key', regex: /AIza[A-Za-z0-9_\\\-]{35}/ },
{ label: 'Slack Token', regex: /xox[pbar]\-[A-Za-z0-9]/ },
{
label: 'Generic Secret',
regex: /(key|token|sig|secret|signature|password|passwd|pwd|android:value)[^a-zA-Z0-9]/,
},
{ label: 'Email', regex: /@[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+/ }, // Regex which matches @*.site
];
// Check for common user data in the telemetry events
for (const secretRegex of userDataRegexes) {
if (secretRegex.regex.test(value)) {
return `<REDACTED: ${secretRegex.label}>`;
}
}
return property;
}
/**
* Does a best possible effort to clean a data object from any possible PII.
* @param data The data object to clean
* @param paths Any additional patterns that should be removed from the data set
* @returns A new object with the PII removed
*/
export function cleanData(data, cleanUpPatterns) {
return cloneAndChange(data, (value) => {
// If it's a trusted value it means it's okay to skip cleaning so we don't clean it
if (value instanceof TrustedTelemetryValue) {
return value.value;
}
// We only know how to clean strings
if (typeof value === 'string') {
let updatedProperty = value.replaceAll('%20', ' ');
// First we anonymize any possible file paths
updatedProperty = anonymizeFilePaths(updatedProperty, cleanUpPatterns);
// Then we do a simple regex replace with the defined patterns
for (const regexp of cleanUpPatterns) {
updatedProperty = updatedProperty.replace(regexp, '');
}
// Lastly, remove commonly leaked PII
updatedProperty = removePropertiesWithPossibleUserInfo(updatedProperty);
return updatedProperty;
}
return undefined;
});
}
//#endregion