Source: worker/NmapWorker.js

/* eslint no-bitwise: 0, no-param-reassign: 0 */

const Os = require('os');
const Path = require('path');
const { spawn } = require('child_process');

const Util = require('../utility/Util');
const { ScanProfiles } = require('../utility/Constants');

/**
  * Interfaces with the nmap process and reports the results
  * @param {Object} options - Class options
  */
class NmapWorker {
  constructor(options) {
    /**
      * Merge default options with given options
      * @type {Object}
      */
    this.options = Util.mergeDefault({
      onError: () => {}, // @type {Function}
      onWarning: () => {}, // @type {Function}
      onDebug: () => {}, // @type {Function}
      onReady: () => {}, // @type {Function}
      onFinish: () => {}, // @type {Function}
      onNmapOut: () => {}, // @type {Function}
      logDirectory: '', // @type {String}
      profile: 'Default', // @type {String}
      args: '', // @type {String}
      nmapPath: '', // @type {String}
      maxScanTime: 900000, // @type {Number} 15 minutes default
      skipChecks: false, // @type {Boolean}
    }, options);

    /**
      * Reference to the current OS
      * @type {Boolean}
      */
    this.isWin = process.platform === 'win32';

    /**
      * Indicates if a scan is currently running
      * @type {Boolean}
      */
    this.busy = false;

    /**
      * Scan options of the currently running scan
      * @type {Object}
      */
    this.currentScan = {};

    /**
      * Stores custom profiles by name
      * @type {Map}
      */
    this.customProfiles = new Map();

    /**
      * Current nmap child process
      * @type {ChildProcess}
      */
    this.nmapProcess = null;

    /**
      * Nmap process std out buffer
      * @type {String}
      */
    this.stdoutBuffer = '';

    /**
      * Nmap process std error buffer
      * @type {String}
      */
    this.stderrBuffer = '';

    /**
      * File extension for the log file
      * @type {String}
      */
    this.logExt = 'html';

    // Validate working enviroment if required
    if (this.options.skipChecks) {
      this.options.onReady();
    } else {
      this.runChecks();
    }
  }

  /**
    * Path to save log files to, returns the temp directory if not specified
    * @type {String}
    * @readonly
    */
  get logDirectory() {
    if (this.options.logDirectory !== '') {
      return `${this.options.logDirectory}${Path.sep}`;
    }

    return `${Os.tmpdir()}${Path.sep}`;
  }

  /**
    * Name of the log file
    * @type {String}
    * @readonly
    */
  get logName() {
    return `${Math.random().toString(36).substring(2, 15)}.${this.logExt}`;
  }

  /**
    * Add custom scan profile
    * @param {String} name Scan profile name
    * @param {String} args Nmap arguments
    */
  addProfile(name, args) {
    this.customProfiles.set(name, args);
  }

  /**
    * Changes current scan profile
    * @param {String} newProfile Target scan profile
    */
  set profile(newProfile) {
    this.options.profile = newProfile;
  }

  /**
    * Current profile name getter
    * @readonly
    */
  get profile() {
    return this.options.profile;
  }

  /**
    * Get current nmap arguements by profile name, default profile is provided
    * if specified name is not found
    * @readonly
    */
  get profileArgs() {
    let { args } = ScanProfiles.default;
    let found = false;

    // Check for profile in built-in profile list
    Object.keys(ScanProfiles).forEach((profile) => {
      if (ScanProfiles[profile].name === this.profile) {
        args = ScanProfiles[profile].args;
        found = true;
      }
    });

    // If not found, check for custom profile name
    if (!found && this.customProfiles.has(this.profile)) {
      args = this.customProfiles.get(this.profile);
    }

    return args;
  }

  /**
    * Splits a string into an array on each space while respecting quoutes
    * @param {String} input Target string
    * @private
    * @returns {Array}
    */
  static stringToArgs(input) {
    return input.match(/\\?.|^$/g).reduce((p, c) => {
      if (c === '"' || c === "'") {
        p.quote ^= 1;
        p.a[p.a.length - 1] += c;
      } else if (!p.quote && c === ' ') {
        p.a.push('');
      } else {
        p.a[p.a.length - 1] += c;
      }

      return p;
    }, { a: [''] }).a;
  }

  /**
    * Run environment checks to make sure we can execute nmap
    * @private
    */
  async runChecks() {
    // Set options to not create a log or specify a target, ask nmap to output version
    const nmapOptions = {
      logPath: false,
      target: false,
      args: '-V',
    };

    try {
      await this.runNmap(nmapOptions);
    } catch (err) {
      this.options.onError(`Nmap Execution Error:\n${err}\n${this.stdoutBuffer}\n${this.stderrBuffer}`);
      this.reset();

      return false;
    }

    // Clear un-needed data
    this.reset();

    // Notify parent that we are ready
    this.options.onReady();

    return true;
  }

  /**
    * Create a new instance of nmap
    * @param {Object} nmapOptions Includes `logPath`, `target`, `args`
    * @private
    */
  async runNmap(nmapOptions) {
    let nmapPath = '';

    // Append specified path to `nmapPath` if specified
    if (this.options.nmapPath !== '') {
      nmapPath += `${this.options.nmapPath}${Path.sep}`;
    }

    // `nmapPath` will be `nmap` or `/path/to/nmap`
    nmapPath += 'nmap';

    const { target, args } = nmapOptions;
    let { logPath } = nmapOptions;

    // Store args, casting is done explicitly on purpose
    let argArray = this.constructor.stringToArgs(`${args}`);

    // Append nmap args to create html log to specified path
    if (logPath) {
      if (this.isWin) {
        logPath = Path.win32.normalize(logPath);
      } else {
        logPath = Path.normalize(logPath);
      }

      if (logPath.indexOf(' ') !== -1) {
        logPath = `"${logPath}"`;
      }

      argArray = [...argArray, ...this.constructor.stringToArgs(`--no-stylesheet -oX ${logPath}`)];
    }

    // Append target specification with the `-6` flag if ipv6 included
    if (target) {
      if (target.indexOf(':') !== -1) {
        argArray.push('-6');
      }

      argArray = [...argArray, ...this.constructor.stringToArgs(`${target}`)];
    }

    // Emit debug info
    this.options.onDebug(`Invoking nmap as: ${nmapPath} ${argArray.join(' ')}`);

    return new Promise((resolve, reject) => {
      this.nmapProcess = spawn(nmapPath, argArray, {
        timeout: this.options.maxScanTime, // default: 15 minutes
        windowsVerbatimArguments: true,
        shell: true,
      });

      this.nmapProcess.on('error', reject);

      this.nmapProcess.on('close', (code) => resolve(code));

      this.nmapProcess.stdout.on('data', (data) => {
        this.options.onNmapOut(data);
        this.stdoutBuffer += data;
      });

      this.nmapProcess.stderr.on('data', (data) => {
        this.stderrBuffer += data;
      });
    });
  }

  /**
    * Start a new scan
    * @param {Object} scanOptions Inlcude `target`, `profile`
    * @public
    */
  async scan(scanOptions) {
    this.busy = true;
    this.currentScan = scanOptions;
    this.profile = scanOptions.profile;

    const nmapOptions = {
      logPath: `${this.logDirectory}${this.logName}`,
      target: scanOptions.target,
      args: this.profileArgs,
    };

    let exitCode = 0;

    try {
      exitCode = await this.runNmap(nmapOptions);
    } catch (err) {
      this.options.onError(`Nmap Execution Error:\n${err}\n${this.stdoutBuffer}\n${this.stderrBuffer}`);
    }

    const results = {
      logPath: nmapOptions.logPath,
      nmapOut: `${this.stdoutBuffer}`,
      nmapErr: `${this.stderrBuffer}`,
      exitCode: exitCode || -1,
    };

    this.reset();

    this.options.onFinish(results);
  }

  /**
    * Clear no longer needed properties
    */
  reset() {
    this.busy = false;
    this.stdoutBuffer = '';
    this.stderrBuffer = '';
  }

  /**
    * Force quit current scan
    * @return {Object} The current scan options + the kill result
    */
  killScan() {
    const killedScan = this.currentScan;
    killedScan.killed = this.nmapProcess.kill('SIGKILL');

    this.reset();

    return killedScan;
  }
}

module.exports = NmapWorker;