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

/**
 * @module lib/rules/BaseScanner
 */

const TokenSequence = require('./token-sequence');
const SubstringFinder = require('./substring-finder');
const ScanState = require('./scan-state');
const scanStates = require('./scan-states');
const logger = require('../../../core/logger')('contrast:protect:base-scanner');

// Constants for tokenizing flags:
const { FLAGS } = ScanState;
const { SKIP } = FLAGS;
const { INCLUSIVE_CAPTURE } = FLAGS;
const { FORCE_TOKEN } = FLAGS;

// Constants for scanning states:
const { EXPECTING_TOKEN } = scanStates;
const { INSIDE_BLOCK_COMMENT } = scanStates;
const { INSIDE_LINE_COMMENT } = scanStates;
const { INSIDE_DOUBLEQUOTE } = scanStates;
const { INSIDE_SINGLEQUOTE } = scanStates;
const { INSIDE_NUMBER } = scanStates;
const { INSIDE_OPERATOR } = scanStates;
const { INSIDE_TOKEN } = scanStates;
const { INSIDE_TERMINATOR } = scanStates;

// These ARE considered tokens, but if they are directly adjacent
// to another token, let's force them into their own token item.
// Ex: 'VALUES(A,B)'  ≠ { tokens: ['VALUES(A,B)'] },
//   but rather       = { tokens: ['VALUES','(','A',',','B',')'] }
const forceTokens = new Set(['(', ')', '[', ']', '{', '}', ',']);

/**
 * Scans a SQL query for a given input or list of inputs and determines whether any of them
 * are successful injection attacks.
 */
class BaseScanner {
  constructor(options = {}) {
    this.forceTokens = options.forceTokens || forceTokens;
    this.options = {
      tokenizeWhitespace: options.tokenizeWhitespace || false,
      includeLabels: options.includeLabels || false,
      labels: options.labels || null
    };
  }

  /**
   * Given an input string(s) and a query, will return a collection
   * of found injections. It identifies injections by,
   *   1. Parsing the query into a token sequence.
   *   2. Finding all stop/start indices of the input string within the query.
   *   3. Comparing these to the indices of the tokens in the sequence.
   * @param   {String} substring An input string.
   * @param   {String} query The query to analyze.
   * @returns {Object[]}
   */
  findInjection(substring, query) {
    let injection;
    const sequence = this.getTokenSequenceFromString(query);

    if (!sequence || !sequence.length) {
      return injection;
    }

    for (const location of new SubstringFinder(query, substring)) {
      const boundary = sequence.crossesBoundary(location);
      if (boundary) {
        injection = { input: substring, location, boundary, query };
        break;
      }
    }

    return injection;
  }

  /**
   * Returns the SQL query as a sequence of token items, each having a keyword
   * and start / stop indices describing its position in the query.
   * @param   {String}      query The SQL query to be parsed into the sequence.
   * @returns {TokenItem[]}
   */
  getTokenSequenceFromString(query) {
    const state = new ScanState(query);
    const sequence = new TokenSequence(query);

    state.on('state-change', (tokenItem) => {
      this.appendTokenItem(sequence, state, tokenItem);
    });

    let cursor = -1;
    while (++cursor < query.length) {
      // The state will mutate and emit events that produce tokens.
      this.readAtCursor(state);
    }

    if (this.isScanStateResultValid(state, query)) {
      return sequence;
    }

    logger.warn('Tokenizing failed for: %s', query);
    return null;
  }

  /**
   * After performing a tokenization of a query, the scan state should have its
   * internal index match the value of the last string index (length - 1). In
   * the current implementation only a mismatch in these values would suggest
   * the tokenization failed.
   * @param {ScanState} state
   * @param {string} query
   * @returns {boolean}
   */
  isScanStateResultValid(state, query) {
    return state.index === query.length - 1;
  }

  /**
   * Appends a token item to the list of token items. This gets called after a
   * state change, so there are some rules on whether to include the character
   * at point of cursor.
   * @param {TokenItem[]} sequence  The list to which to append item
   * @param {String}      state     The state of the scanner.
   * @param {Object}      tokenItem The captured token to add to the sequence.
   */
  appendTokenItem(sequence, state, tokenItem) {
    // Don't include whitespace tokens.
    if (
      !this.options.tokenizeWhitespace &&
      this.isAllWhitespace(tokenItem.token)
    ) {
      return;
    }

    sequence.push(tokenItem);
  }

  /**
   * Based on the current state of the scanner and the characters at the
   * cursor, will return a new scan state. Dispatches to the appropriate
   * processing handler based on state value;
   * @param   {ScanState} state   The current scan state of the parser.
   * @returns {ScanState}
   */
  /* eslint complexity: ["error", 12] */
  readAtCursor(state) {
    if (!state.value()) {
      state.is(EXPECTING_TOKEN);
    }

    switch (state.value()) {
      case EXPECTING_TOKEN:
        return this.processExpectingToken(state);
      case INSIDE_TOKEN:
        return this.processInsideToken(state);
      case INSIDE_NUMBER:
        return this.processNumberToken(state);
      case INSIDE_DOUBLEQUOTE:
        return this.processDoubleQuote(state);
      case INSIDE_SINGLEQUOTE:
        return this.processSingleQuote(state);
      case INSIDE_LINE_COMMENT:
        return this.processInlineCommentState(state);
      case INSIDE_BLOCK_COMMENT:
        return this.processBlockCommentState(state);
      case INSIDE_OPERATOR:
        return this.processOperatorState(state);
      case INSIDE_TERMINATOR:
        return this.processInsideToken(state);
      default:
        return null;
    }
  }

  /**
   * Given that we are in a state of expecting a token, process the
   * next group of characters in the query, and return the new scan state.
   * @param   {ScanState} state   The current scan state of the parser.
   * @returns {ScanState}
   */
  processExpectingToken(state) {
    const { codeString: query, index: idx } = state;
    const current = query[idx + 1];

    if (this.isLineCommentStart(state)) {
      return state.is(INSIDE_LINE_COMMENT).flag(SKIP, 1);
    }

    if (this.isBlockCommentStart(state)) {
      return state.is(INSIDE_BLOCK_COMMENT).flag(SKIP, Infinity);
    }

    return current === "'"
      ? state.is(INSIDE_SINGLEQUOTE)
      : current == '"'
      ? state.is(INSIDE_DOUBLEQUOTE)
      : this.isDigit(current)
      ? state.is(INSIDE_NUMBER)
      : this.isTerminator(current)
      ? state.is(INSIDE_TERMINATOR)
      : this.isOperator(state)
      ? state.is(INSIDE_OPERATOR)
      : this.isWhitespace(current)
      ? state.is(EXPECTING_TOKEN)
      : state.is(INSIDE_TOKEN);
  }

  /**
   * Given that we are in a state of being inside a token, process the
   * next group of characters in the query, and return the new scan state.
   * @param   {ScanState} state   The current scan state of the parser.
   * @returns {ScanState}
   */
  processInsideToken(state) {
    const { codeString: query, index: idx } = state;
    const prev = query[idx];
    const current = query[idx + 1];

    const force = this.forceTokens.has(current) || this.forceTokens.has(prev);

    if (this.isLineCommentStart(state)) {
      return state.is(INSIDE_LINE_COMMENT).flag(SKIP, 1);
    }

    if (this.isBlockCommentStart(state)) {
      return state.is(INSIDE_BLOCK_COMMENT).flag(SKIP, Infinity);
    }

    return current == "'"
      ? state.is(INSIDE_SINGLEQUOTE)
      : current == '"'
      ? state.is(INSIDE_DOUBLEQUOTE)
      : this.isOperator(state)
      ? state.is(INSIDE_OPERATOR)
      : this.isWhitespace(current)
      ? state.is(EXPECTING_TOKEN)
      : this.isTerminator(current)
      ? state.is(INSIDE_TERMINATOR)
      : !force
      ? state.is(INSIDE_TOKEN)
      : state.flag(FORCE_TOKEN, true).is(INSIDE_TOKEN);
  }

  /**
   * Given that we are in a state of being inside a number, process the
   * next group of characters in the query, and return the new scan state.
   * @param   {ScanState} state   The current scan state of the parser.
   * @returns {ScanState}
   */
  processNumberToken(state) {
    const { codeString: query, index: idx } = state;
    const current = query[idx + 1];

    if (this.isLineCommentStart(state)) {
      return state.is(INSIDE_LINE_COMMENT).flag(SKIP, 1);
    }

    if (this.isBlockCommentStart(state)) {
      return state.is(INSIDE_BLOCK_COMMENT).flag(SKIP, Infinity);
    }

    return this.isDigit(current) || current === '.'
      ? state.is(INSIDE_NUMBER)
      : this.isTerminator(current)
      ? state.is(INSIDE_TERMINATOR)
      : this.isOperator(state)
      ? state.is(INSIDE_OPERATOR)
      : state.is(EXPECTING_TOKEN);
  }

  /**
   * Given that we are in a state of being inside a single-quoted string, process
   * the next group of characters in the query, and return the new scan state.
   * @param   {ScanState} state   The current scan state of the parser.
   * @returns {ScanState}
   */
  processSingleQuote(state) {
    const { codeString: query, index: idx } = state;
    const current = query[idx + 1];

    // If we're escaping once or in a sequence, skip out now.
    if (state.value(SKIP)) {
      return state.is(INSIDE_SINGLEQUOTE);
    }

    // Look whether we're entering an escape sequence,
    if (this.isStringEscapeSequenceStart(current)) {
      return state
        .flag('IN_ESCAPE_BLOCK', true)
        .flag(SKIP, Infinity)
        .is(INSIDE_SINGLEQUOTE);
    }
    // or exiting one.
    if (this.isStringEscapeSequenceEnd(current)) {
      return state
        .flag('IN_ESCAPE_BLOCK', false)
        .flag(SKIP, 0)
        .is(INSIDE_SINGLEQUOTE);
    }

    // Check escape characters.
    if (this.isEscapeCharacter(current) || this.isEscapingSingleQuote(state)) {
      return state.is(INSIDE_SINGLEQUOTE).flag(SKIP, 1);
    }

    // Now that escape checks are done,
    // we're either ending the string
    if (current === "'") {
      return state.flag(INCLUSIVE_CAPTURE, true).is(EXPECTING_TOKEN);
    }

    // or still in it.
    return state.is(INSIDE_SINGLEQUOTE);
  }

  /**
   * Given that we are in a state of being inside a double-quoted string, process
   * the next group of characters in the query, and return the new scan state.
   * @param   {ScanState} state   The current scan state of the parser.
   * @returns {ScanState}
   */
  processDoubleQuote(state) {
    const { codeString: query, index: idx } = state;
    const current = query[idx + 1];

    // If we're escaping once or in a sequence, skip out now.
    if (state.value(SKIP)) {
      return state.is(INSIDE_DOUBLEQUOTE);
    }

    // Look whether we're entering an escape sequence,
    if (this.isStringEscapeSequenceStart(current)) {
      return state
        .flag('IN_ESCAPE_BLOCK', true)
        .flag(SKIP, Infinity)
        .is(INSIDE_DOUBLEQUOTE);
    }
    // or exiting one.
    if (this.isStringEscapeSequenceEnd(current)) {
      return state
        .flag('IN_ESCAPE_BLOCK', false)
        .flag(SKIP, 0)
        .is(INSIDE_DOUBLEQUOTE);
    }

    // Check escape characters.
    if (this.isEscapeCharacter(current) || this.isEscapingDoubleQuote(state)) {
      return state.is(INSIDE_DOUBLEQUOTE).flag(SKIP, 1);
    }

    // Now that escape checks are done,
    // we're either ending the string
    if (current === '"') {
      return state.flag(INCLUSIVE_CAPTURE, true).is(EXPECTING_TOKEN);
    }

    // or still in it.
    return state.is(INSIDE_DOUBLEQUOTE);
  }

  /**
   * Given that we are in a state of being inside an inline comment, process the
   * next group of characters in the query, and return the new scan state.
   * @param   {ScanState} state   The current scan state of the parser.
   * @returns {ScanState}
   */
  processInlineCommentState(state) {
    if (state.value(SKIP)) {
      return state.is(INSIDE_LINE_COMMENT);
    }

    const { codeString: query, index: idx } = state;
    return /\n|\f|\r/.test(query[idx + 1])
      ? state.is(EXPECTING_TOKEN)
      : state.is(INSIDE_LINE_COMMENT);
  }

  /**
   * Given that we are in a state of being inside a block comment, process the
   * next group of characters in the query, and return the new scan state.
   * @param   {ScanState} state   The current scan state of the parser.
   * @returns {ScanState}
   */
  processBlockCommentState(state) {
    const { codeString: query, index: idx } = state;
    const previous = query[idx];
    const current = query[idx + 1];

    // We know when this state terminates, so set flag
    if (previous === '*' && current == '/') {
      return state.flag(INCLUSIVE_CAPTURE, true).is(EXPECTING_TOKEN);
    }

    return state.is(INSIDE_BLOCK_COMMENT);
  }

  /**
   * Given that we are in a state of being inside an operator, process the
   * next group of characters in the query, and return the new scan state.
   * @param   {ScanState} state   The current scan state of the parser.
   * @returns {ScanState}
   */
  processOperatorState(state) {
    const { codeString: query, index: idx } = state;
    const current = query[idx + 1];

    if (this.isLineCommentStart(state)) {
      return state.is(INSIDE_LINE_COMMENT).flag(SKIP, 1);
    }

    if (this.isBlockCommentStart(state)) {
      return state.flag(SKIP, Infinity).is(INSIDE_BLOCK_COMMENT);
    }

    return current == "'"
      ? state.is(INSIDE_SINGLEQUOTE)
      : current == '"'
      ? state.is(INSIDE_DOUBLEQUOTE)
      : this.isOperator(state)
      ? state.is(INSIDE_OPERATOR)
      : this.isDigit(current)
      ? state.is(INSIDE_NUMBER)
      : this.isWhitespace(current)
      ? state.is(EXPECTING_TOKEN)
      : state.is(INSIDE_TOKEN);
  }

  /**
   * Whether the current character at the cursor is an escaping single quote.
   * @param   {ScanState} state   The current scan state of the parser.
   * @returns {Boolean}        Whether the quote is escaping.
   */
  isEscapingSingleQuote({ codeString: query, index: idx }) {
    return (
      this.canSingleQuoteEscapeInSingleQuote() &&
      query[idx + 1] === "'" &&
      query[idx + 2] === "'"
    );
  }
  /**
   * Whether the current character at the cursor is an escaping single quote.
   * @param   {ScanState} state   The current scan state of the parser.
   * @returns {Boolean}        Whether the quote is escaping.
   */
  isEscapingDoubleQuote({ codeString: query, index: idx }) {
    return (
      this.canDoubleQuoteEscapeInDoubleQuote() &&
      query[idx + 1] === '"' &&
      query[idx + 2] === '"'
    );
  }

  /**
   * Whether the character is escaping.
   * @param   {String}  character The character to evaluate.
   * @returns {Boolean}           True, if the character is escaping.
   */
  isEscapeCharacter(character) {
    return character === '\\';
  }

  /**
   * Whether a single quote can be an escape character within a single quote.
   * @returns {Boolean} true by default.
   */
  canSingleQuoteEscapeInSingleQuote() {
    return true;
  }

  /**
   * Whether a double quote can be an escape character within a double quote.
   * @returns {Boolean} false by default.
   */
  canDoubleQuoteEscapeInDoubleQuote() {
    return true;
  }

  /**
   * Returns true if we are at the start of a string escape sequence.
   * Support unavailable for most SQL databases, as you see here.
   * Example: '[special char or range]' (SQLServer)
   * @param   {ScanState} current   The current scan state of the parser.
   * @returns {Boolean}
   */
  isStringEscapeSequenceStart(current) {
    return false;
  }

  /**
   * Returns true if we are at the end of a string escape sequence.
   * Support unavailable for most SQL databases, as you see here.
   * Example: '[special char or range]' (SQLServer)
   * @param   {ScanState} current   The current scan state of the parser.
   * @returns {Boolean}
   */
  isStringEscapeSequenceEnd(current) {
    return false;
  }

  /**
   * Whether the scan state is inside any comment type.
   * @param   {ScanState} scanState The state to check.
   * @returns {Boolean}             Whether we are in comment.
   */
  isQuotedState(scanState) {
    return scanState === INSIDE_DOUBLEQUOTE || scanState === INSIDE_SINGLEQUOTE;
  }

  /**
   * Whether the characters indicate the start of an inline comment.
   * @param   {ScanState} state   The current scan state of the parser.
   * @returns {Boolean}
   */
  isLineCommentStart({ codeString: query, index: idx }) {
    return query && query[idx + 1] === '-' && query[idx + 2] === '-';
  }

  /**
   * Whether the characters indicate the start of a block comment.
   * @param   {ScanState} state   The current scan state of the parser.
   * @returns {Boolean}
   */
  isBlockCommentStart({ codeString: query, index: idx }) {
    return query && query[idx + 1] === '/' && query[idx + 2] === '*';
  }

  /**
   * Whether the character is an operator.
   * @param   {String} character The charcater to examine.
   * @returns {Boolean}
   */
  isOperator({ codeString: query, index: idx }) {
    if (!query) {
      return false;
    }

    const previous = query[idx];
    const current = query[idx + 1];
    const next = query[idx + 2];

    if (current === '*') {
      return previous !== '/' && next !== '/';
    }

    if (current === '/') {
      return previous !== '*' && next !== '*';
    }

    return (
      0 <=
      [
        '+',
        '-',
        '%',
        '/', // arithmetic
        '!',
        '=',
        '>',
        '<', // comparison
        '&',
        '^',
        '|' // bitwise
      ].indexOf(current)
    );
  }

  /**
   * Whether the character is a digit.
   * @param   {String} character The character to examine.
   * @returns {Boolean}
   */
  isDigit(character) {
    return /\d/.test(character);
  }

  /**
   * Whether the character is whitespace.
   * @param   {String} character
   * @returns {Boolean}
   */
  isWhitespace(character) {
    return /\s/.test(character);
  }

  /**
   * Whether a string is composed entirely of whitespace characters.
   * @param {String} string The string to examine.
   */
  isAllWhitespace(string) {
    return /^(\n|\r|\f|\s)*$/gm.test(string);
  }

  /**
   * Whether the character is the statement terminator.
   * @param   {String} character
   * @returns {Boolean}
   */
  isTerminator(character) {
    return character === ';';
  }
}

exports.BaseScanner = BaseScanner;
exports.SubstringFinder = SubstringFinder;
exports.TokenSequence = TokenSequence;
