Mentions légales du service

Skip to content
Snippets Groups Projects
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);