/**
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 logger = require('../../../core/logger')('contrast:rules:protect');
const { IMPORTANCE, INPUT_TYPES, SINK_TYPES } = require('../common');
const isGenericComplicated = require('./generic-complicated');

const flavors = {
  MYSQL: 'mysql',
  POSTGRES: 'postgres',
  SQLITE: 'sqlite',
  OTHER: 'other'
};

const ScannerKit = new Map([
  [flavors.MYSQL, () => require('../sqli/sql-scanner').create('MySQL')],
  [
    flavors.POSTGRES,
    () => new (require('../base-scanner/postgresqlscanner'))()
  ],
  [flavors.SQLITE, () => new (require('../base-scanner').BaseScanner)()],
  [flavors.OTHER, () => new (require('../base-scanner').BaseScanner)()]
]);

class SQLInjectionRule extends require('../') {
  constructor(policy, agent) {
    super(policy, agent);

    this._scanners = new Map();

    this.id = 'sql-injection';
    this.name = 'SQL Injection';
    this.applicableInputs = [
      INPUT_TYPES.BODY,
      INPUT_TYPES.COOKIE_NAME,
      INPUT_TYPES.COOKIE_VALUE,
      INPUT_TYPES.HEADER,
      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,
    ];
    this.applicableSinks = [SINK_TYPES.SQL_QUERY];

    // if not using agentLib this constructor is done.
    if (!agent.agentLib) {
      return;
    }

    this.dbFlavors = {};
    for (const flavor in agent.agentLib.DbType) {
      this.dbFlavors[flavor.toLowerCase()] = this.agent.agentLib.DbType[flavor];
    }
    this.dbFlavorKeys = Object.keys(this.dbFlavors);

    this.usesLibInputAnalysis = true;
  }

  /**
   * Evaluates the sink data by scanning the code string for injections
   * by collected sample inputs.
   * @param {SinkEvent} event Emitted by database drivers/wrappers/ORMs
   * @param {Set(Sample)} applicableSamples Samples matching sql-injection criteria
   */
  evaluateAtSink({ event, applicableSamples }) {
    if (_.isEmpty(applicableSamples) || !event.data) {
      return;
    }

    const dialect = SQLInjectionRule.resolveDialect(event);
    const scanner = this.getScanner(dialect);
    const { data } = event;

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

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

  /**
   * Evaluates the sink data by scanning the code string for injections
   * by collected sample inputs. Rather than using the node evaluators,
   * this takes advantage of the agent-lib.
   * @param {SinkEvent} event Emitted by database drivers/wrappers/ORMs
   * @param {Sample[]}  applicableSamples samples that apply to SQL injection.
   */
  // eslint-disable-next-line complexity
  evaluateAtSinkForLib({ event, applicableSamples }) {
    if (applicableSamples.size == 0 || !event.data) {
      return;
    }

    const tag = this.dbFlavorKeys.find((name) => event.tags.has(name));
    const dialect = this.dbFlavors[tag] || this.dbFlavors.mysql;

    for (const sample of applicableSamples) {
      let evalResult = null;
      try {
        const input = sample.input.value;
        const sinkData = event.data;
        const inputIndex = sinkData.indexOf(input);

        if (inputIndex !== -1) {
          evalResult = this.agent.agentLib.checkSqlInjectionSink(
            inputIndex,
            input.length,
            dialect,
            sinkData
          );
          if (evalResult) {
            // capture the query for reporting purposes.
            evalResult.query = sinkData;
          } else if (inputIndex === 0 && input.length === sinkData.length) {
            evalResult = {
              startIndex: 0,
              endIndex: input.length - 1,
              overrunIndex: 0,
              boundaryIndex: 0,
              sinkData,
            };
          }
        }
      } catch (e) {
        logger.info(`Failed to evaluate command-injection sink: ${e}`);
      }

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

  /**
   * In addition to using the traditional signature matching, this rule will
   * also check whether the input has other characteristics that warrant bumping
   * an evaluation's importance from NONE -> WORTH_WATCHING.
   * @returns {}
   */
  getEvaluator() {
    const { signature } = this;

    return {
      evaluate(userInput) {
        const assessment = signature.evaluate(userInput);

        // Additional checks for significance:
        if (
          assessment.results.importance === IMPORTANCE.NONE &&
          SQLInjectionRule.checkGenericComplicated(userInput, assessment)
        ) {
          assessment.results.importance = IMPORTANCE.WORTH_WATCHING;
          assessment.searchers.ids.push(isGenericComplicated.ID);
        }

        return assessment;
      }
    };
  }

  /**
   * Builds details for Sql Injection Attack.
   * @param   {Sample} sample   The Sample for the attack
   * @param   {Object} findings The results of the sink analysis
   * @returns {Object}          The details
   */
  buildDetails(sample, findings) {
    if (!findings) {
      return null;
    }

    const { boundary, query, location } = findings;
    const inputBoundaryIndex = boundary.previous
      ? boundary.previous.start
      : boundary.start;

    return {
      start: location[0],
      end: location[1] + 1,
      input: sample.input.toSerializable(),
      boundaryOverrunIndex: boundary.stop + 1,
      inputBoundaryIndex,
      query
    };
  }

  /**
   * Builds details for Sql Injection Attack from agent-lib evaluation
   * results.
   * @param   {Sample} sample   The Sample for the attack
   * @param   {Object} findings The results of the sink analysis
   * @returns {Object}          The details
   */
  buildDetailsForLib(sample, finding) {
    if (!finding) {
      return null;
    }

    const { query } = finding;

    return {
      start: finding.startIndex,
      end: finding.endIndex,
      input: sample.input.toSerializable(),
      boundaryOverrunIndex: finding.overrunIndex,
      inputBoundaryIndex: finding.boundaryIndex,
      query
    };
  }

  /**
   * Returns the appropriate scanner for the SQL dialect.
   * @param {String} id The id of the scanner
   * @returns {BaseScanner}
   */
  getScanner(id) {
    if (!this._scanners.has(id)) {
      this._scanners.set(id, ScannerKit.get(id)());
    }

    return this._scanners.get(id);
  }

  /**
   * Given a sink event, this will determine the SQL dialect. The dialect
   * provides us with a way to select a scanner that will be most-accurate.
   * @param   {SinkEvent} event
   * @returns {String}
   */
  static resolveDialect(event) {
    const tag = [flavors.MYSQL, flavors.POSTGRES, flavors.SQLITE].find((name) =>
      event.tags.has(name)
    );
    return tag || event.id || flavors.OTHER;
  }

  static checkGenericComplicated(userInput, assessment = { ids: {} }) {
    const { value } = userInput;
    const {
      searchers: { ids }
    } = assessment;

    if (ids.includes(isGenericComplicated.ID)) {
      return false;
    }

    return isGenericComplicated(value);
  }
}

module.exports = SQLInjectionRule;
module.exports.flavors = flavors;
