1207 lines
40 KiB
JavaScript
1207 lines
40 KiB
JavaScript
/** \file
|
|
* \brief Fingerprint Detector (FPD) main logic, fingerprinting evaluation and other essentials
|
|
*
|
|
* \author Copyright (C) 2021-2022 Marek Salon
|
|
*
|
|
* \license SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
//
|
|
|
|
/**
|
|
* \defgroup FPD Fingerprint Detector
|
|
*
|
|
* \brief Fingerprint Detector (FPD) is a module that detects browser fingerprint extraction and prevents
|
|
* its sharing. To learn more about Browser Fingerprinting topic, see study "Browser Fingerprinting: A survey" available
|
|
* here: https://arxiv.org/pdf/1905.01051.pdf
|
|
*
|
|
* The FPD module uses wrapping technique to inject code that allows log API calls and accesses for every visited web page
|
|
* and its frames. Logged JS APIs can be specified in wrappers-lvl_X.json file, where X represents FPD config identifier.
|
|
*
|
|
* Fingerprint Detector is based on heuristic system that can be defined in form of API groups. A group represents
|
|
* a set of APIs that may have something in common. Access to the group is triggered when a certain amount APIs is accessed.
|
|
* Hierarchy of groups creates a tree-like structure, where access to the root group means fingerprinting activity. Groups can
|
|
* be configured in groups-lvl_X.json file, where X represents FPD config identifier.
|
|
*
|
|
* The FPD evaluate API groups with every request made in scope of certain browser tab. When FPD detects fingerprinting activity,
|
|
* blocking of subsequent requests is issued (if allowed in settings). Local browsing data of fingerprinting origin are also cleared
|
|
* to prevent caching extracted fingerprint in browser storage.
|
|
*
|
|
*/
|
|
|
|
/** \file
|
|
*
|
|
* \brief This file is part of Fingerprint Detector (FPD) and contains API groups evaluation logic. File also contains
|
|
* event listeners used for API logging, requests blocking and tabs management.
|
|
*
|
|
* \ingroup FPD
|
|
*/
|
|
|
|
|
|
// START persistent configuration data
|
|
|
|
/**
|
|
* FPD enable flag. Evaluate only when active.
|
|
*/
|
|
var fpDetectionOn;
|
|
|
|
/**
|
|
* Associative array of hosts, that are currently among trusted ones.
|
|
*/
|
|
var fpdWhitelist = {};
|
|
|
|
/**
|
|
* Associative array of settings supported by this module.
|
|
*/
|
|
var fpdSettings = {};
|
|
|
|
// END persistent configuration data
|
|
|
|
|
|
/**
|
|
* Array containing names of unsupported wrappers that should be treated like supported ones during groups evaluation.
|
|
*/
|
|
const exceptionWrappers = ["CSSStyleDeclaration.prototype.fontFamily"];
|
|
|
|
/**
|
|
* API logs database of following structure:
|
|
* "tabId" : {
|
|
* "resource" : {
|
|
* "type" : {
|
|
* arguments {
|
|
* arg : "access count"
|
|
* },
|
|
* total : "total access count"
|
|
* }
|
|
* }
|
|
* }
|
|
*
|
|
* *values in quotations are substituted by concrete names
|
|
*
|
|
*/
|
|
var fpdObservable = new Observable();
|
|
|
|
// depends on /nscl/common/CachedStorage.js
|
|
// session-bound globals
|
|
var fpdGlobals = CachedStorage.init({
|
|
|
|
fpDb: {},
|
|
|
|
/**
|
|
* Contains information about tabs current state.
|
|
*/
|
|
availableTabs: {},
|
|
|
|
/**
|
|
* Store if the user was already notified about fingerprinting activity in the tab.
|
|
*/
|
|
stopNotifyFlag: {},
|
|
|
|
/**
|
|
* A global variable shared with level_cache that controls the collection of calling scripts for FPD
|
|
* report.
|
|
*/
|
|
fpd_track_callers_tab: null,
|
|
|
|
});
|
|
|
|
/**
|
|
* Stores latest evaluation statistics for every examined tab. This statistics contains data about accessed groups and resources
|
|
* and their weights after evaluation. It can be used for debugging or as an informative statement in GUI.
|
|
* It also contains flag for every tab to limit number of notifications.
|
|
*/
|
|
let latestEvals = {};
|
|
|
|
/**
|
|
* Parsed groups object containing necessary group information needed for evaluation.
|
|
* Groups are indexed by level and name for easier and faster access.
|
|
*/
|
|
let fpGroups = {};
|
|
|
|
/**
|
|
* Object containing information about unsupported wrappers for given browser.
|
|
*/
|
|
let unsupportedWrappers = {};
|
|
|
|
/**
|
|
* Definition of settings supported by this module.
|
|
*/
|
|
const FPD_DEF_SETTINGS = {
|
|
behavior: {
|
|
label: browser.i18n.getMessage("fpdBehavior"),
|
|
description: browser.i18n.getMessage("fpdBehaviorDescription"),
|
|
description2: [],
|
|
params: [
|
|
{
|
|
// 0
|
|
short: browser.i18n.getMessage("fpdBehaviorPassive"),
|
|
description: browser.i18n.getMessage("fpdBehaviorPassiveDescription")
|
|
},
|
|
{
|
|
// 1
|
|
short: browser.i18n.getMessage("fpdBehaviorLimitedBlocking"),
|
|
description: browser.i18n.getMessage("fpdBehaviorBlockingDescription"),
|
|
description2: [
|
|
browser.i18n.getMessage("fpdBehaviorBlockingDescription2"),
|
|
browser.i18n.getMessage("fpdBehaviorLimitedBlockingDescription3"),
|
|
browser.i18n.getMessage("fpdBehaviorLimitedBlockingDescription4"),
|
|
browser.i18n.getMessage("fpdBehaviorBlockingDescriptionWarning")
|
|
]
|
|
},
|
|
{
|
|
// 2
|
|
short: browser.i18n.getMessage("fpdBehaviorFullBlocking"),
|
|
description: browser.i18n.getMessage("fpdBehaviorBlockingDescription"),
|
|
description2: [
|
|
browser.i18n.getMessage("fpdBehaviorBlockingDescription2"),
|
|
browser.i18n.getMessage("fpdBehaviorFullBlockingDescription3"),
|
|
browser.i18n.getMessage("fpdBehaviorFullBlockingDescription4"),
|
|
browser.i18n.getMessage("fpdBehaviorBlockingDescriptionWarning")
|
|
],
|
|
permissions: ["browsingData"]
|
|
}
|
|
]
|
|
},
|
|
notifications: {
|
|
label: browser.i18n.getMessage("shieldNotifications"),
|
|
description: browser.i18n.getMessage("fpdNotificationsDescription"),
|
|
description2: [browser.i18n.getMessage("fpdNotificationsDescription2")],
|
|
params: [
|
|
{
|
|
// 0
|
|
short: browser.i18n.getMessage("protectionConfigurationOptionActivatedOff"),
|
|
description: browser.i18n.getMessage("fpdNotificationsOffDescription")
|
|
},
|
|
{
|
|
// 1
|
|
short: browser.i18n.getMessage("protectionConfigurationOptionActivatedOn"),
|
|
description: browser.i18n.getMessage("fpdNotificationsOnDescription")
|
|
}
|
|
]
|
|
},
|
|
detection: {
|
|
label: browser.i18n.getMessage("fpdDetection"),
|
|
description: browser.i18n.getMessage("fpdDetectionDescription"),
|
|
description2: [],
|
|
params: [
|
|
{
|
|
// 0
|
|
short: browser.i18n.getMessage("fpdDetectionDefault"),
|
|
description: browser.i18n.getMessage("fpdDetectionDefaultDescription"),
|
|
description2: [
|
|
browser.i18n.getMessage("fpdDetectionDefaultDescription2"),
|
|
browser.i18n.getMessage("fpdDetectionDefaultDescription3"),
|
|
]
|
|
},
|
|
{
|
|
// 1
|
|
short: browser.i18n.getMessage("fpdStrict"),
|
|
description: browser.i18n.getMessage("fpdStrictDescription"),
|
|
description2: [
|
|
browser.i18n.getMessage("fpdStrictDescription2"),
|
|
browser.i18n.getMessage("fpdStrictDescription3")
|
|
]
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
|
|
var actionApi = browser.browserAction || browser.action;
|
|
// unify default color of popup badge background between different browsers
|
|
actionApi.setBadgeBackgroundColor({color: "#6E7378"});
|
|
|
|
// unify default color of popup badge text between different browsers
|
|
if (typeof actionApi.setBadgeTextColor === "function") {
|
|
actionApi.setBadgeTextColor({color: "#FFFFFF"});
|
|
}
|
|
|
|
/**
|
|
* This function initializes FPD module, loads configuration from storage, and registers listeners needed for fingerprinting detection.
|
|
*/
|
|
async function initFpd() {
|
|
// fill up fpGroups object with necessary data for evaluation
|
|
for (let groupsLevel in fp_levels.groups) {
|
|
fpGroups[groupsLevel] = fpGroups[groupsLevel] || {};
|
|
processGroupsRecursive(fp_levels.groups[groupsLevel], groupsLevel);
|
|
|
|
// Translate severity names
|
|
for (record of fp_levels.groups[groupsLevel].severity) {
|
|
record[1] = browser.i18n.getMessage(record[1]);
|
|
}
|
|
}
|
|
|
|
// load configuration and settings from storage
|
|
await fpdLoadConfiguration();
|
|
await fpdGlobals;
|
|
|
|
// take care of unsupported resources for cross-browser behaviour uniformity
|
|
balanceUnsupportedWrappers();
|
|
|
|
// listen for messages intended for FPD module
|
|
browser.runtime.onMessage.addListener(fpdCommonMessageListener);
|
|
|
|
// listen for click on notification when FPD detects fingerprinting to create a report
|
|
browser.notifications.onClicked.addListener((notificationId) => {
|
|
if (notificationId.startsWith("fpd")) {
|
|
var tabId = notificationId.split("-")[1];
|
|
generateFpdReport(tabId);
|
|
}
|
|
});
|
|
|
|
// listen for update of browser tabs and start periodic FP evaluation
|
|
browser.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) {
|
|
availableTabs[tabId] = tab;
|
|
if (changeInfo.status == "loading") {
|
|
refreshDb(tabId);
|
|
fpDb[tab.id] = {};
|
|
periodicEvaluation(tab.id, 500);
|
|
}
|
|
});
|
|
|
|
// listen for removal of browser tabs to clean fpDb object
|
|
browser.tabs.onRemoved.addListener(function (tabId) {
|
|
refreshDb(tabId);
|
|
delete availableTabs[tabId];
|
|
});
|
|
|
|
// listen for removal of optional permissions to adjust settings accordingly
|
|
browser.permissions.onRemoved.addListener((permissions) => {
|
|
correctSettingsForRemovedPermissions(permissions.permissions, fpdSettings, FPD_DEF_SETTINGS);
|
|
browser.storage.sync.set({"fpdSettings": fpdSettings});
|
|
});
|
|
|
|
if (self.window) {
|
|
// Firefox, event page has window
|
|
// listen for requests through webRequest API and decide whether to block them
|
|
browser.webRequest.onBeforeRequest.addListener(
|
|
fpdRequestCancel,
|
|
{urls: ["<all_urls>"]},
|
|
["blocking"]
|
|
);
|
|
} else {
|
|
// mv3: cannot block!
|
|
// hide behavior settings
|
|
// TODO: shame Google in the setting panels for the missing feature
|
|
delete FPD_DEF_SETTINGS["behavior"];
|
|
// force passive behavior setting
|
|
fpdSettings.behavior = 0;
|
|
browser.storage.sync.set({"fpdSettings": fpdSettings});
|
|
}
|
|
// listen for navigation events to initiate storage clearing of fingerprinting web pages
|
|
browser.webNavigation.onBeforeNavigate.addListener((details) => {
|
|
if (latestEvals[details.tabId] && stopNotifyFlag[details.tabId] && fpdSettings.behavior > 0) {
|
|
// clear storages (using content script) for every frame in this tab
|
|
if (details.tabId >= 0) {
|
|
browser.tabs.sendMessage(details.tabId, {
|
|
cleanStorage: true,
|
|
ignoreWorkaround: fpdSettings.behavior > 1
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// get state of all existing tabs and start periodic FP evaluation
|
|
browser.tabs.query({}).then(function(results) {
|
|
results.forEach(function(tab) {
|
|
availableTabs[tab.id] = tab;
|
|
fpDb[tab.id] ||= {};
|
|
periodicEvaluation(tab.id, 500);
|
|
});
|
|
});
|
|
}
|
|
|
|
/*
|
|
* --------------------------------------------------
|
|
* CRITERIA CORRECTION
|
|
* --------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* This function provides ability to balance/optimalize evaluation heuristics for cross-browser behaviour uniformity.
|
|
* It checks which resources are unsupported for given browser and adjust criteria of loaded FPD configuration accordingly.
|
|
*/
|
|
function balanceUnsupportedWrappers() {
|
|
if (!self.window) return false; // mv3 service worker?
|
|
// object containing groups effected by criteria adjustment and corresponding deleted subgroups
|
|
var effectedGroups = {};
|
|
|
|
// check supported wrappers for each level of FPD configuration
|
|
for (let level in fp_levels.wrappers) {
|
|
unsupportedWrappers[level] = [];
|
|
effectedGroups = {};
|
|
|
|
// find out which wrappers are unsupported and store them in "unsupportedWrappers" object
|
|
for (let wrapper of fp_levels.wrappers[level]) {
|
|
// split path of object from its name
|
|
var resourceSplitted = split_resource(wrapper.resource);
|
|
|
|
// access nested object in browser's "window" object using path string
|
|
var resolvedPath = resourceSplitted["path"].split('.').reduce((o, p) => o ? o[p] : undefined, window);
|
|
|
|
// if resource or resource path is undefined -> resource unsupported && no exception for the resource
|
|
if (!(resolvedPath && resourceSplitted["name"] in resolvedPath) && !exceptionWrappers.includes(wrapper.resource)) {
|
|
// store wrapper object to "unsupportedWrappers" object
|
|
unsupportedWrappers[level].push(wrapper);
|
|
|
|
// mark all groups as effected if containing unsupported resource
|
|
for (let groupObj of wrapper.groups) {
|
|
effectedGroups[groupObj.group] = effectedGroups[groupObj.group] || [];
|
|
}
|
|
|
|
// remove wrapper from FPD configuration (in "fp_levels")
|
|
fp_levels.wrappers[level] = fp_levels.wrappers[level].filter((x) => (x != wrapper));
|
|
}
|
|
}
|
|
|
|
// adjust/correct effected groups criteria
|
|
correctGroupCriteria(fp_levels.groups[level], effectedGroups, level);
|
|
}
|
|
|
|
// refresh "fpGroups" object after criteria adjustment for upcoming evaluations
|
|
for (let groupsLevel in fp_levels.groups) {
|
|
fpGroups[groupsLevel] = {};
|
|
processGroupsRecursive(fp_levels.groups[groupsLevel], groupsLevel);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The function that corrects groups criteria according to unsupported wrappers.
|
|
* Groups should be deleted when more than half of weights (resources) cannot be obtained in the same group (whole group is invalidated).
|
|
* Groups criteria of "value" type are also recomputed to take into account unsupported resources and deleted groups/subgroups.
|
|
*
|
|
* \param rootGroup Group object of certain level from FPD configuration (in "fp_levels.wrappers" format).
|
|
* \param effectedGroups Object containing groups effected by criteria adjustment and corresponding deleted subgroups.
|
|
* \param level FPD configuration level identifier.
|
|
*
|
|
* \returns true if group should be deleted because of unsupported wrappers
|
|
* \returns false if group don't need to be deleted
|
|
*/
|
|
function correctGroupCriteria(rootGroup, effectedGroups, level) {
|
|
// if rootGroup has subgroups, then process all subgroups recursively
|
|
if (rootGroup.groups) {
|
|
for (let groupIdx in rootGroup.groups) {
|
|
// if correctGroupCriteria return true, subgroup is deleted
|
|
if (correctGroupCriteria(rootGroup.groups[groupIdx], effectedGroups, level)) {
|
|
// rootGroup is now also effected and added to "effectedGroups" with deleted subgroup
|
|
effectedGroups[rootGroup.name] = effectedGroups[rootGroup.name] || [];
|
|
effectedGroups[rootGroup.name].push(rootGroup.groups[groupIdx].name);
|
|
}
|
|
|
|
// rootGroup is effected because at least one of its child was effected too
|
|
if (Object.keys(effectedGroups).includes(rootGroup.groups[groupIdx].name)) {
|
|
effectedGroups[rootGroup.name] = effectedGroups[rootGroup.name] || [];
|
|
}
|
|
}
|
|
|
|
// remove deleted subgroups from original group object
|
|
if (effectedGroups[rootGroup.name]) {
|
|
rootGroup.groups = rootGroup.groups.filter((x) => (!effectedGroups[rootGroup.name].includes(x.name)))
|
|
}
|
|
}
|
|
|
|
// if group is effected, try to correct its criteria
|
|
if (Object.keys(effectedGroups).includes(rootGroup.name)) {
|
|
// original sum of max weights of direct children
|
|
var maxOriginalWeight = 0;
|
|
|
|
// sum of max weights after deletion of unsupported wrappers
|
|
var maxNewWeight = 0;
|
|
|
|
// get values of "maxOriginalWeight" and "maxNewWeight"
|
|
for (let resource in fpGroups[level][rootGroup.name].items) {
|
|
// if resource is subgroup
|
|
if (fpGroups[level][rootGroup.name].items[resource] == "group") {
|
|
let maxWeight = fpGroups[level][resource].criteria.reduce((x, {weight}) => (x > weight ? x : weight), 0);
|
|
|
|
maxOriginalWeight += maxWeight;
|
|
maxNewWeight += !effectedGroups[rootGroup.name].includes(resource) ? maxWeight : 0;
|
|
}
|
|
// if resource is property or function
|
|
else {
|
|
// specific resource object from FPD configuration of wrappers
|
|
var resourceObj;
|
|
|
|
// array of groups where resource can be found
|
|
var groupsArray;
|
|
|
|
// flag - resource is supported (resource found in "fp_levels.wrappers" instead of "unsupportedWrappers")
|
|
var supported = false;
|
|
|
|
// get resource from wrappers and extract all group objects in context of parent group (rootGroup)
|
|
if (resourceObj = fp_levels.wrappers[level].filter((x) => (x.resource == resource))[0]) {
|
|
groupsArray = resourceObj.groups.filter((x) => (x.group == rootGroup.name));
|
|
supported = true;
|
|
}
|
|
else {
|
|
resourceObj = unsupportedWrappers[level].filter((x) => (x.resource == resource))[0];
|
|
groupsArray = resourceObj.groups.filter((x) => (x.group == rootGroup.name));
|
|
}
|
|
|
|
// get maximal obtainable weight for resource from every group object
|
|
for (let groupObj of groupsArray) {
|
|
if (groupObj.criteria && groupObj.criteria.length > 0) {
|
|
let maxWeight = groupObj.criteria.reduce((x, {weight}) => (x > weight ? x : weight), 0);
|
|
|
|
maxOriginalWeight += maxWeight;
|
|
maxNewWeight += supported ? maxWeight : 0;
|
|
}
|
|
else {
|
|
maxOriginalWeight += 1;
|
|
maxNewWeight += supported ? 1 : 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// adjust "value" criteria to follow their original percentage state
|
|
for (let criterion of fpGroups[level][rootGroup.name].criteria) {
|
|
if ("value" in criterion) {
|
|
// original percentage of given criterion
|
|
var percValue = (criterion.value*100) / maxOriginalWeight;
|
|
|
|
// get new criterion value adjusted for new max weights
|
|
criterion.value = Math.round((percValue*maxNewWeight) / 100);
|
|
}
|
|
}
|
|
|
|
// if sum of max weights of unsupported resources is higher than 50% of total original weights
|
|
// in this case, group should be deleted or manually corrected in FPD configuration to contain only supported resources
|
|
if (2*maxNewWeight < maxOriginalWeight) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* --------------------------------------------------
|
|
* GROUPS EVALUATION
|
|
* --------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* The function initializing evaluation of logged API calls (fpDb) according to groups criteria (fpGroups).
|
|
*
|
|
* \param tabId Integer number representing ID of browser tab that is going to be evaluated.
|
|
*
|
|
* \returns object where "weight" property represents evaluated weight of root group and "severity" property contain severity array
|
|
*/
|
|
function evaluateGroups(tabId) {
|
|
// get url of evaluated tab
|
|
var url = availableTabs[tabId] ? availableTabs[tabId].url : "";
|
|
var ret = {
|
|
weight: 0,
|
|
severity: []
|
|
}
|
|
|
|
// inaccesible or invalid url - do not evaluate
|
|
if (!url) {
|
|
return ret;
|
|
}
|
|
|
|
// clear old evalStats
|
|
latestEvals[tabId] = latestEvals[tabId] || {};
|
|
latestEvals[tabId].evalStats = [];
|
|
|
|
// check if the level exists within FPD configuration, if not use default FPD configuration
|
|
level = fpdSettings.detection ? fpdSettings.detection : 0;
|
|
|
|
// getting root group name as a start point for recursive evaluation
|
|
var rootGroup = fp_levels.groups[level] ? fp_levels.groups[level].name : undefined;
|
|
|
|
// start recursive evaluation if all needed objects are defined
|
|
if (rootGroup && fpGroups[level] && fp_levels.wrappers[level]) {
|
|
let evalRes = evaluateGroupsCriteria(rootGroup, level, tabId)[0];
|
|
ret.weight = evalRes.actualWeight;
|
|
if (fp_levels.groups[level].severity) {
|
|
let sortedSeverity = fp_levels.groups[level].severity.sort((x, y) => x[0] - y[0]);
|
|
ret.severity = sortedSeverity.filter((x) => (x[0] <= evalRes.actualWeightsSum)).reverse()[0];
|
|
}
|
|
}
|
|
|
|
return ret;
|
|
}
|
|
|
|
/**
|
|
* The function that evaluates group criteria according to evaluation of its child items (groups/resources).
|
|
*
|
|
* \param rootGroup Group name that needs to be evaluated.
|
|
* \param level Level ID of groups and wrappers used for evaluation.
|
|
* \param tabId Integer number representing ID of evaluated browser tab.
|
|
*
|
|
* \returns Array that contains "Result" objects
|
|
*
|
|
* Result object contains following properties:
|
|
* actualWeight (Obtained weight value of group after evaluation)
|
|
* maxWeight (Maximum obtainable weight value of group)
|
|
* type (Type of group item - group/call/get/set)
|
|
* accesses (Number of accesses to specified resource - groups always 0)
|
|
*/
|
|
function evaluateGroupsCriteria(rootGroup, level, tabId) {
|
|
// result object that is delegated to parent group
|
|
var res = {};
|
|
|
|
// all result objects from child items of rootGroup
|
|
var scores = [];
|
|
|
|
// array of relevant criteria based on groupTypes
|
|
var relevantCriteria = [];
|
|
|
|
// types of criteria that are relevant for evaluating rootGroup
|
|
var groupTypes = [];
|
|
|
|
// evaluate every item of rootGroup and add result objects to scores array
|
|
for (let item in fpGroups[level][rootGroup].items) {
|
|
if (fpGroups[level][rootGroup].items[item] == "group") {
|
|
scores = scores.concat(evaluateGroupsCriteria(item, level, tabId));
|
|
}
|
|
else {
|
|
scores = scores.concat(evaluateResourcesCriteria(item, rootGroup ,level, tabId));
|
|
}
|
|
}
|
|
|
|
/*
|
|
Group type is determined by first criteria object:
|
|
- access - evaluation of child items is based on sum of accesses
|
|
- value/percentage - evaluation of child items is based on sum of weights or percentage of sum of maxWeights
|
|
*/
|
|
groupTypes = Object.keys(fpGroups[level][rootGroup].criteria[0]).includes("access") ? ["access"] : ["value", "percentage"];
|
|
relevantCriteria = fpGroups[level][rootGroup].criteria.filter((x) => (groupTypes.some((y) => (Object.keys(x).includes(y)))));
|
|
|
|
// now evaluating group
|
|
res.type = "group";
|
|
|
|
// get maximal obtainable weight for rootGroup
|
|
res.maxWeight = fpGroups[level][rootGroup].criteria.reduce((x, {weight}) => (x > weight ? x : weight), 0);
|
|
|
|
// compute actualWeight of rootGroup with value of accesses
|
|
res.accesses = 0;
|
|
res.actualWeight = 0;
|
|
res.actualWeightsSum = 0;
|
|
if (groupTypes.length == 2) {
|
|
// groupTypes contains "value" and "percetange" - take weight of child items into account
|
|
var actualWeightsSum = scores.reduce((x, {actualWeight}) => (x + actualWeight), 0);
|
|
var maxWeightsSum = scores.reduce((x, {maxWeight}) => (x + maxWeight), 0);
|
|
|
|
// recalculate percentage values of relevant criteria to exact values
|
|
var relativeCriteria = [];
|
|
for (let criteriaObj of relevantCriteria) {
|
|
if (criteriaObj.value) {
|
|
relativeCriteria.push(criteriaObj);
|
|
}
|
|
else {
|
|
relativeCriteria.push({
|
|
value: Math.round(maxWeightsSum * (criteriaObj.percentage/100)),
|
|
weight: criteriaObj.weight
|
|
});
|
|
}
|
|
}
|
|
|
|
// sort relevant and relative criteria by value
|
|
relativeCriteria.sort((x, y) => (x.value - y.value));
|
|
|
|
// filter criteria and take weight of highest achieved criteria
|
|
var filteredCriteria = relativeCriteria.filter((x) => (x.value <= actualWeightsSum)).reverse()[0];
|
|
res.actualWeight = filteredCriteria ? filteredCriteria.weight : 0;
|
|
res.actualWeightsSum = actualWeightsSum;
|
|
}
|
|
else {
|
|
// groupTypes contains "access" - take access of child items into account
|
|
var accessesSum = scores.reduce((x, {accesses}) => (x + accesses), 0);
|
|
|
|
// sort relevant criteria
|
|
relevantCriteria.sort((x, y) => (x.access - y.access));
|
|
|
|
// filter criteria and take weight of highest achieved criteria
|
|
var filteredCriteria = relevantCriteria.filter((x) => (x.access <= accessesSum)).reverse()[0];
|
|
res.actualWeight = filteredCriteria ? filteredCriteria.weight : 0;
|
|
res.actualWeightsSum = accessesSum;
|
|
}
|
|
|
|
// update group statistics in latestEvals
|
|
latestEvals[tabId].evalStats.push({
|
|
title: rootGroup,
|
|
type: "group",
|
|
weight: res.actualWeight,
|
|
sum: res.actualWeightsSum
|
|
});
|
|
|
|
return [res];
|
|
}
|
|
|
|
/**
|
|
* The function that evaluates resource (wrapper) criteria according to API calls logs.
|
|
*
|
|
* \param resource Full name of resource/wrapper.
|
|
* \param groupName Name of direct parent group.
|
|
* \param level Level ID of groups and wrappers used for evaluation.
|
|
* \param tabId Integer number representing ID of evaluated browser tab.
|
|
*
|
|
* \returns Array that contains "Result" objects
|
|
*
|
|
* Result object contains following properties (all of them in context of parent group):
|
|
* actualWeight (Obtained weight value of resource after evaluation)
|
|
* maxWeight (Maximum obtainable weight value of resource)
|
|
* type (Type of resource - call/get/set)
|
|
* accesses (Number of accesses to specified resource)
|
|
*/
|
|
function evaluateResourcesCriteria(resource, groupName, level, tabId) {
|
|
// all result objects for given resource (set/get/call)
|
|
var scores = [];
|
|
|
|
// get resource from wrappers and extract all group objects in context of parent group (groupName)
|
|
var resourceObj = fp_levels.wrappers[level].filter((x) => (x.resource == resource))[0];
|
|
var groupsArray = resourceObj.groups.filter((x) => (x.group == groupName));
|
|
|
|
// evaluate every retrieved group object
|
|
for (let groupObj of groupsArray) {
|
|
// initialize new result object
|
|
var res = {}
|
|
|
|
// get resource type from group object (get/set/call)
|
|
if (resourceObj.type == "property") {
|
|
if (groupObj.property) {
|
|
res.type = groupObj.property
|
|
}
|
|
else {
|
|
// property not defined => implicit get
|
|
res.type = "get";
|
|
}
|
|
}
|
|
else {
|
|
res.type = "call"
|
|
}
|
|
|
|
// get maximal obtainable weight for resource from group object
|
|
if (groupObj.criteria && groupObj.criteria.length > 0) {
|
|
res.maxWeight = groupObj.criteria.reduce((x, {weight}) => (x > weight ? x : weight), 0);
|
|
}
|
|
else {
|
|
// criteria not defined => set implicit criteria
|
|
res.maxWeight = 1;
|
|
groupObj.criteria = [{value: 1, weight: 1}];
|
|
}
|
|
|
|
// compute actualWeight of resource in context of parent group from logs located in fpDb object
|
|
res.actualWeight = 0;
|
|
if (fpDb[tabId] && fpDb[tabId][resource] && fpDb[tabId][resource][res.type]) {
|
|
let record = fpDb[tabId][resource][res.type];
|
|
// logs for given resource and type exist
|
|
if (groupObj.arguments) {
|
|
// if arguments logging is defined, evaluate resource accordingly
|
|
|
|
if (groupObj.arguments == "diff") {
|
|
// "diff" - accesses depend on number of different arguments
|
|
res.accesses = Object.keys(record.args).length;
|
|
}
|
|
else if (groupObj.arguments == "same") {
|
|
// "same" - accesses depend on maximum number of same arguments calls
|
|
res.accesses = Object.values(record.args).reduce((x, y) => x > y ? x : y);
|
|
}
|
|
else {
|
|
// try to interpret arguments as regular expression and take accesses that match this expression
|
|
try {
|
|
let re = new RegExp(...groupObj.arguments);
|
|
res.accesses = Object.keys(record.args).reduce(
|
|
(x, y) => (re.test(y) ? x + record.args[y] : x), 0);
|
|
} catch {
|
|
res.accesses = 0;
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
// arguments logging not defined, simply take total accesses to resource
|
|
res.accesses = record.total
|
|
}
|
|
|
|
// sort criteria by value
|
|
groupObj.criteria.sort((x, y) => (x.value - y.value));
|
|
|
|
// filter criteria and take weight of highest achieved criteria
|
|
var filteredCriteria = groupObj.criteria.filter((x) => (x.value <= res.accesses)).reverse()[0];
|
|
res.actualWeight = filteredCriteria ? filteredCriteria.weight : 0;
|
|
}
|
|
else {
|
|
// no logs of given criteria
|
|
res.accesses = 0;
|
|
}
|
|
scores.push(res)
|
|
}
|
|
|
|
// update resource statistics in latestEvals
|
|
scores.forEach(function (res) {
|
|
latestEvals[tabId].evalStats.push({
|
|
title: resource,
|
|
type: "resource",
|
|
resource: res.type,
|
|
group: groupName,
|
|
weight: res.actualWeight,
|
|
accesses: res.accesses
|
|
});
|
|
});
|
|
|
|
return scores;
|
|
}
|
|
|
|
/*
|
|
* --------------------------------------------------
|
|
* EVENT HANDLERS
|
|
* --------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Callback function that parses and handles messages recieved by FPD module.
|
|
* Messages may contain wrappers logging data that are stored into fpDb object.
|
|
* Also listen for popup messages to update FPD state and whitelist.
|
|
*
|
|
* \param message Receives full message.
|
|
* \param sender Sender of the message.
|
|
*/
|
|
async function fpdCommonMessageListener(record, sender) {
|
|
if (!record) return;
|
|
await fpdGlobals;
|
|
try {
|
|
switch (record.purpose) {
|
|
case "fp-detection":
|
|
// check objects existance => if do not exist, create new one
|
|
fpDb[sender.tab.id] = fpDb[sender.tab.id] || {};
|
|
fpDb[sender.tab.id][record.resource] = fpDb[sender.tab.id][record.resource] || {};
|
|
fpDb[sender.tab.id][record.resource][record.type] = fpDb[sender.tab.id][record.resource][record.type] || {};
|
|
|
|
// object that contains access counters
|
|
const fpCounterObj = fpDb[sender.tab.id][record.resource][record.type];
|
|
const argsStr = record.args.join();
|
|
fpCounterObj["args"] = fpCounterObj["args"] || {};
|
|
|
|
// increase counter for accessed arguments
|
|
fpCounterObj["args"][argsStr] = fpCounterObj["args"][argsStr] || 0;
|
|
fpCounterObj["args"][argsStr] += 1;
|
|
|
|
// increase counter for total accesses
|
|
fpCounterObj["total"] = fpCounterObj["total"] || 0;
|
|
fpCounterObj["total"] += 1;
|
|
fpdObservable.update(record.resource, sender.tab.id, record.type, fpCounterObj["total"]);
|
|
|
|
// Track callers
|
|
fpCounterObj["callers"] = fpCounterObj["callers"] || {};
|
|
if (record.stack !== undefined) {
|
|
fpCounterObj["callers"][record.stack] = true;
|
|
}
|
|
break;
|
|
case "fpd-state-change":
|
|
browser.storage.sync.get(["fpDetectionOn"]).then(function(result) {
|
|
fpDetectionOn = result.fpDetectionOn;
|
|
});
|
|
break;
|
|
case "fpd-whitelist-check": {
|
|
// answer to popup, when asking whether is the site whitelisted
|
|
return Promise.resolve(isFpdWhitelisted(record.url));
|
|
}
|
|
case "add-fpd-whitelist":
|
|
// obtain current hostname and whitelist it
|
|
var currentHost = record.url;
|
|
fpdWhitelist[currentHost] = true;
|
|
browser.storage.sync.set({"fpdWhitelist": fpdWhitelist});
|
|
break;
|
|
case "remove-fpd-whitelist":
|
|
// obtain current hostname and remove it form whitelist
|
|
var currentHost = record.url;
|
|
delete fpdWhitelist[currentHost];
|
|
browser.storage.sync.set({"fpdWhitelist": fpdWhitelist});
|
|
break;
|
|
case "update-fpd-whitelist":
|
|
// update current fpdWhitelist from storage
|
|
browser.storage.sync.get(["fpdWhitelist"]).then(function(result) {
|
|
fpdWhitelist = result.fpdWhitelist;
|
|
});
|
|
break;
|
|
case "fpd-get-report-data": {
|
|
// get current FPD level for evaluated tab
|
|
if (record.tabId) {
|
|
level = fpdSettings.detection ? fpdSettings.detection : 0;
|
|
return Promise.resolve({
|
|
tabObj: availableTabs[record.tabId],
|
|
groups: {root: fp_levels.groups[level].name, all: fpGroups[level]},
|
|
fpDb: fpDb[record.tabId],
|
|
latestEvals: latestEvals[record.tabId],
|
|
exceptionWrappers: exceptionWrappers
|
|
});
|
|
}
|
|
}
|
|
case "fpd-create-report":
|
|
// create FPD report for the tab
|
|
if (record.tabId) {
|
|
generateFpdReport(record.tabId);
|
|
}
|
|
break;
|
|
case "fpd-fetch-severity": {
|
|
// send severity value of the latest evaluation
|
|
let severity = [];
|
|
if (record.tabId && isFpdOn(record.tabId) && latestEvals[record.tabId]) {
|
|
severity = latestEvals[record.tabId].severity;
|
|
}
|
|
return Promise.resolve(severity);
|
|
}
|
|
case "fpd-get-settings": {
|
|
// send settings definition and current values
|
|
return Promise.resolve({
|
|
def: FPD_DEF_SETTINGS,
|
|
val: fpdSettings
|
|
});
|
|
}
|
|
case "fpd-set-settings":
|
|
// update current settings
|
|
fpdSettings[record.id] = record.value;
|
|
browser.storage.sync.set({"fpdSettings": fpdSettings});
|
|
break;
|
|
case "fpd-load-config":
|
|
// load current configuration
|
|
fpdLoadConfiguration();
|
|
break;
|
|
case "fpd-clear-storage":
|
|
// clear browser storage for the origin
|
|
clearStorageFacilities(record.url);
|
|
break;
|
|
case "fpd-fetch-hits": {
|
|
let {tabId} = record;
|
|
// filter by tabId;
|
|
let hits = Object.create(null);
|
|
if (fpDb[tabId]) {
|
|
for ([resource, recordObj] of Object.entries(fpDb[tabId])) {
|
|
let total = 0;
|
|
for (let stat of Object.values(recordObj)) { // by type
|
|
total += stat.total;
|
|
}
|
|
let group_name = wrapping_groups.wrapper_map[resource];
|
|
if (group_name) {
|
|
get_or_create(hits, group_name, 0);
|
|
hits[group_name] += total;
|
|
}
|
|
}
|
|
}
|
|
return Promise.resolve(hits);
|
|
}
|
|
case "fpd-track-callers": {
|
|
let tabId = Number(record.tabId);
|
|
fpd_track_callers_tab = tabId;
|
|
return browser.tabs.reload(tabId);
|
|
}
|
|
case "fpd-track-callers-stop": {
|
|
fpd_track_callers_tab = undefined;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(e, "Error processing", record);
|
|
throw e;
|
|
} finally {
|
|
CachedStorage.save();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The function that makes decisions about requests blocking. If blocking enabled, also clear browsing data.
|
|
*
|
|
* \param requestDetails Details about the request.
|
|
*
|
|
* \returns Object containing key "cancel" with value true if request is blocked, otherwise with value false
|
|
*/
|
|
function fpdRequestCancel(requestDetails) {
|
|
|
|
// chrome fires onBeforeRequest event before tabs.onUpdated => refreshDb won't happen in time
|
|
// need to refreshDb when main_frame request occur, otherwise also user's requests will be blocked
|
|
if (requestDetails.type == "main_frame") {
|
|
refreshDb(requestDetails.tabId);
|
|
}
|
|
|
|
return evaluateFingerprinting(requestDetails.tabId)
|
|
}
|
|
|
|
/*
|
|
* --------------------------------------------------
|
|
* MISCELLANEOUS
|
|
* --------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* The function that loads module configuration from sync storage.
|
|
*/
|
|
async function fpdLoadConfiguration() {
|
|
({fpDetectionOn, fpdWhitelist, fpdSettings} = await browser.storage.sync.get({
|
|
fpDetectionOn: false,
|
|
fpdWhitelist: {},
|
|
fpdSettings: {},
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* The function transforming recursive groups definition into indexed fpGroups object.
|
|
*
|
|
* \param input Group object from loaded JSON format.
|
|
* \param groupsLevel Level ID of groups to process.
|
|
*/
|
|
function processGroupsRecursive(input, groupsLevel) {
|
|
fpGroups[groupsLevel][input.name] = {};
|
|
fpGroups[groupsLevel][input.name]["description"] = input["description"] || "";
|
|
|
|
// criteria missing => set implicit criteria
|
|
fpGroups[groupsLevel][input.name]["criteria"] = input["criteria"] || [{value:1, weight:1}];
|
|
fpGroups[groupsLevel][input.name]["items"] = {};
|
|
|
|
// retrieve associated resources (wrappers) for input group
|
|
for (let resourceObj of fp_levels.wrappers[groupsLevel]) {
|
|
if (resourceObj.groups.filter((x) => (x.group == input.name)).length > 0) {
|
|
fpGroups[groupsLevel][input.name]["items"][resourceObj.resource] = resourceObj.type;
|
|
}
|
|
}
|
|
|
|
// retrieve associated sub-groups for given input group and process them recursively
|
|
if (input["groups"]) {
|
|
for (let groupObj of input["groups"]) {
|
|
fpGroups[groupsLevel][input.name]["items"][groupObj.name] = "group";
|
|
processGroupsRecursive(groupObj, groupsLevel);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the hostname or any of it's domains is whitelisted.
|
|
*
|
|
* \param hostname Any hostname (subdomains allowed).
|
|
*
|
|
* \returns TRUE when domain (or subdomain) is whitelisted, FALSE otherwise.
|
|
*/
|
|
function isFpdWhitelisted(hostname) {
|
|
var domains = extractSubDomains(hostname);
|
|
for (var domain of domains) {
|
|
if (fpdWhitelist[domain] != undefined) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The function that returns FPD setting for given url.
|
|
*
|
|
* \param tabId Tab identifier for which FPD setting is needed.
|
|
*
|
|
* \returns Boolean value TRUE if FPD is on, otherwise FALSE.
|
|
*/
|
|
function isFpdOn(tabId) {
|
|
if (!availableTabs[tabId]) {
|
|
return false;
|
|
}
|
|
let url = getEffectiveDomain(availableTabs[tabId].url);
|
|
if (fpDetectionOn && !isFpdWhitelisted(url)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The function that creates notification and informs user about fingerprinting activity.
|
|
*
|
|
* \param tabId Integer number representing ID of suspicious browser tab.
|
|
*/
|
|
function notifyFingerprintBlocking(tabId) {
|
|
let msg;
|
|
if (fpdSettings.behavior > 0) {
|
|
msg = browser.i18n.getMessage("fpdBlockingSubsequent");
|
|
}
|
|
if (fpdSettings.behavior == 0) {
|
|
msg = browser.i18n.getMessage("fpdClickNotificationDetails");
|
|
}
|
|
|
|
browser.notifications.create("fpd-" + tabId, {
|
|
type: "basic",
|
|
iconUrl: browser.runtime.getURL("img/icon-48.png"),
|
|
title: browser.i18n.getMessage("fpdNotificationTitle"),
|
|
message: browser.i18n.getMessage("fpdNotificationMessage",
|
|
[
|
|
msg,
|
|
availableTabs[tabId].title.slice(0, 30),
|
|
getEffectiveDomain(availableTabs[tabId].url)
|
|
])
|
|
});
|
|
setTimeout(() => {
|
|
browser.notifications.clear("fpd-" + tabId);
|
|
}, 6000);
|
|
}
|
|
|
|
/**
|
|
* The function that generates a report about fingerprinting evaluation in a separate window.
|
|
*
|
|
* \param tabId Integer number representing ID of evaluated browser tab.
|
|
*/
|
|
function generateFpdReport(tabId) {
|
|
// open popup window containing FPD report
|
|
browser.windows.create({
|
|
url: "/fp_report.html?id=" + tabId,
|
|
type: "popup",
|
|
height: 600,
|
|
width: 800
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear all stored logs for a tab.
|
|
*
|
|
* \param tabId Integer number representing ID of browser tab.
|
|
*/
|
|
function refreshDb(tabId) {
|
|
if (fpDb[tabId]) {
|
|
delete fpDb[tabId];
|
|
}
|
|
if (latestEvals[tabId]) {
|
|
delete latestEvals[tabId];
|
|
}
|
|
if (availableTabs[tabId] && availableTabs[tabId].timerId) {
|
|
clearTimeout(availableTabs[tabId].timerId);
|
|
}
|
|
CachedStorage.save();
|
|
}
|
|
|
|
/**
|
|
* The function that periodically starts fingerprinting evaluation without the need for a request.
|
|
* Delay is increased exponentially and doubles in every call.
|
|
*
|
|
* \param tabId Integer number representing ID of browser tab.
|
|
* \param delay Initial value of a delay in milliseconds.
|
|
*/
|
|
function periodicEvaluation(tabId, delay) {
|
|
evaluateFingerprinting(tabId);
|
|
if (availableTabs[tabId]) {
|
|
// limit max delay to 90s per tab
|
|
availableTabs[tabId].timerId = setTimeout(periodicEvaluation, delay, tabId, delay > 90000 ? delay : delay*2);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The function that starts evaluation process and if fingerprinting is detected, it reacts accordingly.
|
|
*
|
|
* \param tabId Integer number representing ID of evaluated browser tab.
|
|
*
|
|
* \returns Object containing key "cancel" with value true if request is blocked, otherwise with value false
|
|
*/
|
|
function evaluateFingerprinting(tabId) {
|
|
// if FPD enabled for the site continue with FP evaluation
|
|
if (isFpdOn(tabId)) {
|
|
|
|
// start FP evaluation process and store result array
|
|
var evalResult = evaluateGroups(tabId);
|
|
|
|
// store latest severity value after evaluation of given tab
|
|
if (evalResult.severity) {
|
|
latestEvals[tabId].severity = evalResult.severity;
|
|
}
|
|
|
|
// modify color of action icon
|
|
if (evalResult.severity[2]) {
|
|
actionApi.setBadgeBackgroundColor({color: evalResult.severity[2], tabId: tabId});
|
|
}
|
|
|
|
// if actualWeight of root group is higher than 0 => reactive phase and applying measures
|
|
if (evalResult.weight) {
|
|
|
|
// create notification for user if behavior is "notification" or higher (only once for every tab load)
|
|
if (fpdSettings.notifications == 1 && !stopNotifyFlag[tabId]) {
|
|
notifyFingerprintBlocking(tabId);
|
|
stopNotifyFlag[tabId] = true;
|
|
CachedStorage.save();
|
|
}
|
|
|
|
// block request and clear cache data only if "blocking" behavior is set
|
|
if (fpdSettings.behavior > 0) {
|
|
|
|
// clear storages (using content script) for every frame in this tab
|
|
if (tabId >= 0) {
|
|
browser.tabs.sendMessage(tabId, {
|
|
cleanStorage: true,
|
|
ignoreWorkaround: fpdSettings.behavior > 1
|
|
});
|
|
}
|
|
|
|
return {
|
|
cancel: true
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
cancel: false
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The function that clears all supported browser storage mechanisms for a given origin.
|
|
*
|
|
* \param url URL address of the origin.
|
|
*/
|
|
function clearStorageFacilities(url) {
|
|
// clear all browsing data for origin of tab url to prevent fingerprint caching
|
|
if (url && fpdSettings.behavior > 1) {
|
|
try {
|
|
// "origins" key only supported by Chromium browsers
|
|
browser.browsingData.remove({
|
|
"origins": [new URL(url).origin]
|
|
}, {
|
|
"appcache": true,
|
|
"cache": true,
|
|
"cacheStorage": true,
|
|
"cookies": true,
|
|
"fileSystems": true,
|
|
"indexedDB": true,
|
|
"localStorage": true,
|
|
"serviceWorkers": true,
|
|
"webSQL": true
|
|
});
|
|
}
|
|
catch (e) {
|
|
// need to use "hostnames" key for Firefox
|
|
if (e.message.includes("origins")) {
|
|
browser.browsingData.remove({
|
|
"hostnames": extractSubDomains(new URL(url).hostname).filter((x) => (x.includes(".")))
|
|
}, {
|
|
"cache": true,
|
|
"cookies": true,
|
|
"indexedDB": true,
|
|
"localStorage": true,
|
|
"serviceWorkers": true
|
|
});
|
|
}
|
|
else {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The function splitting resource string into path and name.
|
|
* For example: "window.navigator.userAgent" => path: "window.navigator", name: "userAgent"
|
|
*
|
|
* \param wrappers text String representing full name of resource.
|
|
*
|
|
* \returns Object consisting of two properties (path, name) for given resource.
|
|
*/
|
|
function split_resource(text) {
|
|
var index = text.lastIndexOf('.');
|
|
return {
|
|
path: text.slice(0, index),
|
|
name: text.slice(index + 1)
|
|
}
|
|
}
|