-
Romain Fouquet authoredRomain Fouquet authored
detect-frameworks.js 10.46 KiB
/*
* Copyright (c) 2022 Inria
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
'use strict';
const asyncFlatMap = async (array, callback) => {
return (await Promise.all(array.map(callback)))
.flat();
};
const getStylesheetText = async (stylesheet) => {
if (stylesheet.ownerNode.tagName === 'STYLE') {
// Return inline style
return stylesheet.ownerNode.textContent;
}
try {
return (await fetch(
stylesheet.href,
{ cache: 'force-cache' },
)).text();
} catch(_) {
// The fetch can fail because of the CSP or CORS
return '';
}
};
const getScriptText = async (script) => {
if (!script.src) {
// Return inline script
return script.textContent;
}
try {
return (await fetch(
script.src,
{ cache: 'force-cache' },
)).text();
} catch(_) {
// The fetch can fail because of the CSP or CORS
return '';
}
};
const getCSSRulesFromDownloadedStylesheet = async (styleSheetURL) => {
let cssText = '';
try {
cssText = await (await fetch(
styleSheetURL,
{ cache: 'force-cache' },
)).text();
} catch(_) {
// The fetch can fail because of the CSP or CORS
console.log(styleSheetURL);
return [];
}
// Write the stylesheet contents to a <style> elements so we can read
// the parsed stylesheet
const styleElement = document.createElement('style');
styleElement.textContent = cssText;
document.body.appendChild(styleElement);
try {
return await new Promise((resolve, reject) => {
// Wait for the style element to be ready
setTimeout(() => {
// Read the just-added stylesheet
try {
resolve(Array.from(styleElement.sheet.cssRules));
} catch {
reject(new Error());
} finally {
styleElement.remove();
}
}, 10);
});
} catch {
console.log(styleSheetURL);
return [];
}
};
const getStylesheetCSSRules = async (styleSheet) => {
try {
return Array.from(styleSheet.cssRules);
} catch(e) {
// Access to styleSheets is not possible for CORS styleSheets
// https://bugzilla.mozilla.org/show_bug.cgi?id=1393022
// instead, fetch and inject the stylesheet ourself so it is not
// cross-origin anymore
if (e.name === 'SecurityError') {
console.log('CORS stylesheet', styleSheet.href);
return await getCSSRulesFromDownloadedStylesheet(styleSheet.href);
}
return [];
}
};
const COMPONENT_SELECTORS = [
{ component: 'data-accordion', selector: '[data-toggle="collapse"].accordion-button, [data-bs-toggle="collapse"].accordion-button' },
{ component: 'data-collapse', selector: '[data-toggle="collapse"]:not(.accordion-button), [data-bs-toggle="collapse"]:not(.accordion-button)' },
{ component: 'data-carousel', selector: '[data-ride="carousel"], [data-bs-ride="carousel"]' },
{ component: 'data-dropdown', selector: '[data-toggle="dropdown"], [data-bs-toggle="dropdown"]' },
{ component: 'data-modal', selector: '[data-dismiss="modal"], [data-bs-dismiss="modal"]' },
{ component: 'data-tab', selector: '[data-toggle="tab"], [data-bs-toggle="tab"]' },
{ component: 'data-offcanvas', selector: '[data-bs-toggle="offcanvas"]' }, // No offcanvas before Bootstrap 5
{ component: 'data-popover', selector: '[data-toggle="popover"], [data-bs-toggle="popover"]' },
{ component: 'data-scroll', selector: '[data-spy="scroll"], [data-bs-spy="scroll"]' },
{ component: 'data-tooltip', selector: '[data-toggle="tooltip"], [data-bs-toggle="tooltip"]' },
{ component: 'class-accordion', selector: '.accordion' },
// .alert-message is for Bootstrap 1 (https://getbootstrap.com/1.4.0/#alerts)
{ component: 'class-alert', selector: '.alert, .alert-message' },
{ component: 'class-btn-close', selector: '.btn-close' },
{ component: 'class-collapse', selector: '.collapse' },
{ component: 'class-carousel', selector: '.carousel' },
{ component: 'class-dropdown', selector: '.dropdown' },
{ component: 'class-modal', selector: '.modal' },
{ component: 'class-tabs', selector: '.nav-tabs' },
{ component: 'class-pills', selector: '.nav-pills' },
{ component: 'class-spinner', selector: '.spinner-border, .spinnger-grow' },
{ component: 'class-toast', selector: '.toast' },
{ component: 'class-offcanvas', selector: '.offcanvas' },
];
const detectJSBootstrap5Versions = (win) => {
// This is only compatible with Firefox
const bootstrapObj = win.wrappedJSObject.bootstrap;
const version = bootstrapObj
&& bootstrapObj.Dropdown
&& bootstrapObj.Dropdown.VERSION;
XPCNativeWrapper(win.wrappedJSObject.bootstrap);
return [version];
};
const detectJSBootstrapBannerVersions = async (win) => {
const bannerVersions = await asyncFlatMap(
Array.from(win.document.scripts),
async (script) => {
const scriptText = await getScriptText(script);
const matches = scriptText.match(/^\s*\* [bB]ootstrap(?:[-A-Za-z]*\.js)? v([0-9.]+)/m);
if (matches) {
return [matches[1]];
}
return [];
},
);
return bannerVersions;
};
// Inspired by https://github.com/johnmichel/Library-Detector-for-Chrome/blob/fdf01805a82bc53f13537c96937fef862f1fe9af/library/libraries.js#L80
const detectJSBootstrapOlderVersions = async (win) => {
// This is only compatible with Firefox
const jQueryObj = win.wrappedJSObject.$;
const version = jQueryObj
&& jQueryObj.fn
&& jQueryObj.fn.dropdown
&& jQueryObj.fn.dropdown.Constructor
&& jQueryObj.fn.dropdown.Constructor.VERSION;
if (version) {
XPCNativeWrapper(win.wrappedJSObject.$);
return [version];
}
const bannerVersions = await detectJSBootstrapBannerVersions(win);
if (bannerVersions.length > 0) {
XPCNativeWrapper(win.wrappedJSObject.$);
return bannerVersions;
}
const inferedVersion = jQueryObj
&& jQueryObj.fn
&& jQueryObj.fn.dropdown
&& jQueryObj.fn.dropdown.toString().search(/\|\|c\.data/) >= 0
&& '3'
|| jQueryObj
&& jQueryObj.fn
&& jQueryObj.fn.dropdown
&& jQueryObj.fn.dropdown.toString().search('Dropdown') >= 0
&& '2'
|| jQueryObj
&& jQueryObj.fn
&& jQueryObj.fn.dropdown
&& jQueryObj.fn.dropdown.toString().search('delegate') >= 0
&& '1';
XPCNativeWrapper(win.wrappedJSObject.$);
return [inferedVersion];
};
const detectCSSBootstrapBannerVersions = async (win) => {
const bannerVersions = await asyncFlatMap(
Array.from(win.document.styleSheets),
async (stylesheet) => {
const cssText = await getStylesheetText(stylesheet);
const matches = cssText.match(/^\s*\* Bootstrap v([0-9.]+)/m);
if (matches) {
return [matches[1]];
}
return [];
},
);
return bannerVersions;
};
const detectCSSBootstrapVersions = async (win) => {
const bannerVersions = await detectCSSBootstrapBannerVersions(win);
if (bannerVersions.length > 0) {
return bannerVersions;
}
// If no Bootstrap banner has been found, try to fingerprint the stylesheets
// Subselectors must be present in stylesheet top-level rules
const VERSION_SUBSELECTORS = [
{
version: '5',
subselectors: ['.btn-primary', '.bs-tooltip-auto', '.offcanvas'],
},
{
version: '4',
subselectors: ['.btn-primary', '.bs-tooltip-auto', '.custom-file'],
},
{
// Version 3 could be heavily customized and is hard to detect this way
version: '3',
subselectors: ['.btn-primary', '.navbar-default'],
},
{
version: '2',
subselectors: ['.btn-primary', '.icon-star', '.row-fluid'],
},
{
version: '1',
subselectors: ['.btn.primary', '.twipsy'],
},
];
const versions = await asyncFlatMap(
Array.from(win.document.styleSheets),
async (stylesheet) => {
const cssRules = await getStylesheetCSSRules(stylesheet);
return VERSION_SUBSELECTORS.flatMap(({ version, subselectors }) => {
if (subselectors.every((subselector) =>
cssRules.some((rule) =>
typeof rule.selectorText === 'string'
&& rule.selectorText.search(subselector) >= 0
)
)) {
return [version];
}
return [];
});
},
);
return versions;
};
const detectComponents = (win) => {
return COMPONENT_SELECTORS.flatMap(({ component, selector }) => {
const elements = Array.from(win.document.querySelectorAll(selector));
return {
component,
count: elements.length,
};
});
};
const runDetection = async (win) => {
const jsVersions = (await detectJSBootstrapOlderVersions(win))
.concat(detectJSBootstrap5Versions(win))
.filter((v) => typeof v !== 'undefined');
return {
frameworkJSVersions: {
bootstrap: Array.from(new Set(jsVersions)).sort(),
},
frameworkCSSVersions: {
bootstrap: Array.from(await detectCSSBootstrapVersions(win)).sort(),
},
components: detectComponents(win),
};
};
let alreadyRunAnalysis = false;
const analyse = async () => {
if (alreadyRunAnalysis) {
return;
}
const detectionData = await runDetection(window);
console.log(detectionData);
const JSON_ID = 'js-ui-framework-detection';
const dataElement = document.createElement('script');
dataElement.textContent = JSON.stringify(detectionData);
dataElement.id = JSON_ID;
dataElement.type = 'application/json';
(document.head || document.documentElement).appendChild(dataElement);
browser.runtime.sendMessage({
inspectionData: true,
detectionData,
});
alreadyRunAnalysis = true;
};
window.addEventListener('load', analyse);
setTimeout(analyse, 3000);