/**
Copyright: 2024 Contrast Security, Inc
  Contact: support@contrastsecurity.com
  License: Commercial

  NOTICE: This Software and the patented inventions embodied within may only be
  used as part of Contrast Security’s commercial offerings. Even though it is
  made available through public repositories, use of this Software is subject to
  the applicable End User Licensing Agreement found at
  https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
  between Contrast Security and the End User. The Software may not be reverse
  engineered, modified, repackaged, sold, redistributed or otherwise used in a
  way not consistent with the End User License Agreement.
*/
'use strict';

const parseurl = require('parseurl');
const agentEmitter = require('../../agent-emitter');
const { AsyncStorage, KEYS } = require('../../core/async-storage');
const logger = require('../../core/logger')('contrast:assess:sources');
const { EXCLUSION_INPUT_TYPES } = require('../../constants');
const SourceMembrane = require('../membrane/source-membrane');
const { Signature } = require('../models');
const { SourceEventHandler } = require('./event-handler');
const FormidableSource = require('./formidable');

/**
 * Shared logging for when URL/Input exclusions thwart handling of sources.
 * @param {string} type the source type e.g. BODY, PARAMETER, HEADER
 */
const logExclusionMessage = (type, exclusion) => {
  const { name } = exclusion;
  logger.debug(
    'excluding %s inputs from all assess rules (%s)',
    type.toLowerCase(),
    name
  );
};

module.exports = {
  /**
   * Registers sources for assess instrumentation
   */
  init(agent) {
    new FormidableSource(agent);
  },

  /**
   * @param {string} type name of source type (headers, params, etc)
   * @param {Object} parent object to watch property on, e.g., req
   * @param {string} key name of property (for req.query, 'query')
   * @param {Membrane} membrane used in testing to provide a custom membrane instance
   */
  track(type, parent, key, membrane) {
    const object = parent[key];
    if (!object) {
      return;
    }
    const metadata = Object.create(null);
    metadata.sourceType = type;

    // We set this flag for string properties that are direct properties
    // of the request object. Unlike parsed querystring values like
    // { input: 'userInput' } these properties have an empty path, but still
    // need to be tracked
    metadata.rootLevelString = false;

    if (key === 'originalUrl') {
      metadata.rootLevelString = true;
    }

    parent[key] = membrane.wrap(object, metadata);
  },

  /**
   * Chooses a strategy for tracking the source events
   * @param {any} config Current configuration for the agent
   * @param {Logger} logger A logger instance
   * @returns {Boolean} whether lazy tracking is enabled or not
   */
  getLazyTrackingConfig(config, logger) {
    if (config._default['agent.traverse_and_track']) {
      return config.assess.enable_lazy_tracking;
    }

    if (config._default['assess.enable_lazy_tracking']) {
      logger.error(
        "agent.traverse_and_track option is deprecated. Please use assess.enable_lazy_tracking from now on. It's value should be the opposite of this one"
      );
      return !config.agent.traverse_and_track;
    }

    logger.error(
      'Conflicting options set: `agent.traverse_and_track` and `assess.enable_lazy_tracking`. `agent.traverse_and_track` is deprecated, so `assess.enable_lazy_tracking` takes precedence'
    );
    return config.assess.enable_lazy_tracking;
  },

  /**
   * Registers an event to add URL and input exclusions to async storage if they
   * pertain to the current request path. Also registers all the source events
   * that are emitted in the framework instrumentation sources. This wraps an
   * object in a membrane
   */
  registerListeners({ config, exclusions }) {
    const isLazyTrackingEnabled = this.getLazyTrackingConfig(config, logger);

    const handleAssessEvent = (event, type) => {
      agentEmitter.on(event, (obj, prop) => {
        if (isLazyTrackingEnabled) {
          return this.handleSourceEvent(config, exclusions, type, obj, prop);
        }

        const signature = new Signature({
          moduleName: 'Object',
          methodName: 'getter',
          args: [prop],
          return: 'String',
          isModule: false,
        });

        new SourceEventHandler({ config, exclusions, signature, type }).handle({
          obj,
          prop,
        });
      });
    };

    handleAssessEvent('assess.url', EXCLUSION_INPUT_TYPES.PARAMETER);
    handleAssessEvent('assess.body', EXCLUSION_INPUT_TYPES.BODY);
    handleAssessEvent('assess.headers', EXCLUSION_INPUT_TYPES.HEADER);
    handleAssessEvent('assess.params', EXCLUSION_INPUT_TYPES.PARAMETER);
    handleAssessEvent('assess.query', EXCLUSION_INPUT_TYPES.QUERYSTRING);
    handleAssessEvent('assess.cookies', EXCLUSION_INPUT_TYPES.COOKIE);

    // might be helpful for clients to send add'l values in event arg
    //   - stackOpts: elide frames from function ref in client instrumentation
    //   - signature: rather than create shared one in the handler
    //   - or stack snapshot function - could share among SourceEvents
    //   - call context to share among SourceEvents
    agentEmitter.on('assess.source', ({ obj, prop, type, signature }) => {
      if (!signature) {
        signature = new Signature({
          moduleName: 'Object',
          methodName: 'getter',
          args: [prop],
          return: 'String',
          isModule: false,
        });
      }

      new SourceEventHandler({
        config,
        exclusions,
        signature,
        type,
        stackOpts: undefined,
        snapshot: undefined,
      }).handle({ obj, prop });
    });
  },

  /**
   * Checks whether exclusions settings should prevent wrapping of the untrusted
   * data in membrane. If we should wrap, we create a membrane and provide it with
   * active input exclusions to modify onString behaviors.
   * @param {object} config agent config used to create membrane
   * @param {ExclusionFactory} exclusions exclusions applicable to current req url
   * @param {string} type source type
   * @param {*} obj proxy target
   * @param {string} prop property name
   */
  handleSourceEvent(config, exclusions, type, obj, prop) {
    const req = AsyncStorage.get(KEYS.REQ);
    // NODE-1431: prevents crashing when req is not present in AsyncStorage.
    if (!req) {
      logger.error(
        'failed to handle source event for type: %s, property: %s; req not present in async storage',
        type,
        prop
      );
      return;
    }

    const { pathname } = parseurl(req);
    const urlExclusions = exclusions
      .getUrlExclusions('assess')
      .filter((e) => e.matchesUrl(pathname));
    const inputExclusions = exclusions
      .getInputExclusions('assess')
      .filter((e) => e.matchesUrl(pathname) && e.appliesToInputType(type));

    if (
      this.exclusionSourceEventFilter({
        type,
        urlExclusions,
        inputExclusions,
      })
    ) {
      return;
    }

    const sourceParams = {
      type,
      object: obj[prop],
      constructorOpt: agentEmitter.emit,
      inputExclusions,
    };

    const membrane = new SourceMembrane(config, sourceParams);
    this.track(type.toLowerCase(), obj, prop, membrane);
  },

  /**
   * Event filter function returns `false` if source event shouldn't be published.
   * If there is a URL Exclusion for all rules, then don't bother tracking inputs.
   * Otherwise, we have logic in TSReporter to skip reporting for URL Exclusions
   * for specific rules.
   * And some Input Exclusions apply broadly to the type. If they also apply to
   * all rules we can skip the source event outright.
   * @param {object} params
   * @param {string} params.type
   * @param {object[]} params.urlExclusions
   * @param {object[]} params.inputExclusions
   * @returns {boolean}
   */
  exclusionSourceEventFilter({ type, urlExclusions, inputExclusions }) {
    const urlMatch = urlExclusions.find((e) => e.appliesToAllAssessRules());
    if (urlMatch) {
      logExclusionMessage(type, urlMatch);
      return true;
    }
    // `isNamed` is false for exclusion types such as BODY and QUERYSTRING which
    // apply to the entire set of params not only those by a specified name. Also
    // is true if the input name is set to wildcard "*" meaning it should apply to
    // all values. In each case there's no need to wrap if all rules are excluded.
    const inputMatch = inputExclusions.find(
      (e) => e.appliesToAllAssessRules() && !e.isNamed
    );
    if (inputMatch) {
      logExclusionMessage(type, inputMatch);
      return true;
    }

    return false;
  },
};
