sussudio
Version:
An unofficial VS Code Internal API
334 lines (333 loc) • 13.8 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 "../../../base/common/objects.mjs";
import { isObject } from "../../../base/common/types.mjs";
import { Event } from "../../../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