/**
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.
*/
/**
 * This module is responsible for all of the agent's communication to TeamServer.
 * @module lib/reporter/tsReporter
 * @see HTTPClient
 */

'use strict';

// FIXME: Replace lodash get function with a native one
const { get } = require('lodash');
const {
  INTERVALS: { HEARTBEAT }
} = require('../constants');
const { STATIC_RULES } = require('../util/static-rules');
const loggerFactory = require('../core/logger');
const logger = loggerFactory('contrast:tsReporter');
const ServerActivityQueue = require('./server-activity-queue');
const ObservedRoute = require('./models/observed-route');
const MessagingRouter = require('./messaging-router');
const Speedracer = require('./speedracer');
const Scopes = require('../core/async-storage/scopes');
const { AsyncStorage, KEYS } = require('../core/async-storage');
const UmbrellaLogger = require('../core/logger/umbrella-logger');
const agentEmitter = require('../agent-emitter');
// lazy loading these modules because of the dependencies they load
let Finding, AppActivityQueue, AppUpdate, AppCreate, Startup;

const DEADZONE_NAME = 'tsReporter';

const updateWithFeatureSet = (tsFeatureSet) => ({
  applicationSettings,
  serverFeatures
} = {}) => {
  tsFeatureSet.updateApplicationSettings(applicationSettings);
  tsFeatureSet.updateServerFeatures(serverFeatures);
};

/**
 * Safely get sources from cache and cast to array
 * Note: keep in here for testing so I can properly
 * mock the async storage
 */
const getSourcesFromStorage = () => {
  let sources;
  try {
    sources = Array.from(AsyncStorage.get(KEYS.SOURCES).values());
  } catch (e) {
    sources = [];
  }

  return sources;
};

module.exports = class TSReporter {
  constructor(agent) {
    // lazy loading these modules because of the dependencies they load
    Finding = require('./models/finding/finding');
    AppUpdate = require('./models/app-update');
    AppActivityQueue = require('./app-activity-queue');
    AppCreate = require('./models/app-create');
    Startup = require('./models/startup');
    this.agent = agent;
    this.config = this.agent.config;
    this.appInfo = this.agent.appInfo;
    this.appActivityQueue = new AppActivityQueue(this.config);
    this.serverActivityQueue = new ServerActivityQueue(this.config);
    this.umbrellaLogger = new UmbrellaLogger(this.agent);
    this.enhancedLibraryUsage = this.config.get(
      'agent.node.library_usage.reporting.enable',
      false
    );

    TSReporter.decorateWithSpeedracer(this);
    TSReporter.decorateWithMessageQueueClient(this);
    this.messagingRouter = MessagingRouter.create({
      agent: this.agent,
      logger: loggerFactory,
      reporter: this
    });
  }

  /**
   * starts up
   *
   * @param {Array} args process.argv to original app
   */
  async onboard(args) {
    if (this.agent.isScreening) {
      await this.messageQueueClient.initialize();
    }

    return this.startup();
  }

  /**
   * This will send the startup and appcreate messages. Those responses will be
   * used to set the agent's application settings and server features.
   * @returns {Object}
   */
  async startup() {
    const success = true;
    const {
      agent,
      agent: { tsFeatureSet }
    } = this;

    return this.sendMessage('startup', new Startup(agent))
      .then((r) => {
        if (this.speedracer.startFailureError) {
          throw this.speedracer.startFailureError;
        }
        return r;
      })
      .then(updateWithFeatureSet(tsFeatureSet))
      .then(() =>
        this.sendMessage('appcreate', new AppCreate(agent, this.appInfo))
      )
      .then(updateWithFeatureSet(tsFeatureSet))
      .then(() => {
        this.openIntervals();
        this.initListeners();
        this.initHeartbeat();
        return success;
      })
      .catch((err) => {
        const noConnect = 'Contrast was unable to connect to TeamServer.';
        const noInstrument = 'Continuing without instrumentation.';

        logger.error(`${noConnect} ${noInstrument}`);
      });
  }

  /**
   * Sends a message to SR. Previously the router would dispatch to SR or TS based
   * on the <code>messageType</code> parameter.
   *
   * Deprecated, full documentation of the TS endpoints can be found
   * [here]{@link https://contrast.atlassian.net/wiki/display/ENG/Agent+Endpoints}.
   *
   * @param   {String}  messageType Corresponds to the agent API endpoint
   * @param   {Object}  message     Data packet to send
   * @returns {Promise}             Resolves to server response
   */
  sendMessage(messageType, message) {
    // send the message now
    return this.messagingRouter.route(messageType, message);
  }

  /**
   * sets up sending appinfo on an interval.
   */
  openIntervals() {
    // periodically emits to update custom code line count
    this.agent.intervals.appInfoInterval = setInterval(() => {
      Scopes.runInNoInstrumentationScope(() => {
        agentEmitter.emit('appinfo');
      }, DEADZONE_NAME);
    }, this.config.agent.polling.app_update_ms).unref();
  }

  /**
   * Sets up a 30 second interval to send a Poll
   * heartbeat message to Speedracer
   */
  initHeartbeat() {
    const {
      agent: { tsFeatureSet },
      speedracer
    } = this;
    if (speedracer) {
      setInterval(() => {
        this.sendMessage('poll').then(updateWithFeatureSet(tsFeatureSet));
      }, HEARTBEAT).unref();
    }
  }

  /**
   * Reports route observation from async storage when request has ended.
   * Grabs route info and sources from 2 diff async storage keys
   */
  sendObservation() {
    const {
      agent: { tsFeatureSet }
    } = this;
    // defaulting to empty array because Koa emits response being done twice
    const { signature, observations = [] } = AsyncStorage.get(KEYS.ROUTE) || {};

    const sources = getSourcesFromStorage();
    const sessionId =
      tsFeatureSet.applicationSettings &&
      tsFeatureSet.applicationSettings.sessionId;

    observations.forEach(({ verb, url }) => {
      const data = { signature, verb, url, sessionId, sources };
      this.sendMessage('observedRoute', new ObservedRoute(data)).then(
        updateWithFeatureSet(tsFeatureSet)
      );
    });
  }

  /**
   * Creates listeners for:
   *   - appinfo: sends appupdate message
   *   - finding:
   *   - inventory:
   *   - attack:
   *   - auth:
   *   - Speedracer Service reporting events
   *     - activity
   */
  initListeners() {
    agentEmitter.on('appinfo', (data) => {
      Scopes.runInNoInstrumentationScope(() => {
        const update = new AppUpdate(data, this.agent);
        this.sendMessage('appupdate', update);
      }, DEADZONE_NAME);
    });

    agentEmitter.on('finding', (evidence) => {
      Scopes.runInNoInstrumentationScope(() => {
        const { hash, ruleId, request, event, props } = evidence;
        const {
          tsFeatureSet: {
            applicationSettings: { sessionId }
          }
        } = this.agent;
        const { tags } = this.agent.config.assess;
        const storageContext = AsyncStorage.getContext();

        if (this.isUrlExcluded({ ruleId, storageContext })) {
          return;
        }

        const finding = new Finding(
          hash,
          ruleId,
          request,
          event,
          props,
          tags,
          sessionId
        );
        const route = get(storageContext, KEYS.ROUTE);

        if (route) {
          // There is only ever one route per request
          // However the DTM requires it to be an array
          // with key of routes ¯\_(ツ)_/¯
          finding.routes = [route];
        }

        // store the finding on the async storage
        if (storageContext) {
          storageContext.addFinding(finding);
        }

        if (this.agent.config.agent.node.unsafe.generate_umbrella) {
          this.umbrellaLogger.log(finding);
        }

        // static findings that are discovered during AST
        // will never have a request associated w/ them
        // bit of an ugly hack - just send an activity w/ the
        // finding immediately
        if (!storageContext && STATIC_RULES.includes(ruleId)) {
          // the DTM translation expects findings to be a map
          // add a map of 1 so that doesn't blow up
          const findings = new Map([[finding.hash, finding]]);
          this.sendActivityMessage({ findings });
        }
        // async finding occured after response sent
        // send immediately
        if (storageContext && storageContext.activitySent) {
          this.sendActivityMessage(storageContext);
        }
      }, DEADZONE_NAME);
    });

    agentEmitter.on('inventory', (data) => {
      Scopes.runInNoInstrumentationScope(() => {
        this.appActivityQueue.updateInventory(data);
      }, DEADZONE_NAME);
    });

    agentEmitter.on('attack', (attackResult) => {
      Scopes.runInNoInstrumentationScope(() => {
        const context = AsyncStorage.getContext();
        if (context) {
          context.attackResults.push(attackResult);
          logger.info(`${attackResult.ruleId}: Adding RASP attack activity`);
          if (context.activitySent) {
            this.sendActivityMessage(context);
          }
        }
      }, DEADZONE_NAME);
    });

    agentEmitter.on('activity', (context) => {
      Scopes.runInNoInstrumentationScope(() => {
        this.sendObservation();
        this.sendActivityMessage(context);
      }, DEADZONE_NAME);
    });

    agentEmitter.on('libraryUsage', (libUsage) => {
      if (this.enhancedLibraryUsage) {
        Scopes.runInNoInstrumentationScope(() => {
          this.appActivityQueue.updateLibUsage(libUsage);
        }, DEADZONE_NAME);
      }
    });

    agentEmitter.on('architectureComponent', (arch) => {
      Scopes.runInNoInstrumentationScope(() => {
        this.appActivityQueue.updateArchitectures(arch);
      }, DEADZONE_NAME);
    });
  }

  /**
   * Instantiates the SR client when port/host/socket
   * if properly configured
   *
   * @param {TSReporter} reporter TSReporter instance
   */
  static decorateWithSpeedracer(reporter) {
    const {
      agent,
      agent: { config }
    } = reporter;

    const service = config.agent && config.agent.service;
    if (service && ((service.port && service.host) || service.socket)) {
      reporter.speedracer = new Speedracer({
        agent,
        logger: loggerFactory
      });
    } else {
      logger.warn(
        'contrast-service port/host or socket are misconfigured: %j. The contrast agent is disabled.',
        service
      );
      config.enable = false;
    }
  }

  static decorateWithMessageQueueClient(reporter) {
    const { agent } = reporter;

    if (agent.isScreening) {
      const MessageQueueClient = require('./mq-client');
      reporter.messageQueueClient = new MessageQueueClient({
        agent
      });
    }
  }

  /**
   * Gets app activity data from "queue"
   * and sends an `activity` message
   *
   * @param {StorageContext} context
   */
  sendActivityMessage(context) {
    const {
      agent: { tsFeatureSet },
      speedracer
    } = this;

    if (!speedracer) {
      return;
    }

    if (!context) {
      logger.warn('StorageContext unavailable - sendActivityMessage() failed');
      return;
    }

    const libs = this.appActivityQueue.getLibUsage();
    const appActivity = this.appActivityQueue.get();
    const architectures = this.appActivityQueue.getArchitectures();
    const inventory =
      appActivity && appActivity.inventory ? appActivity.inventory : {};

    const queueData = {
      queriesMonitored: inventory.queriesMonitored,
      routes: inventory.routes,
      technologies: inventory.technologies,
      libs,
      architectures
    };
    const contextData = {
      request: get(context, KEYS.REQUEST),
      attackResults: context.attackResults,
      findings: context.findings
    };

    context.activitySent = true;
    // reset context data to avoid sending again on
    // an async finding or attack
    if (context.reset) {
      context.reset();
    }
    this.sendMessage('activity', { ...queueData, ...contextData }).then(
      updateWithFeatureSet(tsFeatureSet)
    );
  }

  /**
   * Checks agent's URL exclusions against current request path.
   * @param {Object} params
   * @param {Object} params.sinkData
   * @param {Object} params.storageContext
   * @returns {boolean}
   */
  isUrlExcluded({ ruleId, storageContext }) {
    const urlExclusions = this.agent.exclusions.getUrlExclusions('assess');
    const request = get(storageContext, KEYS.REQUEST);

    if (!request || !request.uri) {
      return false;
    }

    for (const exclusion of urlExclusions) {
      if (
        exclusion.appliesToAssessRule(ruleId) &&
        exclusion.matchesUrl(request.uri)
      ) {
        return true;
      }
    }
    return false;
  }
};
