/* * 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);