forked from mirrors/gecko-dev
We can't add the provenance data to the `installation.first_seen` extra data because it is already at its maximum number of keys. So instead we will add the `installation.first_seen_prov_ext` event which will be sent at the same time as `installation.first_seen` and will contain provenance attribution data in its extras object. Differential Revision: https://phabricator.services.mozilla.com/D172520
544 lines
21 KiB
JavaScript
544 lines
21 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
|
|
|
|
const lazy = {};
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
AttributionIOUtils: "resource:///modules/AttributionCode.sys.mjs",
|
|
});
|
|
|
|
let gReadZoneIdPromise = null;
|
|
let gTelemetryPromise = null;
|
|
|
|
export var ProvenanceData = {
|
|
/**
|
|
* Clears cached code/Promises. For testing only.
|
|
*/
|
|
_clearCache() {
|
|
if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
|
|
gReadZoneIdPromise = null;
|
|
gTelemetryPromise = null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Returns an nsIFile for the file containing the zone identifier-based
|
|
* provenance data. This currently only exists on Windows. On other platforms,
|
|
* this will return null.
|
|
*/
|
|
get zoneIdProvenanceFile() {
|
|
if (AppConstants.platform == "win") {
|
|
let file = Services.dirsvc.get("GreD", Ci.nsIFile);
|
|
file.append("zoneIdProvenanceData");
|
|
return file;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Loads the provenance data that the installer copied (along with some
|
|
* metadata) from the Firefox installer used to create the current
|
|
* installation.
|
|
*
|
|
* If it doesn't already exist, creates a global Promise that loads the data
|
|
* from the file and caches it. Subsequent calls get the same Promise. There
|
|
* is no support for re-reading the file from the disk because there is no
|
|
* good reason that the contents of the file should change.
|
|
*
|
|
* Only expected contents will be pulled out of the file. This does not
|
|
* extract arbitrary, unexpected data. Data will be validated, to the extent
|
|
* possible. Most possible data returned has a potential key indicating that
|
|
* we read an unexpected value out of the file.
|
|
*
|
|
* @returns `null` on unsupported OSs. Otherwise, an object with these
|
|
* possible keys:
|
|
* `readProvenanceError`
|
|
* Will be present if there was a problem when Firefox tried to read the
|
|
* file that ought to have been written by the installer. Possible values
|
|
* are:
|
|
* `noSuchFile`, `readError`, `parseError`
|
|
* If this key is present, no other keys will be present.
|
|
* `fileSystem`
|
|
* What filesystem the installer was on. If available, this will be a
|
|
* string returned from `GetVolumeInformationByHandleW` via its
|
|
* `lpFileSystemNameBuffer` parameter. Possible values are:
|
|
* `NTFS`, `FAT32`, `other`, `missing`, `readIniError`
|
|
* `other` will be used if the value read does not match one of the
|
|
* expected file systems.
|
|
* `missing` will be used if the file didn't contain `fileSystem` or
|
|
* `readFsError` information.
|
|
* `readIniError` will be used if an error is encountered when reading
|
|
* the key from the file in the installation directory.
|
|
* `readFsError`
|
|
* The reason why the installer file system could not be determined. Will
|
|
* be present if `readProvenanceError` and `fileSystem` are not. Possible
|
|
* values are:
|
|
* `openFile`, `getVolInfo`, `fsUnterminated`, `getBufferSize`,
|
|
* `convertString`, `unexpected`, `readIniError`
|
|
* `unexpected` will be used if the value read from the file didn't match
|
|
* any of the expected values, for some reason.
|
|
* `missing` will be used if the file didn't contain `fileSystem` or
|
|
* `readFsError` information.
|
|
* `readIniError` will be used if an error is encountered when reading
|
|
* the key from the file in the installation directory.
|
|
* `readFsErrorCode`
|
|
* An integer returned by `GetLastError()` indicating, in more detail,
|
|
* why we failed to obtain the file system. This key may exist if
|
|
* `readFsError` exists.
|
|
* `readZoneIdError`
|
|
* The reason why the installer was unable to read its zone identifier
|
|
* ADS. Possible values are:
|
|
* `openFile`, `readFile`, `unexpected`, `readIniError`
|
|
* `unexpected` will be used if the value read from the file didn't match
|
|
* any of the expected values, for some reason.
|
|
* `readIniError` will be used if an error is encountered when reading
|
|
* the key from the file in the installation directory.
|
|
* `readZoneIdErrorCode`
|
|
* An integer returned by `GetLastError()` indicating, in more detail,
|
|
* why we failed to read the zone identifier ADS. This key may exist if
|
|
* `readZoneIdError` exists.
|
|
* `zoneIdFileSize`
|
|
* This key should exist if Firefox successfully read the file in the
|
|
* installation directory and the installer successfully opened the ADS.
|
|
* If the installer failed to get the size of the ADS prior to reading
|
|
* it, this will be `unknown`. If the installer was able to get the ADS
|
|
* size, this will be an integer describing how many bytes long it was.
|
|
* If this value in installation directory's file isn't `unknown` or an
|
|
* integer, this will be `unexpected`. If an error is encountered when
|
|
* reading the key from the file in the installation directory, this will
|
|
* be `readIniError`.
|
|
* `zoneIdBufferLargeEnough`
|
|
* This key should exist if Firefox successfully read the file in the
|
|
* installation directory and the installer successfully opened the ADS.
|
|
* Indicates whether the zone identifier ADS size was bigger than the
|
|
* maximum size that the installer will read from it. If we failed to
|
|
* determine the ADS size, this will be `unknown`. If the installation
|
|
* directory's file contains an invalid value, this will be `unexpected`.
|
|
* If an error is encountered when reading the key from the file in the
|
|
* installation directory, this will be `readIniError`.
|
|
* Otherwise, this will be a boolean indicating whether or not the buffer
|
|
* was large enough to fit the ADS data into.
|
|
* `zoneIdTruncated`
|
|
* This key should exist if Firefox successfully read the file in the
|
|
* installation directory and the installer successfully read the ADS.
|
|
* Indicates whether or not we read through the end of the ADS data when
|
|
* we copied it. If the installer failed to determine this, this value
|
|
* will be `unknown`. If the installation directory's file contains an
|
|
* invalid value, this will be `unexpected`. If an error is encountered
|
|
* when reading the key from the file in the installation directory, this
|
|
* will be `readIniError`. Otherwise, this will be a boolean value
|
|
* indicating whether or not the data that we copied was truncated.
|
|
* `zoneId`
|
|
* The Security Zone that the Zone Identifier data indicates that
|
|
* installer was downloaded from. See this documentation:
|
|
* https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/ms537183(v=vs.85)
|
|
* This key will be present if `readProvenanceError` and
|
|
* `readZoneIdError` are not. It will either be a valid zone ID (an
|
|
* integer between 0 and 4, inclusive), or else it will be `unexpected`,
|
|
* `missing`, or `readIniError`.
|
|
* `referrerUrl`
|
|
* The URL of the download referrer. This key will be present if
|
|
* `readProvenanceError` and `readZoneIdError` are not. It will either be
|
|
* a `URL` object, or else it will be `unexpected`, `missing`, or
|
|
* `readIniError`.
|
|
* `referrerUrlIsMozilla`
|
|
* This key will be present if `ReferrerUrl` is a `URL` object. It will
|
|
* be `true`` if the download referrer appears to be a Mozilla URL.
|
|
* Otherwise it will be `false`.
|
|
* `hostUrl`
|
|
* The URL of the download source. This key will be present if
|
|
* `readProvenanceError` and `readZoneIdError` are not. It will either be
|
|
* a `URL` object, or else it will be `unexpected`, `missing`, or
|
|
* `readIniError`
|
|
* `hostUrlIsMozilla`
|
|
* This key will be present if `HostUrl` is a `URL` object. It will
|
|
* be `true`` if the download source appears to be a Mozilla URL.
|
|
* Otherwise it will be `false`.
|
|
*/
|
|
async readZoneIdProvenanceFile() {
|
|
if (gReadZoneIdPromise) {
|
|
return gReadZoneIdPromise;
|
|
}
|
|
gReadZoneIdPromise = (async () => {
|
|
let file = this.zoneIdProvenanceFile;
|
|
if (!file) {
|
|
return null;
|
|
}
|
|
let iniData;
|
|
try {
|
|
iniData = await lazy.AttributionIOUtils.readUTF8(file.path);
|
|
} catch (ex) {
|
|
if (DOMException.isInstance(ex) && ex.name == "NotFoundError") {
|
|
return { readProvenanceError: "noSuchFile" };
|
|
}
|
|
return { readProvenanceError: "readError" };
|
|
}
|
|
|
|
let ini;
|
|
try {
|
|
// We would rather use asynchronous I/O, so we are going to read the
|
|
// file with IOUtils and then pass the result into the INI parser
|
|
// rather than just giving the INI parser factory the file.
|
|
ini = Cc["@mozilla.org/xpcom/ini-parser-factory;1"]
|
|
.getService(Ci.nsIINIParserFactory)
|
|
.createINIParser(null);
|
|
ini.initFromString(iniData);
|
|
} catch (ex) {
|
|
return { readProvenanceError: "parseError" };
|
|
}
|
|
|
|
const unexpectedValueError = "unexpected";
|
|
const missingKeyError = "missing";
|
|
const readIniError = "readIniError";
|
|
const possibleIniErrors = [missingKeyError, readIniError];
|
|
|
|
// Format is {"IniSectionName": {"IniKeyName": "IniValue"}}
|
|
let iniValues = {
|
|
Mozilla: {
|
|
fileSystem: null,
|
|
readFsError: null,
|
|
readFsErrorCode: null,
|
|
readZoneIdError: null,
|
|
readZoneIdErrorCode: null,
|
|
zoneIdFileSize: null,
|
|
zoneIdBufferLargeEnough: null,
|
|
zoneIdTruncated: null,
|
|
},
|
|
ZoneTransfer: {
|
|
ZoneId: null,
|
|
ReferrerUrl: null,
|
|
HostUrl: null,
|
|
},
|
|
};
|
|
|
|
// The ini reader interface is a little weird in that if we just try to
|
|
// read a value from a known section/key and the section/key doesn't
|
|
// exist, we just get a generic error rather than an indication that
|
|
// the section/key doesn't exist. To distinguish missing keys from any
|
|
// other potential errors, we are going to enumerate the sections and
|
|
// keys use that to determine if we should try to read from them.
|
|
let existingSections;
|
|
try {
|
|
existingSections = Array.from(ini.getSections());
|
|
} catch (ex) {
|
|
return { readProvenanceError: "parseError" };
|
|
}
|
|
for (const section in iniValues) {
|
|
if (!existingSections.includes(section)) {
|
|
for (const key in iniValues[section]) {
|
|
iniValues[section][key] = missingKeyError;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let existingKeys;
|
|
try {
|
|
existingKeys = Array.from(ini.getKeys(section));
|
|
} catch (ex) {
|
|
for (const key in iniValues[section]) {
|
|
iniValues[section][key] = readIniError;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
for (const key in iniValues[section]) {
|
|
if (!existingKeys.includes(key)) {
|
|
iniValues[section][key] = missingKeyError;
|
|
continue;
|
|
}
|
|
|
|
let value;
|
|
try {
|
|
value = ini.getString(section, key).trim();
|
|
} catch (ex) {
|
|
value = readIniError;
|
|
}
|
|
iniValues[section][key] = value;
|
|
}
|
|
}
|
|
|
|
// This helps with how verbose the validation gets.
|
|
const fileSystem = iniValues.Mozilla.fileSystem;
|
|
const readFsError = iniValues.Mozilla.readFsError;
|
|
const readFsErrorCode = iniValues.Mozilla.readFsErrorCode;
|
|
const readZoneIdError = iniValues.Mozilla.readZoneIdError;
|
|
const readZoneIdErrorCode = iniValues.Mozilla.readZoneIdErrorCode;
|
|
const zoneIdFileSize = iniValues.Mozilla.zoneIdFileSize;
|
|
const zoneIdBufferLargeEnough = iniValues.Mozilla.zoneIdBufferLargeEnough;
|
|
const zoneIdTruncated = iniValues.Mozilla.zoneIdTruncated;
|
|
const zoneId = iniValues.ZoneTransfer.ZoneId;
|
|
const referrerUrl = iniValues.ZoneTransfer.ReferrerUrl;
|
|
const hostUrl = iniValues.ZoneTransfer.HostUrl;
|
|
|
|
let returnObject = {};
|
|
|
|
// readFsError, readFsErrorCode, fileSystem
|
|
const validReadFsErrors = [
|
|
"openFile",
|
|
"getVolInfo",
|
|
"fsUnterminated",
|
|
"getBufferSize",
|
|
"convertString",
|
|
];
|
|
// These must be upper case
|
|
const validFileSystemValues = ["NTFS", "FAT32"];
|
|
if (fileSystem == missingKeyError && readFsError != missingKeyError) {
|
|
if (
|
|
possibleIniErrors.includes(readFsError) ||
|
|
validReadFsErrors.includes(readFsError)
|
|
) {
|
|
returnObject.readFsError = readFsError;
|
|
} else {
|
|
returnObject.readFsError = unexpectedValueError;
|
|
}
|
|
if (readFsErrorCode != missingKeyError) {
|
|
let code = parseInt(readFsErrorCode, 10);
|
|
if (!isNaN(code)) {
|
|
returnObject.readFsErrorCode = code;
|
|
}
|
|
}
|
|
} else if (possibleIniErrors.includes(fileSystem)) {
|
|
returnObject.fileSystem = fileSystem;
|
|
} else if (validFileSystemValues.includes(fileSystem.toUpperCase())) {
|
|
returnObject.fileSystem = fileSystem.toUpperCase();
|
|
} else {
|
|
returnObject.fileSystem = "other";
|
|
}
|
|
|
|
// zoneIdFileSize
|
|
if (zoneIdFileSize == missingKeyError) {
|
|
// We don't include this one if it's missing.
|
|
} else if (
|
|
zoneIdFileSize == readIniError ||
|
|
zoneIdFileSize == "unknown"
|
|
) {
|
|
returnObject.zoneIdFileSize = zoneIdFileSize;
|
|
} else {
|
|
let size = parseInt(zoneIdFileSize, 10);
|
|
if (isNaN(size)) {
|
|
returnObject.zoneIdFileSize = unexpectedValueError;
|
|
} else {
|
|
returnObject.zoneIdFileSize = size;
|
|
}
|
|
}
|
|
|
|
// zoneIdBufferLargeEnough
|
|
if (zoneIdBufferLargeEnough == missingKeyError) {
|
|
// We don't include this one if it's missing.
|
|
} else if (
|
|
zoneIdBufferLargeEnough == readIniError ||
|
|
zoneIdBufferLargeEnough == "unknown"
|
|
) {
|
|
returnObject.zoneIdBufferLargeEnough = zoneIdBufferLargeEnough;
|
|
} else if (zoneIdBufferLargeEnough.toLowerCase() == "true") {
|
|
returnObject.zoneIdBufferLargeEnough = true;
|
|
} else if (zoneIdBufferLargeEnough.toLowerCase() == "false") {
|
|
returnObject.zoneIdBufferLargeEnough = false;
|
|
} else {
|
|
returnObject.zoneIdBufferLargeEnough = unexpectedValueError;
|
|
}
|
|
|
|
// zoneIdTruncated
|
|
if (zoneIdTruncated == missingKeyError) {
|
|
// We don't include this one if it's missing.
|
|
} else if (
|
|
zoneIdTruncated == readIniError ||
|
|
zoneIdTruncated == "unknown"
|
|
) {
|
|
returnObject.zoneIdTruncated = zoneIdTruncated;
|
|
} else if (zoneIdTruncated.toLowerCase() == "true") {
|
|
returnObject.zoneIdTruncated = true;
|
|
} else if (zoneIdTruncated.toLowerCase() == "false") {
|
|
returnObject.zoneIdTruncated = false;
|
|
} else {
|
|
returnObject.zoneIdTruncated = unexpectedValueError;
|
|
}
|
|
|
|
// readZoneIdError, readZoneIdErrorCode, zoneId, referrerUrl, hostUrl,
|
|
// referrerUrlIsMozilla, hostUrlIsMozilla
|
|
const validReadZoneIdErrors = ["openFile", "readFile"];
|
|
if (
|
|
readZoneIdError != missingKeyError &&
|
|
zoneId == missingKeyError &&
|
|
referrerUrl == missingKeyError &&
|
|
hostUrl == missingKeyError
|
|
) {
|
|
if (
|
|
possibleIniErrors.includes(readZoneIdError) ||
|
|
validReadZoneIdErrors.includes(readZoneIdError)
|
|
) {
|
|
returnObject.readZoneIdError = readZoneIdError;
|
|
} else {
|
|
returnObject.readZoneIdError = unexpectedValueError;
|
|
}
|
|
if (readZoneIdErrorCode != missingKeyError) {
|
|
let code = parseInt(readZoneIdErrorCode, 10);
|
|
if (!isNaN(code)) {
|
|
returnObject.readZoneIdErrorCode = code;
|
|
}
|
|
}
|
|
} else {
|
|
if (possibleIniErrors.includes(zoneId)) {
|
|
returnObject.zoneId = zoneId;
|
|
} else {
|
|
let id = parseInt(zoneId, 10);
|
|
if (isNaN(id) || id < 0 || id > 4) {
|
|
returnObject.zoneId = unexpectedValueError;
|
|
} else {
|
|
returnObject.zoneId = id;
|
|
}
|
|
}
|
|
|
|
let isMozillaURL = url => {
|
|
const mozillaDomains = ["mozilla.com", "mozilla.net", "mozilla.org"];
|
|
for (const domain of mozillaDomains) {
|
|
if (url.hostname == domain) {
|
|
return true;
|
|
}
|
|
if (url.hostname.endsWith("." + domain)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (possibleIniErrors.includes(referrerUrl)) {
|
|
returnObject.referrerUrl = referrerUrl;
|
|
} else {
|
|
try {
|
|
returnObject.referrerUrl = new URL(referrerUrl);
|
|
} catch (ex) {
|
|
returnObject.referrerUrl = unexpectedValueError;
|
|
}
|
|
if (URL.isInstance(returnObject.referrerUrl)) {
|
|
returnObject.referrerUrlIsMozilla = isMozillaURL(
|
|
returnObject.referrerUrl
|
|
);
|
|
}
|
|
}
|
|
|
|
if (possibleIniErrors.includes(hostUrl)) {
|
|
returnObject.hostUrl = hostUrl;
|
|
} else {
|
|
try {
|
|
returnObject.hostUrl = new URL(hostUrl);
|
|
} catch (ex) {
|
|
returnObject.hostUrl = unexpectedValueError;
|
|
}
|
|
if (URL.isInstance(returnObject.hostUrl)) {
|
|
returnObject.hostUrlIsMozilla = isMozillaURL(returnObject.hostUrl);
|
|
}
|
|
}
|
|
}
|
|
|
|
return returnObject;
|
|
})();
|
|
return gReadZoneIdPromise;
|
|
},
|
|
|
|
/**
|
|
* Only submits telemetry once, no matter how many times it is called.
|
|
* Has no effect on OSs where provenance data is not supported.
|
|
*
|
|
* @returns An object indicating the values submitted. Keys may not match the
|
|
* Scalar names since the returned object is intended to be suitable
|
|
* for use as a Telemetry Event's `extra` object, which has shorter
|
|
* limits for extra key names than the limits for Scalar names.
|
|
* Values will be converted to strings since Telemetry Event's
|
|
* `extra` objects must have string values.
|
|
* On platforms that do not support provenance data, this will always
|
|
* return an empty object.
|
|
*/
|
|
async submitProvenanceTelemetry() {
|
|
if (gTelemetryPromise) {
|
|
return gTelemetryPromise;
|
|
}
|
|
gTelemetryPromise = (async () => {
|
|
const errorValue = "error";
|
|
|
|
let extra = {};
|
|
|
|
let provenance = await this.readZoneIdProvenanceFile();
|
|
if (!provenance) {
|
|
return extra;
|
|
}
|
|
|
|
let setTelemetry = (scalarName, extraKey, value) => {
|
|
Services.telemetry.scalarSet(scalarName, value);
|
|
extra[extraKey] = value.toString();
|
|
};
|
|
|
|
setTelemetry(
|
|
"attribution.provenance.data_exists",
|
|
"data_exists",
|
|
!provenance.readProvenanceError
|
|
);
|
|
if (provenance.readProvenanceError) {
|
|
return extra;
|
|
}
|
|
|
|
setTelemetry(
|
|
"attribution.provenance.file_system",
|
|
"file_system",
|
|
provenance.fileSystem ?? errorValue
|
|
);
|
|
|
|
// https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-#ERROR_FILE_NOT_FOUND
|
|
const ERROR_FILE_NOT_FOUND = 2;
|
|
|
|
let ads_exists =
|
|
!provenance.readProvenanceError &&
|
|
!(
|
|
provenance.readZoneIdError == "openFile" &&
|
|
provenance.readZoneIdErrorCode == ERROR_FILE_NOT_FOUND
|
|
);
|
|
setTelemetry(
|
|
"attribution.provenance.ads_exists",
|
|
"ads_exists",
|
|
ads_exists
|
|
);
|
|
if (!ads_exists) {
|
|
return extra;
|
|
}
|
|
|
|
setTelemetry(
|
|
"attribution.provenance.security_zone",
|
|
"security_zone",
|
|
"zoneId" in provenance ? provenance.zoneId.toString() : errorValue
|
|
);
|
|
|
|
let haveReferrerUrl = URL.isInstance(provenance.referrerUrl);
|
|
setTelemetry(
|
|
"attribution.provenance.referrer_url_exists",
|
|
"refer_url_exist",
|
|
haveReferrerUrl
|
|
);
|
|
if (haveReferrerUrl) {
|
|
setTelemetry(
|
|
"attribution.provenance.referrer_url_is_mozilla",
|
|
"refer_url_moz",
|
|
provenance.referrerUrlIsMozilla
|
|
);
|
|
}
|
|
|
|
let haveHostUrl = URL.isInstance(provenance.hostUrl);
|
|
setTelemetry(
|
|
"attribution.provenance.host_url_exists",
|
|
"host_url_exist",
|
|
haveHostUrl
|
|
);
|
|
if (haveHostUrl) {
|
|
setTelemetry(
|
|
"attribution.provenance.host_url_is_mozilla",
|
|
"host_url_moz",
|
|
provenance.hostUrlIsMozilla
|
|
);
|
|
}
|
|
|
|
return extra;
|
|
})();
|
|
return gTelemetryPromise;
|
|
},
|
|
};
|