/**
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';
// This class is a mirror image of Java's implementation of the CMDi Dangerous Paths subrule
// https://bitbucket.org/contrastsecurity/java-agent/src/03134f94648603ede4d75391e3aeaa85bea6de64/contrast-agent-core/src/main/java/com/contrastsecurity/agent/plugins/rasp/rules/cmdinjection/CommandArgumentScanner.java#lines-12
module.exports = class DangerousPathsScanner {
  /**
   * @constructor
   * @param {SinkEvent.data.command}
   * @param {SinkEvent.data.request}
   * @param {SinkEvent.data.event}
   */
  constructor({ command, request, event } = {}) {
    this.offset = 0;
    this.command = command;
    this.request = request;
    this.event = event;
    this.argScanMode = {
      NORMAL: 'NORMAL',
      EXPANSION: 'EXPANSION',
      BRACE_EXPANSION: 'BRACE_EXPANSION'
    };
    this.mode = this.argScanMode.NORMAL;
    this.cleanString = '';
  }

  /**
   * Performs a series of checks on whether this.command contains a dangerous path argument
   * 1. Remove any shell parameter expansion attempts (e.g. ${some_null_var})
   * 2. Call this.sanitizeCommand() to remove any other path obfuscation techniques
   * 3. After string sanitization is done, see if a dangerous path arg exists in the string
   * @returns {Boolean}
   */
  containsDangerousPath() {
    const commandLength = this.command.length;

    while (this.offset < commandLength) {
      const currentChar = this.command.charAt(this.offset);
      const nextChar =
        this.offset < commandLength - 1
          ? this.command.charAt(this.offset + 1)
          : null;
      switch (this.mode) {
        case 'NORMAL':
          this.handleNormal(currentChar, nextChar);
          continue;
        case 'EXPANSION':
          this.handleExpansion(currentChar, nextChar);
          continue;
        case 'BRACE_EXPANSION':
          if (this.handleBraceExpansion(currentChar)) return true;
          continue;
        default:
          throw new Error('Command Injection: Unknown argument scanning mode');
      }
    }

    this.cleanString = this.sanitizeCommand(this.cleanString);
    return this.includesDangerousPathArg(this.cleanString);
  }

  /**
   * Sets mode to [BRACE_EXPANSION | EXPANSION] if shell parameter expansion is detected
   * @param {String} currentChar
   * @param {String} nextChar
   */
  handleNormal(currentChar, nextChar) {
    if (this.isExpansionStart(currentChar, nextChar)) {
      this.mode = this.argScanMode.EXPANSION;
      this.offset += 1;
      return;
    }
    if (this.isBraceExpansionStart(currentChar, nextChar)) {
      this.mode = this.argScanMode.BRACE_EXPANSION;
      this.offset += 2;
      return;
    }
    this.cleanString += currentChar;
    this.offset += 1;
    return;
  }

  /**
   * Sets mode to BRACE_EXPANSION if shell parameter expansion w/ braces (e.g. ${VAR}) is detected
   * Sets mode to NORMAL and appends the current character if no envvar substition is attempted
   * @param {String} currentChar
   * @param {String} nextChar
   */
  handleExpansion(currentChar, nextChar) {
    if (this.isExpansionStart(currentChar, nextChar)) {
      this.offset += 1;
      return;
    }
    if (this.isBraceExpansionStart(currentChar, nextChar)) {
      this.mode = this.argScanMode.BRACE_EXPANSION;
      this.offset += 2;
      return;
    }
    if (!this.isAllowedEnvVarChar(currentChar)) {
      this.mode = this.argScanMode.NORMAL;
      this.cleanString += currentChar;
      this.offset += 1;
      return;
    }

    this.offset += 1;
    return;
  }

  /**
   * Sets scanning mode back to normal when reading the end of shell parameter expansion
   * And returns true when substring extraction is attempted
   * e.g.(${XDG_CONFIG_DIRS:0:5}/passwd)
   * @param {String} currentChar
   */
  handleBraceExpansion(currentChar) {
    if (currentChar === ':') {
      return true;
    }
    if (currentChar === '}') {
      this.mode = this.argScanMode.NORMAL;
      this.offset += 1;
      return;
    }

    this.offset += 1;
    return;
  }

  /**
   * Perform a series of string replacements on RegEx patterns
   * @example Before: /etc//../${var}/passwd After: /etc/passwd
   * @param {String} command
   */
  sanitizeCommand(command) {
    /*
      Quotes and Escapes pattern - quotes, double quotes and escapes can be used to obfuscate paths
       - e.g.: cat /etc/"passwd", cat '/etc/passwd', cat /etc/pass\wd
    */
    let cleanString = command.replace(/\\|'|"/g, '');
    /*
     Dot Link pattern - the dot link can be used to obfuscate a path
     e.g.: cat /etc/././passwd
   */
    cleanString = cleanString.replace(/\/\.\/(\.\/)*/g, '/');
    /*
     Extra Slashes pattern - extra slashes can be used to obfuscate a path
     e.g.: cat /etc///passwd
   */
    return cleanString.replace(/\/\/*/g, '/');
  }

  /**
   * Checks if the command contains a dangerous path arg.
   * @param {String} cleanString
   */
  includesDangerousPathArg(cleanString) {
    cleanString = cleanString.toLowerCase();
    return (
      cleanString.includes('/etc/passwd') ||
      cleanString.includes('/etc/shadow') ||
      cleanString.includes('/etc/hosts')
    );
  }

  /**
   * Checks for variable substitution using brace method (e.g. ${VAR}
   * @param {string} currentChar
   * @param {string} nextChar
   */
  isBraceExpansionStart(currentChar, nextChar) {
    return currentChar === '$' && nextChar === '{';
  }

  /**
   * Checks for variable substitution without braces (e.g. $VAR)
   * @param {string} currentChar
   * @param {string} nextChar
   * @returns {Boolean}
   */
  isExpansionStart(currentChar, nextChar) {
    return currentChar === '$' && this.isAllowedEnvVarChar(nextChar);
  }

  /**
   * Checks if a character is one of [a-z], [0-9], or _
   * @param {string} char
   * @returns {Boolean}
   */
  isAllowedEnvVarChar(char) {
    return (
      char != null &&
      'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'.indexOf(
        char
      ) > -1
    );
  }
};
