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

/**
 * Common module for reporting to teamserver and checking vulnerability
 * of a given input for abstract rules.
 */
const _ = require('lodash');
const agentEmitter = require('../../agent-emitter');
const { makeBitset } = require('../../util/bitset');
const traceUtil = require('../../util/trace-util');
const tracker = require('../../tracker');
const logger = require('../../core/logger')('contrast:rules:sinks');
const { traverse } = require('../../util/traverse');
const { isString } = require('../../util/is-string');
const { tagRangesExcludeRuleId } = require('../models/tag-range/util');
const {
  RULE_TYPES: { DATAFLOW },
  SAFE_XSS_CONTENT_TYPES
} = require('../../constants');
const { AsyncStorage, KEYS } = require('../../core/async-storage');
const { Signature, CallContext, SinkEvent } = require('../models');
const { loadProvider, isRuleEnabled } = require('../policy/util');
const stackFactory = require('../../core/stacktrace').singleton;
const safeContentTypeRegex = new RegExp(SAFE_XSS_CONTENT_TYPES.join('|'));

module.exports = (agent) => {
  const common = {};
  /**
   * checks to make sure sink definitions have all required fields
   *
   * @param {sink} sink Sink policy definition from JSON
   */
  common.isValidSinkDefinition = function(sink) {
    if (!sink.type) {
      return false;
    }

    return true;
  };

  /**
   * report a vulnerability if it's found.
   *
   * @param {string}      ruleId    - the ID of the rule.
   * @param {Signature}   signature - the signature being sent off.
   * @param {string}      input    -  vulnerable input
   * @param {CallContext} ctxt      - The CallContext used in building the event to report
   */
  common.report = function({
    ruleId,
    signature,
    input,
    ctxt,
    sinkType = DATAFLOW
  }) {
    let tagRanges = [],
      event;

    const request = traceUtil.getRequest(agent, ruleId);
    if (!request) {
      return;
    }

    // get the tags, event from dataflow actions ONLY
    if (sinkType === DATAFLOW) {
      const contrastProps = tracker.getData(input);
      if (contrastProps) {
        ({ tagRanges, event } = contrastProps);
      }
    }

    const sink = new SinkEvent({
      context: ctxt,
      signature,
      source: 'P',
      tagRanges
    });
    if (event) {
      sink.parents.push(event);
    }

    common.emitFinding({
      ruleId,
      stack: ctxt.stack,
      request,
      event: sink
    });
  };

  /**
   * Creates a hash and builds the finding object with supplied keys
   * @param {Object} params
   * @param {String} params.ruleId id of rule
   * @param {Object} params.request IncomingMessage
   * @param {Array} params.event the sink event
   * @param {Object} param.props properties from the finding
   * @param {String} params.attribute attr from finding
   */
  common.emitFinding = function({ ruleId, request, event, props, attribute }) {
    if (!isRuleEnabled(ruleId)) {
      return;
    }

    // favor normalizedUri over uri when present
    // this cuts down on duplicate findings for parameterized routes
    // see spec: https://bitbucket.org/contrastsecurity/assess-specifications/src/master/vulnerability/preflight.md
    const url = request.normalizedUri || request.uri;
    const hash = traceUtil.createHash([ruleId, url, request.method]);

    const evidence = {
      ruleId,
      hash,
      request,
      event
    };

    if (props) {
      evidence.props = props;
    }

    if (attribute) {
      evidence.attribute = attribute;
    }

    logger.debug('%s: %s', ruleId, request.uri);
    agentEmitter.emit('finding', evidence);
  };

  common.isVulnerable = function({
    input,
    exclusiveKeys,
    searchDepth = 0,
    disallowedTags,
    requiredTags,
    ruleId
  }) {
    if (ruleId === 'reflected-xss' && isSafeContentType()) {
      return false;
    }

    return common.findVulnerableString({
      input,
      exclusiveKeys,
      searchDepth,
      disallowedTags,
      requiredTags,
      ruleId
    });
  };

  /**
   * Given a set of criteria, is a request vulnerable?
   * @param {object} params
   * @param {string} params.input the value to check if it is a string and vulnerable
   * @param {array} params.disallowedTags tags you should NOT see in the result.
   * @param {array} params.requiredTags tags you MUST see in the result.
   * @param {array} params.exclusiveKeys keys to check on an object for vulnerable strings
   * @param {int} [params.searchDepth=0] how many layers should you check in an object/array
   * @returns {*} vulnerable string or false indicating no vulnerable string
   */
  common.findVulnerableString = function({
    input,
    exclusiveKeys,
    searchDepth = 0,
    disallowedTags,
    requiredTags,
    ruleId
  }) {
    let found = false;
    traverse(
      input,
      (key, value, depth) => {
        if (
          !found &&
          isString(value) &&
          (!exclusiveKeys || exclusiveKeys.includes(key))
        ) {
          const trackData = tracker.getData(value);

          if (!trackData) {
            return;
          }

          const { tagRanges } = trackData;

          if (tagRangesExcludeRuleId(ruleId, tagRanges)) {
            return;
          }

          // go through an entire string and find where there is requiredTags and no disallowedTags
          const bitset = makeBitset({
            value,
            tagRanges,
            disallowedTags,
            requiredTags
          });
          // finds an overlap of a requiredTag and disallowedTag
          if (bitset.indexOf('1') !== -1) {
            // FIXME when we find a bad value and the input is an object. we aren't reporting the object context just the value of a key
            found = value;
          }
        }
      },
      searchDepth
    );

    return found;
  };

  /**
   * Reduces conditions from isVulnerable check to one value
   * depending on the mode
   */
  function reduceConditions(conditions, mode = 'and') {
    if (conditions.length === 1) {
      return conditions[0];
    }
    return conditions.reduce(function(prev, curr) {
      switch (mode) {
        case 'and':
          return prev && curr;
        case 'or':
          return prev || curr;
        default:
          return prev && curr;
      }
    });
  }

  /**
   * Checks if content is one of the "safe" content types for a reflected-xss
   * rule evaluation
   */
  function isSafeContentType() {
    const contentType = AsyncStorage.get(KEYS.RESPONSE_CONTENT_TYPE);
    return safeContentTypeRegex.test(contentType);
  }

  /**
   * Traverses call stack to see if there is a
   * trusted library, which means we trust the source
   * and will not report a finding
   *
   * @param {Array} stackTrustedLibs array of strings that are "trusted"
   * @param {Array} stack call stack
   */
  function containsTrustedLib(stackTrustedLibs, stack) {
    for (let stackIndex = 0; stackIndex < stack.length; stackIndex++) {
      const { file } = stack[stackIndex];
      for (let index = 0; index < stackTrustedLibs.length; index++) {
        if (file && !!file.match(new RegExp(stackTrustedLibs[index]))) {
          return true;
        }
      }
    }
  }

  /**
   *  Returns a function that checks for string vulnerability
   *  @param {Object} params
   *  @param {Object} params.sink the policy for the sink
   *  @return {Function} isVulnerable *see desc below*
   */
  common.nonDataflowVulnerableCheck = ({ sink }) => {
    const { stackTrustedLibs, conditions } = sink;

    /**
     * Checks for non dataflow sinks
     *
     *  - if stack contains a trusted lib(return false)
     *  - if a fn arg is in not allowlist or allowlist regex
     *
     *  Note: It's assumed a string is vulnerable, the checks above attempt to disprove that
     *  @param {Object} stack call stack
     *  @param {Object} result result of sink execution
     *  @param {Object} args arguments passed to sink
     *  @return {Array|Boolean} If a string within sink is considered vulnerable
     */
    return function isVulnerable(stack, result, args) {
      if (stackTrustedLibs && containsTrustedLib(stackTrustedLibs, stack)) {
        return false;
      }

      // There was not a policy for a given sink, we assume it is vulnerable
      if (!(conditions && conditions.args)) {
        return true;
      }

      return conditions.args.map(({ index = 0, allowlist, allowlistRegex }) => {
        if (index < 0) {
          index += args.length;
        }

        const input = args[index];
        const matchesAllowlistRegex = allowlistRegex
            ? input.match(allowlistRegex)
            : false,
          isInAllowlist = allowlist ? allowlist.indexOf(input) !== -1 : false;

        return !(matchesAllowlistRegex || isInAllowlist);
      });
    };
  };

  /**
   *  Returns a function that checks for string vulnerability
   *  @param {Object} params
   *  @param {Object} params.sink the policy for the sink
   *  @return {Function} isVulnerable *see desc below*
   */
  common.dataflowVulnerableCheck = ({ sink }) => {
    const { stackTrustedLibs, conditions, handler } = sink;

    /**
     * Checks for dataflow sinks
     *  - if a fn arg has an index where a requiredTag and disallowedTag do not overlap
     *
     *  Note: It's assumed a string is vulnerable, the checks above attempt to disprove that
     *  @param {Object} stack call stack
     *  @param {Object} result result of sink execution
     *  @param {Object} args arguments passed to sink
     *  @return {Array|Boolean} If a string within sink is considered vulnerable
     */
    return function isVulnerable(stack, result, args) {
      const ruleId = _.get(handler, 'id');

      if (stackTrustedLibs && containsTrustedLib(stackTrustedLibs, stack)) {
        return false;
      }

      // There was not a policy for a given sink, we assume it is vulnerable
      if (!(conditions && conditions.args)) {
        return args;
      }

      const met = conditions.args.map(
        ({
          index = 0,
          provider,
          requiredTags,
          depth: searchDepth,
          disallowedTags,
          exclusiveKeys
        }) => {
          if (index < 0) {
            index += args.length;
          }

          const input = args[index];

          // no string or rules to check, bailing
          if (!input || !requiredTags) {
            return false;
          }

          // allow to run a custom provider on just one of the args
          // otherwise run the common check for keys
          return provider
            ? loadProvider(provider)({ agent, common }).handle({
                input,
                requiredTags,
                disallowedTags
              })
            : common.isVulnerable({
                searchDepth,
                exclusiveKeys,
                input,
                disallowedTags,
                requiredTags,
                ruleId
              });
        }
      );

      return met;
    };
  };

  /**
   * Creates a sink handler for config based sinks.
   * Depending on if the sinkType is dataflow or nonDataflow a
   * function for isVulnerable will be assigned
   *
   * @param {Object} signature of sink
   * @param {Object} sink configuration for a given sink
   * @param {String} ruleId applicable rule for sink
   */
  common.createSinkHandler = (signature, sink, ruleId) => {
    const { conditions } = sink;
    const sinkType = sink.type;

    /**
     * @see common.dataflowVulnerableCheck or common.nonDataflowVulnerableCheck
     * @param {Object} stack call stack
     * @param {Object} result result of sink execution
     * @param {Object} args arguments passed to sink
     * @return {Boolean} If a string within sink is considered vulnerable
     */
    const isVulnerable = function isVulnerable(stack, result, args) {
      const isVulnCheck =
        sinkType === DATAFLOW
          ? common.dataflowVulnerableCheck({ sink })
          : common.nonDataflowVulnerableCheck({ sink });

      const checkedArgs = isVulnCheck(stack, result, args);
      if (Array.isArray(checkedArgs) && checkedArgs.length > 0) {
        const mode = conditions ? conditions.mode : 'and';
        return reduceConditions(checkedArgs, mode);
      } else {
        return checkedArgs;
      }
    };

    /**
     * Function that is executed in post phase of a hook
     * If we are in a request context and it's vulnerable,
     * it will report a finding
     * @param {Object} params
     * @param {Object} params.result return value of the function
     * @param {Array} params.args params passed to function
     * @param {Object} params.obj reference to this
     */
    return function handler(data) {
      const { result: value, args } = data;
      // this will exist if the function that is hooked
      // is some lower level abstracted method
      // here we will pull the method out of the fn args
      // and replace it on the signature. the assumption
      // is the string to replace is always the last position
      // eslint-disable-next-line no-prototype-builtins
      if (signature.hasOwnProperty('userMethodPosition')) {
        const method = args.splice(signature.userMethodPosition, 1).join();
        const methodArray = signature.methodName.split('.');
        methodArray.splice(-1, 1, method);
        signature.methodName = methodArray.join('.');
      }

      let vulnerableString;
      let stack;

      if (sink.stackTrustedLibs) {
        stack = stackFactory.createSnapshot({
          constructorOpt: data.hooked
        })();
        vulnerableString = isVulnerable(stack, value, args);
      } else {
        vulnerableString = isVulnerable(null, value, args);
      }

      if (vulnerableString) {
        const ctxt = CallContext.create(data);
        // If we had to create a stacktrace already, use it rather than build
        // another during reporting
        if (stack) {
          ctxt.stack = stack;
        }

        common.report({
          ruleId,
          signature: new Signature(signature),
          input: vulnerableString,
          ctxt,
          sinkType
        });
      }
    };
  };

  common.xss = {
    disallowedTags: [
      'alphanum-space-hyphen',
      'cookie',
      'header',
      'limited-chars',
      'html-encoded',
      'sql-encoded',
      'url-encoded',
      'weak-url-encoded'
    ]
  };

  common.requiredTags = ['untrusted'];

  return common;
};

module.exports.xss = {
  disallowedTags: [
    'alphanum-space-hyphen',
    'cookie',
    'header',
    'limited-chars',
    'html-encoded',
    'sql-encoded',
    'url-encoded',
    'weak-url-encoded'
  ]
};
module.exports.requiredTags = ['untrusted'];
