/**
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 _ = require('lodash');
const util = require('util');

const logger = require('../../../core/logger')('contrast:rules:protect');
const { INPUT_TYPES, SINK_TYPES } = require('../common');
const AsyncStorage = require('../../../core/async-storage');
const constants = require('../../../constants');
const { traverse } = require('../../../util/traverse');

const brackets = /\[(.{1,1024}?)\]/;
const child = /\[(.{1,1024}?)\]/g;

const MONGODB = 'mongodb';
const RETHINKDB = 'rethinkdb';

const ScannerKit = new Map([
  [MONGODB, () => require('../nosqli/nosql-scanner').create('MongoDB')],
  [RETHINKDB, () => require('../nosqli/nosql-scanner').create('RethinkDB')]
]);

class NoSqlInjectionRule extends require('../') {
  // eslint-disable-next-line default-param-last
  constructor(policy = {}, agent) {
    policy.inputParseDepth = 3;
    super(policy, agent);

    this._scanners = new Map();

    this.id = 'nosql-injection';
    this.name = 'NoSQL Injection';
    this.applicableInputs = [
      INPUT_TYPES.BODY,
      INPUT_TYPES.JSON_VALUE,
      INPUT_TYPES.JSON_ARRAYED_VALUE,
      INPUT_TYPES.PARAMETER_NAME,
      INPUT_TYPES.PARAMETER_VALUE,
      INPUT_TYPES.QUERYSTRING,
      INPUT_TYPES.XML_VALUE,
      INPUT_TYPES.URI,
      INPUT_TYPES.URL_PARAMETER,
      INPUT_TYPES.BODY,
      INPUT_TYPES.MULTIPART_VALUE,
    ];

    // POST bodies are handled by the lib.
    // if lib is disabled, node input analysis needs to handle this.
    if (!this.agentLibEnabled) {
      this.applicableInputs.push(INPUT_TYPES.BODY);
    }

    this.applicableSinks = [SINK_TYPES.NOSQL_QUERY];

    // explicitly set to false so that this rule will continue to use node input analysis.
    this.usesLibInputAnalysis = false;
  }

  /**
   * Evaluates the sink data by scanning the document object for repeated instances
   * of the saved input object/strings. This applies to both string injection and
   * object expansion.
   * @param {SinkEvent} event             Emitted by database drivers/wrappers/ORMs
   * @param {Set}       applicableSamples set of Samples applicable to rule.
   */
  // eslint-disable-next-line complexity
  evaluateAtSink({ event, applicableSamples }) {
    if (applicableSamples.size == 0 || !event.data) {
      return;
    }

    const { data } = event;
    if (typeof data === 'object') {
      for (const sample of applicableSamples) {
        const doc = this.getInput(sample);
        if (!doc) {
          return;
        }

        let evalResult = null;
        traverse(data, (key, value) => {
          if (evalResult) {
            return;
          }

          // check for _exact_ match under clause for saved user input
          for (const queryClause of Object.keys(doc)) {
            if (key === queryClause) {
              if (_.isEqual(value, doc[queryClause])) {
                evalResult = this.buildFinding(data, queryClause, doc);
              }
            }
          }
        });

        if (evalResult) {
          this.appendAttackDetails(sample, evalResult);
          sample.captureAppContext(event);
          logger.warn(`EFFECTIVE - rule: ${this.id}, mode: ${this.mode}`);
          this.blockRequest(sample);
        }
      }
    } else if (typeof event.data === 'string' || event.data instanceof String) {
      const scanner = this.getScanner(event.id);

      for (const sample of applicableSamples) {
        const injection = scanner.findInjection(sample.input.value, event.data);

        if (injection) {
          this.appendAttackDetails(sample, injection);
          sample.captureAppContext(event);
          logger.warn(`EFFECTIVE - rule: ${this.id}, mode: ${this.mode} `);
          this.blockRequest(sample);
        }
      }
    }
  }

  /**
   * Depending on the sample's data, collect the query clause and object
   * for a given input sample. Handles both body inputs and other types.
   * If the library is enabled and the input is a JSON body, it will parse
   * query clauses out and pass them in _inputInfoForSink.
   * @param   {Sample} sample applicable sample for nosqli
   * @returns {Object}        relevant query clause and doc object to search for.
   */
  getInput(sample) {
    if (!_.isEmpty(sample._inputInfoForSink)) {
      return {
        [sample._inputInfoForSink.queryClause]: sample._inputInfoForSink.docObject
      };
    }

    const reqData = this.getRequestData(sample.input);

    return reqData;
  }

  /**
   * Build the query object based on the inputs passed and stringify them for reporting.
   * @param {String} queryClause the mongo query clause (ex: $ne, $where, etc.)
   * @param {Object} docObject   the document object passed to the mongo function.
   */
  buildFinding(queryObject, queryClause, docObject) {
    // this input string being generated is kinda duplicate work from the input stage
    // but it is only really hit when a nosqli is found, so I'm not sure its a big deal.
    const input = util.inspect(docObject, false, null);
    const query = util.inspect(queryObject, false, null);
    // this substring being generated is some duplicate work.
    const start = query.indexOf(input);
    return {
      input,
      // account for query clause, ':' and ' '.
      start: start - queryClause.length - 2,
      end: start + (queryClause.length + 2),
      query
    };
  }

  /**
   * Given the sample's user input object, will read the value from the request
   * from the async storage context.
   * @param {string} _documentPath
   * @param {string} _documentType
   */
  getRequestData({ _documentPath, _documentType }) {
    const ctx = AsyncStorage.getContext();

    if (!ctx) {
      logger.info(
        'unable to perform nosql-expansion sink analysis: async storage context not available'
      );
      return;
    }

    const pathArray = [];

    if (!_documentPath) {
      return pathArray;
    }

    if (
      _documentType === constants.INPUT_TYPES.PARAMETER_NAME &&
      _documentPath.length <= 1024
    ) {
      this.buildPathArray(_documentPath, pathArray);
    } else {
      // only qs params and body are "expandable"
      pathArray.push('query', ..._documentPath.split('.'));
    }

    let data;
    let curr = ctx.request;

    for (const path of pathArray) {
      if (curr) {
        data = curr[path];
        curr = data;
      }
    }
    return data;
  }

  buildPathArray(documentPath, pathArray) {
    const documentPathArray = documentPath.split('.');

    pathArray.push('query');
    documentPathArray.forEach((dotSegment) => {
      let lastChar;
      let bracketSegment = brackets.exec(dotSegment);
      let parent = bracketSegment
        ? dotSegment.slice(0, bracketSegment.index)
        : dotSegment;
      if (parent) {
        lastChar = parent.slice(-1);
        parent = lastChar === ']' ? parent.slice(0, -1) : parent;
        pathArray.push(parent);
      }
      while ((bracketSegment = child.exec(dotSegment))) {
        lastChar = bracketSegment[1].slice(-1);
        const segment =
          lastChar === ']' ? bracketSegment[1].slice(0, -1) : bracketSegment[1];
        pathArray.push(segment);
      }
    });
    pathArray.pop();
  }

  getScanner(id) {
    if (!ScannerKit.has(id)) {
      throw new Error(`Unknown NoSQL scanner: ${id}`);
    }

    if (!this._scanners.has(id)) {
      this._scanners.set(id, ScannerKit.get(id)());
    }

    return this._scanners.get(id);
  }

  /**
   * Builds details for NoSQL Injection Attack.
   * @param   {UserInput} inputDtm The user input that resulted in attack
   * @param   {String}    query    The query that was analyzed
   * @param   {Object}    results  The repsults of the sql-scanner
   * @returns {Object}             The details
   */
  buildDetails(sample, findings) {
    if (!findings) {
      return null;
    }

    // if it's not the old node format, then it's the agent-lib format.
    // yeah, i know it's ugly.
    if (!findings.boundary) {
      const { start, end, query } = findings;
      return {
        start,
        end,
        // since there are no actual `token boundary overruns' in nosqli,
        // are these not the exact same as start and end?
        boundaryOverrunIndex: end,
        inputBoundaryIndex: start,
        query
      };
    }

    const { boundary, location, query } = findings;
    let inputBoundaryIndex, boundaryOverrunIndex;
    const start = location[0]; // eslint-disable-line
    const end = location[1] + 1;

    if (boundary) {
      inputBoundaryIndex = boundary.previous
        ? boundary.previous.start
        : boundary.start;
      boundaryOverrunIndex = boundary.stop + 1;
    } else {
      inputBoundaryIndex = start;
      boundaryOverrunIndex = end;
    }

    return {
      start,
      end,
      input: sample.input.toSerializable(),
      boundaryOverrunIndex,
      inputBoundaryIndex,
      query
    };
  }
}

module.exports = NoSqlInjectionRule;
