/**
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/protect/service
 */

const _ = require('lodash');

const {
  IMPORTANCE,
  SAFE_HEADER_VALUES,
  INPUT_TYPES,
  RULES
} = require('../constants');
const agentEmitter = require('../agent-emitter');
const SampleAggregator = require('./sample-aggregator');
const RuleFactory = require('./rules/rule-factory');
const ExclusionFactory = require('../core/exclusions/exclusion-factory.js');
const { IpAnalyzer } = require('../util/ip-analyzer');
const logger = require('../core/logger')('contrast:rules:protect:service');
const headerValidators = require('./validators');
const UserInputKit = require('../reporter/models/utils/user-input-kit');
const UserInputFactory = require('../reporter/models/utils/user-input-factory');
const blockRequest = require('../util/block-request');
const { AsyncStorage, KEYS } = require('../core/async-storage');


const evalOptions = { preferWorthWatching: true };

class ProtectService {
  /**
   * Configures the service to use the provided agent.
   * @param {Agent} agent
   */
  constructor(agent, reporter) {
    this.reporter = reporter;
    this.config = agent.config;
    this.enabled = agent.isInDefendMode();
    this.assessEnabled = agent.isInAssessMode();

    this.agentLibAnalysis =
      this.config.agent.node.native_input_analysis &&
      this.config.agent.node.speedracer_input_analysis;

    // if agentLib is present it will be used (for the "speedracer" variant of
    // protect).
    this.agentLib = agent.agentLib;
    // map the rule-id in this.rules to the constant name for agentLib.RuleType values.
    // are these mappings needed elsewhere? if so, yet another module...
    if (this.agentLib && reporter.speedracer) {
      this.agentLibRuleTypeToName = {
        'nosql-injection-mongo': 'nosql-injection',
      };
    }

    this._exclusionFactory = new ExclusionFactory({
      featureSet: agent.tsFeatureSet,
      protect: this.enabled,
      assess: this.assessEnabled
    });
    this._ruleFactory = new RuleFactory({
      featureSet: agent.tsFeatureSet,
      enabled: this.enabled,
      agent
    });
    this.rules = this._ruleFactory.getRules();
    this.updateIpAllowlist(agent.tsFeatureSet.serverFeatures);

    this.urlExclusions = this._exclusionFactory.getUrlExclusions();
    this.inputExclusions = this._exclusionFactory.getInputExclusions();
    this.rules = this._ruleFactory.getRules();
    if (this.agentLibAnalysis) {
      this.addAgentLibBitToRules();
    }

    agentEmitter.on('server-features', (serverFeatures) => {
      this.enabled = agent.isInDefendMode();
      this.assessEnabled = agent.isInAssessMode();
      this.updateRules(serverFeatures);
      this.updateExclusions(serverFeatures);
      this.updateIpAllowlist(serverFeatures);
    });

    agentEmitter.on('application-settings', (applicationSettings) => {
      this.updateRules(applicationSettings);
      this.updateExclusions(applicationSettings);
    });
  }

  /**
   * Sends Connection/Header/URI data to SR or agent-lib to perform input analysis.
   * @param {} meta
   * @param {IncomingMessage} req The current request
   * @param {ServerResponse} res The current response
   * @returns {Boolean} Returning `true` allows instrumentation to resume app code
   */
  analyzeRequest({ meta, req, res, appContext }) {
    if (this.agentLibAnalysis) {
      const agentLibResults = this.analyzeWithAgentLib(meta, req);

      const analysis = this.handleAgentLibAnalysis({
        asyncStorageContext: meta.asyncStorageContext,
        appContext,
        agentSettings: agentLibResults,
        req,
        res
      });
      return Promise.resolve(analysis);
    }

    // if not doing native analysis (i.e., agent-lib) then send a message to
    // SR and wait for the reply.
    return this.reporter
      .sendMessage('request', { incomingMessage: req })
      .then((agentSettings) => {
        meta.requestId = agentSettings.protectState.uuid;

        return this.handleAnalysisResponse({
          asyncStorageContext: meta.asyncStorageContext,
          appContext,
          agentSettings,
          req,
          res
        });
      });
  }

  /**
   * Send request data to SR for analysis
   * @param {Object} meta input analysis request metadata
   * @param {IncomingMessage} req The current request
   * @param {ServerResponse} res The current response
   * @param {Buffer[]} chunks The chunks being handled
   * @returns {Boolean} Returning `true` allows instrumentation to resume app code
   */
  analyzeRequestStream({ meta, req, res, appContext }) {
    const { requestId, chunks } = meta;

    // use agentLib?
    if (this.agentLibAnalysis) {
      // don't try to analyze multipart bodies; agent-lib does not parse because the
      // interpretation is framework dependent, like query params.
      let multipart = false;
      if (req.headers['content-type']) {
        multipart = req.headers['content-type'].toLowerCase().includes('multipart');
      }
      let agentLibResults;
      if (multipart) {
        agentLibResults = {};
      } else {
        agentLibResults = this.analyzeBodyWithAgentLib(meta, chunks);
      }

      const analysis = this.handleAgentLibAnalysis({
        asyncStorageContext: meta.asyncStorageContext,
        appContext,
        agentSettings: agentLibResults,
        req,
        res
      });
      return Promise.resolve(analysis);
    }

    // use SR, not agentLib.
    return this.reporter
      .sendMessage('request', { requestId, chunks })
      .then((agentSettings) =>
        this.handleAnalysisResponse({
          asyncStorageContext: meta.asyncStorageContext,
          appContext,
          agentSettings,
          req,
          res
        })
      );
  }

  //
  // note that agent-lib returns "trackRequest" which is the logical-not
  // of SR's "permit" return.
  //
  analyzeWithAgentLib(meta, req) {
    const rules = this.getRulesMask(meta.asyncStorageContext.defend.rules);
    if (!rules) {
      return {};
    }

    const arg = {
      // header names must be lowercase. should this be done in agent-lib?
      headers: req.rawHeaders.map((h, ix) => (ix & 1 ? h : h.toLowerCase()))
    };

    arg.uriPath = req.url;
    const questionMark = req.url.indexOf('?');
    if (questionMark >= 0) {
      arg.queries = req.url.slice(questionMark + 1);
      arg.uriPath = req.url.slice(0, questionMark);
    }

    const findings = this.agentLib.scoreRequestConnect(rules, arg, evalOptions);

    return findings;
  }

  analyzeBodyWithAgentLib(meta, chunks) {
    const rules = this.getRulesMask(meta.asyncStorageContext.defend.rules);
    if (!rules) {
      return {};
    }

    let bodyData = '';

    if (Array.isArray(chunks)) {
      if (typeof chunks[0] == 'string') {
        const bodyStr = ''.concat('', ...chunks);
        bodyData = Buffer.from(bodyStr).toString('base64');
      } else if (Buffer.isBuffer(chunks[0])) {
        const bodyBuffer = Buffer.concat(chunks);
        bodyData = Uint8Array.from(bodyBuffer);
      } else {
        logger.error('Invalid chunk type');
      }
    } else {
      logger.error('Invalid chunk type');
    }

    // also, if content-type has multipart...
    const findings = this.agentLib.scoreRequestBody(
      rules,
      bodyData,
      evalOptions
    );

    // store body buffer on findings for nosqli sink.
    findings.bodyBuffer = bodyData;
    return findings;
  }

  getRulesMask(rules) {
    return rules.reduce((mask, rule) => {
      if (!rule.agentLibBit) {
        logger.trace(`rule ${rule.id} missing agentLibBit`);
        return mask;
      }
      return mask | rule.agentLibBit;
    }, 0);
  }

  /**
   * Independent of the part(s) of the HTTP message being analyzed, there is a
   * common process for handling the analysis response from S-R.
   * @param {AgentSettings} agentSettings SR model containing state and results
   * @param {IncomingMessage} req The current request
   * @param {ServerResponse} res The current response
   * @returns {Boolean}
   */
  handleAnalysisResponse({
    asyncStorageContext,
    appContext,
    agentSettings,
    req,
    res
  }) {
    if (_.get(agentSettings, 'protectState.securityException')) {
      return this.handleBlockAtPerimeter(res);
    }

    // If we're not blocking, then save results for input tracing.
    this.collectSamples(
      asyncStorageContext,
      _.get(agentSettings, 'inputAnalysis.resultsList'),
      appContext
    );

    return true;
  }

  /**
   * Handle the analysis response from agent-lib
   *
   * @param {AgentSettings} agentSettings agentLib findings
   * @param {IncomingMessage} req The current request
   * @param {ServerResponse} res The current response
   * @returns {Boolean}
   *
   * agentLib findings are an object:
   * {trackRequest: true|false, resultsList: [result]}
   *
   * a result is an object:
   * {
   *    ruleId: string,
   *    inputType: string,
   *    path: [string],
   *    key: string,
   *    value: string,
   *    score: number
   * }
   */
  // eslint-disable-next-line complexity
  handleAgentLibAnalysis({
    asyncStorageContext,
    appContext,
    agentSettings: agentLibResults,
    res
  }) {
    if (!agentLibResults.resultsList) {
      return true;
    }

    // at this point rules that are excluded by URL have been removed but
    // none of the user-input exclusions have been applied; those exclusions
    // are only applied for the protect.source event and this is (indirectly)
    // invoked by the request.start and request.end events.

    // determine if user input is excluded now that we have the results.
    const { defend: { exclusions } } = asyncStorageContext;

    let securityException = false;
    // map the resultsList to the srResultsList (SR legacy format)
    const srResultsList = [];

    for (const r of agentLibResults.resultsList) {
      // it's a little ugly but not all names returned correspond. this duplicates work
      // in resultItemToSrResultItem() but allows us to avoid the conversion if the
      // rule was excluded. i'm not sure it is a good trade because i'm presuming most
      // items are not excluded, so it's a little bit of extra work to do this before
      // the conversion.
      const ruleId = this.agentLibRuleTypeToName[r.ruleId] || r.ruleId;
      if (exclusions.length) {
        const exclusionId = this.shouldExclude(exclusions, ruleId, r.inputType, r.key);
        // don't add this to srResultsList if it is excluded.
        // check null - can an exclusion name be an empty string?
        if (exclusionId !== null) {
          logger.debug(`EXCLUSION: ${exclusionId} - ${r.inputType} '${r.key}'`);
          continue;
        }
      }

      const mapped = this.resultItemToSrResultItem(r);
      // nosqli requires the object at the key returned by the object to be stored
      // on the sample so that it can be accessed at sink time.
      if (mapped.ruleId === 'nosql-injection' && agentLibResults.bodyBuffer) {
        this.captureMongoObject(mapped, agentLibResults.bodyBuffer);
      }
      // is the rule BAP?
      if (mapped.scoreLevel === 'DEFINITE' && this.getRuleMode(mapped.ruleId) === 'BLOCK_AT_PERIMETER') {
        securityException = true;
      }

      srResultsList.push(mapped);
    }

    /*
    // this is the message SR returns but there is no need to create that format.
    // only the resultsList is used by collectSamples(). securityException has
    // already been synthesized.
    const srAnalysisFmt = {
      sentMs: Date.now(),
      serverFeatures: undefined,
      applicationSettings: undefined,
      accumulatorSettings: undefined,
      protectState: {
        uuid: 'dead-beef-feed-a-fad-b4-a-fade',
        trackRequest: agentLibResults.trackRequest,
        securityException,
        securityMessage: ''
      },
      inputAnalysis: { resultsList: srResultsList },
      // tack on raw agent lib results so they can be used at sinks. this will
      // facilitate removing the SR format when SR is removed.
      agentLibResults
    };
    // */

    // hack this into agentLibResults for now. it's needed to set sample.blocked
    // when one had a DEFINITE score. previously, this was not needed because SR
    // did reporting and only returned a securityException flag, indicating that
    // the agent needed to block the request.
    agentLibResults.securityException = securityException;

    // save results for input tracing. collectSamples() is called with the
    // additional parameter, agentLibResults, in this case. (see the implementation
    // of collectSamples()).
    this.collectSamples(
      asyncStorageContext,
      srResultsList,
      appContext,
      agentLibResults
    );

    // if there is a security exception there used to be no need to do anything more
    // because SR would report it; when SR sent us a "securityException" it had already
    // been reported, so the agent needed only to block the request. but with agentLib
    // the sample must always be collected so it will be reported.
    if (securityException) {
      return this.handleBlockAtPerimeter(res);
    }

    return true;
  }

  /**
   * map an agent-lib result to an SR format result.
   *
   * @param {result} r see handleAgentLibAnalysis above; an item returned by scoreRequestConnect
   * in the resultsList array.
   *
   * @returns an SR-formatted result.
   */
  resultItemToSrResultItem(r) {
    const copy = Object.assign({}, r, { attackCount: 1 });
    // the ruleIds are not the same. kind of ugly.
    if (copy.ruleId in this.agentLibRuleTypeToName) {
      copy.ruleId = this.agentLibRuleTypeToName[copy.ruleId];
    }

    // user-input serialization wants a string. it replaces
    // '.' with '>'; it really shouldn't do that - a '.' could
    // be in a key, but that's how it works.
    copy.path = copy.path.join('>');
    // agent-lib doesn't return the pattern IDs that matched. they're not used, but the
    // array cannot be empty for TS (rumor has it).
    copy.idsList = ['agent-lib'];
    if (copy.score >= 90) {
      copy.scoreLevel = 'DEFINITE';
    } else if (copy.score >= 10) {
      copy.scoreLevel = 'WATCH';
    } else {
      // it really shouldn't be in this list...
      copy.scoreLevel = 'NONE';
    }
    // get rid of the score property because it's not part of the SR
    // resultsList items.
    delete copy.score;

    return copy;
  }

  /**
   * Capture document object sample for Mongo. Right now applies blanketly
   * to all nosqli rules because there is not a translation layer.
   * For more information on how this applies to mongo injection & expansion,
   * See `mongo.md' in the agent-lib-core repo.
   *
   * @param {Object} libResult  result object from library representing mongo injection/expansion
   * @param {Buffer} bodyBuffer buffer form of the request body (concat'd from chunks)
   */
  captureMongoObject(libResult, bodyBuffer) {
    try {
      // matches Sample's _inputInfoForSink
      if (!libResult.inputInfo) {
        libResult.inputInfo = {};
      }

      // parse the body as json.
      const { path } = libResult;
      const obj = JSON.parse(bodyBuffer.toString());
      let doc = obj;
      // returned path from lib is array of keys to traverse.
      for (const entry of path) {
        doc = doc[entry];
      }

      libResult.inputInfo.docObject = doc;
      // the query clause (eg: $ne) is always the last entry in the path.
      libResult.inputInfo.queryClause = path[path.length - 1];
    } catch (e) {
      logger.debug(`Failed to parse body buffer on nosqli libResult ${e}`);
    }
  }

  /**
   * Get the mode for a given rule
   * @param   {string} ruleId rule to get mode of
   * @returns {string}        the mode of the given rule
   */
  getRuleMode(ruleId) {
    // must filter every time because teamserver can update these
    // at any time.
    for (const rule of this.rules) {
      if (rule.id === ruleId) {
        return rule.mode;
      }
    }

    return null;
  }

  /**
   * Block at perimeter when instructed to do so by S-R.
   * @param {ServerResponse} res The current response
   * @returns {Boolean} false which halts executing of original method
   */
  handleBlockAtPerimeter(res) {
    const finalHandlerCbIndex = AsyncStorage.get(KEYS.FINALHANDLER_CB_INDEX);
    if (finalHandlerCbIndex || finalHandlerCbIndex == 0) {
      const req = AsyncStorage.get(KEYS.REQ);
      req.__onFinished && req.__onFinished.queue && req.__onFinished.queue.splice(finalHandlerCbIndex, 1);
    }
    blockRequest(res);
    // halts further execution of user code
    return false;
  }

  /**
   * When results are returned from S-R, save them for input tracing.
   *
   * @param {AsyncContext} asyncContext
   * @param {[Object]} resultsList SR-format results list (see handleAgentLibAnalysis)
   * @param {ApplicationContext} appContext request is added to this if not present
   * @param {Object} agentLibResults used only for securityException
   */
  collectSamples(asyncContext, resultsList, appContext, agentLibResults) {
    if (!resultsList || !resultsList.length) {
      return;
    }

    // this shouldn't happen as this is retrieved when the request event
    // is processed, and will be available.
    if (!asyncContext) {
      logger.error('StorageContext not found - Unable to create samples from results list');
      return;
    }

    const { request, defend } = asyncContext;

    if (!appContext.request) {
      appContext.request = request;
    }

    this._collectSamples(defend.samples, resultsList, appContext, agentLibResults);
  }

  /**
   * Collect samples from already checked and present arguments
   */
  _collectSamples(samples, resultsList, appContext, agentLibResults) {
    let blocked = false;

    if (agentLibResults) {
      blocked = !!agentLibResults.securityException;
    }

    for (const result of resultsList) {
      // Coerce custom rule id
      if (result.ruleId === RULES.NOSQL_EXPANSION) {
        result.ruleId = RULES.NOSQL_INJECTION;
      }

      // don't bind all the following vars unless we need to
      if (result.scoreLevel === IMPORTANCE.NONE) {
        continue;
      }

      const {
        scoreLevel,
        ruleId,
        inputType: type,
        path,
        key: name,
        value,
        idsList
      } = result;

      const sample = samples.addRuleSample({
        id: ruleId,
        input: UserInputFactory.makeOne({ name, path, type, value }),
        evaluation: { results: { importance: scoreLevel } },
        appContext
      });

      sample.blocked = blocked;

      // copy over custom info for sink.
      if (result.inputInfo) {
        Object.assign(sample._inputInfoForSink, result.inputInfo);
      }

      sample.filters.push(...idsList);
    }
  }

  updateRules(settings) {
    if (settings) {
      this._ruleFactory.updateSettings(settings, this.enabled);
      this.rules = this._ruleFactory.getRules();
      if (this.agentLibAnalysis) {
        this.addAgentLibBitToRules();
      }
    }
  }

  addAgentLibBitToRules() {
    for (const rule of this.rules) {
      rule.agentLibBit = this.agentLib.RuleType[rule.id];
    }
  }

  updateExclusions(settings) {
    if (settings) {
      this._exclusionFactory.updateSettings({
        settings,
        assess: this.assessEnabled,
        protect: this.protectEnabled
      });
      this.urlExclusions = this._exclusionFactory.getUrlExclusions();
      this.inputExclusions = this._exclusionFactory.getInputExclusions();
    }
  }

  /**
   * Checks if the IP matches the current IP allowlist
   *
   * @param {UserInput} ipInput
   * @return {boolean}
   */
  checkIpAllowList(ipEvent) {
    if (!(this.ipAllowlist && ipEvent)) {
      return;
    }

    const inputKit = new UserInputKit();
    const { data } = ipEvent;
    // hack; we don't have a proper rule to create the inputs from
    const inputs = inputKit.create({}, data, ipEvent.type);
    // length should always just be 1
    const [input] = inputs;
    return this.ipAllowlist.evaluate(input);
  }

  /**
   * Loads IP analyzer for allowist analysis given TS settings.
   */
  updateIpAllowlist(settings) {
    const list = _.get(settings, 'defend.ipAllowlistsList');
    if (list && list.length) {
      this.ipAllowlist = new IpAnalyzer(list);
      this.ipAllowlist.on('expired', (dtm) => {
        const dtms = this.ipAllowlist.dtms.filter((item) => item.id !== dtm.id);
        logger.info(`Defend allowlist item expired: ${dtm.id}.`);
        this.ipAllowlist = new IpAnalyzer(dtms);
      });
    } else {
      this.ipAllowlist = null;
    }
  }

  /**
   * Loads the rules for context storage based on current url exclusions.
   * This is only called by protect/listeners.js and probably belongs there
   * rather than here, but it's here. In any case, listeners sets async
   * context rules based on the return value of this function.
   *
   * @param {string} path
   * @param {SourceEvent} ipEvent created when an http 'request' event occurs
   * @returns {[Rule]} the array of rules that applies to this URL
   *
   * exclusions are an array of exclusion objects.
   * [{
   *  assess: boolean,
   *  assessmentRulesList: [],
   *  defend: boolean,
   *  inputName: string,
   *  inputType: string enum 'PARAMETER', ? (<= querystring & parameter)
   *  isNamed: boolean,
   *  matchStrategy: string enum 'ALL', ?,
   *  name: 'parameter-input',        // name of exclusion
   *  urls: [],
   * }]
   *
   * exclusion inputTypes: BODY, COOKIE, HEADER, PARAMETER - all input types
   * are mapped to one of these four.
   *
   */
  getEnabledRules(path, ipEvent) {
    if (!this.enabled) {
      return [];
    }

    if (this.checkIpAllowList(ipEvent)) {
      return [];
    }

    return this.rules.filter((rule) => {
      const { id } = rule;

      for (const exclusion of this.urlExclusions) {
        if (exclusion.appliesToProtectRule(id) && exclusion.matchesUrl(path)) {
          return false;
        }
      }

      return true;
    });
  }

  /**
   * returns an array of the input exclusions applicable to the current url
   *
   * @param {string} path
   */
  getEnabledInputExclusions(path) {
    if (!this.enabled) {
      return [];
    }

    const { inputExclusions } = this;

    return inputExclusions.filter(
      (exclusion) => exclusion.defend && exclusion.matchesUrl(path)
    );
  }

  /**
   * Dispatches to the appropriate preFilter handler based on the SourceEvent
   * input type. If the event type is an URL_PARAMETER and agent-lib analysis
   * is being used, dispatches to a different handler because agent-lib needs
   * to check url params after the framework has parsed them.
   *
   * @param {SourceEvent} event Source event providing data and context (from lib/protect/listeners).
   * @param {[Rule]} rules enabled rules
   * @param {[InputExclusions]} inputExclusions input exclusions
   * @param {Samples} samples Samples object for this request
   */
  // eslint-disable-next-line complexity
  handleSourceEvent(event, rules, inputExclusions, samples) {
    // reduce the number of rules and exclusions that need to be checked because
    // the event.type does not change.
    rules = rules.filter((rule) => rule.appliesToInputType(event.type));
    if (rules.length === 0) {
      return;
    }
    inputExclusions = inputExclusions.filter((iex) => iex.appliesToInputType(event.type));

    // agent-lib handles raw URLs, bodies, querystrings, headers, etc. but cannot
    // handle URL parameter (e.g., /path/:param/action) because only the framework
    // is aware of them. this function is invoked after the framework has parsed
    // the URL and created the params object. this is important because the params,
    // as represented in the URL, is URI encoded so the normal regexes will not
    // match until the framework has decoded the param.
    if (this.agentLibAnalysis) {
      switch (event.type) {
        case 'URL_PARAMETER': {
          this.handleUrlParametersWithAgentLib(event, rules, inputExclusions, samples);
          break;
        }
        case 'MULTIPART_NAME': {
          this.handleMultipartFilenameWithAgentLib(event, rules, inputExclusions, samples);
          break;
        }
        case 'MULTIPART_VALUE':
        case 'BODY': {
          this.handleMultipartBodyWithAgentLib(event, rules, inputExclusions, samples);
          break;
        }
        case 'COOKIE_VALUE': {
          this.handleCookiesWithAgentLib(event, rules, inputExclusions, samples);
          break;
        }
      }
    }

    // remove agent-lib rules from the list to be handled by node. node handles rules
    // that are not implemented by agent-lib. remove the agent-lib rules so those rules
    // are not executed by both agent-lib and node.
    rules = rules.filter((r) => !r.agentLibBit);
    if (rules.length === 0) {
      return;
    }

    const data = this.filterSafeData(event);
    if (data.length === 0) {
      return;
    }

    const inputKit = new UserInputKit();

    for (const rule of rules) {
      const inputs = inputKit.create(rule, data, event.type);
      for (const input of inputs) {
        if (this.isUserInputExcluded({ inputExclusions, rule, event, input })) {
          continue;
        }
        // for all rules that do not use library input analysis.
        if (!(rule.usesLibInputAnalysis && this.agentLibAnalysis)) {
          logger.debug(`Starting rule analysis: ${input.type} ${input.name}`);
          rule.preFilterUserInput(input, event, samples);
        }
      }
    }
  }

  /**
   * handle protect.source events for URL parameters when agent lib is enabled.
   *
   * @param {SourceEvent} event Source event providing data and context (from lib/protect/listeners).
   * @param {[Rule]} rules enabled rules
   * @param {[InputExclusions]} inputExclusions input exclusions
   * @param {Samples} samples Samples object for this request
   */
  // eslint-disable-next-line complexity
  handleUrlParametersWithAgentLib(event, rules, inputExclusions, samples) {
    const res = event._serverResponse;
    const params = event.data;
    // if it's URL_PARAMETER and there are not params, then why are
    // we here?
    if (!params) {
      logger.debug('handleUrlParametersWithAgentLib - no params found');
      return;
    }

    const srResultsList = [];
    let securityException = false;
    const type = this.agentLib.InputType.UrlParameter;
    const libRules = this.getRulesMask(rules);

    if (!libRules) {
      logger.debug('handleUrlParametersWithAgentLib - no rules');
      return;
    }

    // for each key, check out the value. the key is set in the code so
    // is not vulnerable.
    for (const key in params) {
      // items from scoreAtom() return only [{ruleId, score}, ...] because the key
      // and inputType are already known and there is no path.
      const items = this.agentLib.scoreAtom(libRules, params[key], type);
      if (!items) {
        continue;
      }
      for (const item of items) {
        item.inputType = type;
        const resultItem = Object.assign({ path: [key], value: params[key] }, item);
        const mapped = this.resultItemToSrResultItem(resultItem);
        const input = { type, name: key };
        if (this.isUserInputExcluded({ inputExclusions, rule: { id: mapped.ruleId }, event, input })) {
          continue;
        }
        if (mapped.scoreLevel === 'DEFINITE' && this.getRuleMode(mapped.ruleId) === 'BLOCK_AT_PERIMETER') {
          securityException = true;
        }
        srResultsList.push(mapped);
      }
    }

    this._collectSamples(samples, srResultsList, {}, { securityException });


    if (securityException) {
      this.handleBlockAtPerimeter(res);
    }
  }

  // event.type === MULTIPART_NAME, data: {newrelic.js: 'newrelic.js'}
  handleMultipartFilenameWithAgentLib(event, rules, inputExclusions, samples) {
    const res = event._serverResponse;
    const srResultsList = [];
    let securityException = false;
    // 'MULTIPART_NAME' is apparently used only for filenames; 'MULTIPART_VALUE'
    // is used for multipart KV pairs (and we can just use PARAMETER_KEY/PARAMETER_VALUE).
    const type = this.agentLib.InputType.MultipartName;
    const libRules = this.getRulesMask(rules);

    if (!libRules) {
      logger.debug('handleUrlParametersWithAgentLib - no rules');
      return;
    }

    // why these aren't {filename: 'newrelic.js'} instead of {newrelic.js: 'newrelic.js'}
    // escapes me.
    if (typeof event.data !== 'object') {
      return;
    }
    const filenames = Object.keys(event.data);

    for (const filename of filenames) {
      const items = this.agentLib.scoreAtom(libRules, filename, type);
      if (!items) {
        continue;
      }
      for (const item of items) {
        item.inputType = type;
        const resultItem = Object.assign({ path: [filename], value: filename }, item);
        const mapped = this.resultItemToSrResultItem(resultItem);
        if (mapped.scoreLevel === 'DEFINITE' && this.getRuleMode(mapped.ruleId) === 'BLOCK_AT_PERIMETER') {
          securityException = true;
        }
        srResultsList.push(mapped);
      }
    }

    this._collectSamples(samples, srResultsList, {}, { securityException });

    if (securityException) {
      this.handleBlockAtPerimeter(res);
    }
  }

  handleMultipartBodyWithAgentLib(event, rules, inputExclusions, samples) {
    const rulesMask = this.getRulesMask(rules);
    if (!rulesMask || typeof event.data !== 'object' || !event._ctxt) {
      return;
    }
    // just treat these as an array of query params.
    const queries = Object.entries(event.data)
      .filter(i => typeof i[1] === 'string')
      .reduce((queries, q) => {
        queries.unshift(...q); return queries;
      }, []);

    const arg = { queries };

    const findings = this.agentLib.scoreRequestConnect(rulesMask, arg, evalOptions);

    this.handleAgentLibAnalysis({
      asyncStorageContext: event._ctxt,
      appContext: {},
      agentSettings: findings,
      req: event._incomingMessage,
      res: event._serverResponse,
    });
  }

  handleCookiesWithAgentLib(event, rules, inputExclusions, samples) {
    const cookies = Object.entries(event.data).reduce((acc, [key, value]) => {
      acc.unshift(key, value);
      return acc;
    }, []);
    const rulesMask = this.getRulesMask(rules);
    const arg = { cookies };
    const findings = this.agentLib.scoreRequestConnect(rulesMask, arg, evalOptions);
    this.handleAgentLibAnalysis({
      asyncStorageContext: event._ctxt,
      appContext: {},
      agentSettings: findings,
      req: event._incomingMessage,
      res: event._serverResponse,
    });

  }

  /**
   * check a rule/input combination against the specified exclusions.
   *
   * @param {[Exclusion]} exclusions array of exclusions to check against
   * @param {String} ruleId the rule ID
   * @param {String} inputType the type of the input
   * @param {String} inputName the key for JSON objects and KV pairs
   *
   * @returns {String|null} the name of the exclusion that applied, or null.
   */
  shouldExclude(exclusions, ruleId, inputType, inputName) {
    for (const exclusion of exclusions) {
      if (exclusion.shouldExclude(ruleId, inputType, inputName)) {
        return exclusion.name;
      }
    }
    return null;
  }

  isUserInputExcluded({ inputExclusions, rule, event, input }) {
    let excluded;
    for (const exclusion of inputExclusions) {
      excluded = exclusion.shouldExclude(rule.id, input.type, input.name);
      if (excluded) {
        logger.debug(`EXCLUSION: ${exclusion.name} - ${input.type} '${input.name}'`);
        break;
      }
    }
    return excluded;
  }

  skipEventHandling(rules) {
    return _.isEmpty(rules);
  }

  /**
   * Given a SinkEvent, will run applicable rule evaluations.
   * @param {Object} params
   * @param {SinkEvent} params.event
   * @param {[Rule]} params.rules
   * @param {[Samples]} params.samples worthWatching/definite
   */
  handleSinkEvent({ event, rules, samples }) {
    if (_.isEmpty(rules)) {
      return;
    }

    const { request } = event;
    for (const rule of rules) {
      if (!rule.appliesToSink(event.type)) {
        continue;
      }

      const applicableSamples = samples.getAll(rule.id);
      // this should be tested here as opposed to constructing an object
      // and passing it to evaluateAtSink*(). but tests expect that
      // evaluateAtSink*() gets called and they don't bother to set up
      // appopriate samples and event data. so, comment it out for now.
      //if (applicableSamples.size === 0 || !event.data) {
      //  continue;
      //}

      // Do we want to use the standard node evaluator or the library sink
      // evaluation (which requires data from the library's input analysis stage)?
      const args = { event, samples, applicableSamples, request };

      if (!this.agentLibAnalysis || !rule.evaluateAtSinkForLib) {
        rule.evaluateAtSink(args);
      } else {
        rule.evaluateAtSinkForLib(args);
      }
    }
  }

  /**
   * Filters out data that is "safe" meaning not applicable to any
   * protect rule evaluations
   *
   * Related Ticket: https://contrast.atlassian.net/browse/CONTRAST-35257
   *
   * @param {event.type} type This is an INPUT_TYPE
   * @param {event.data} data Data pertaining to the SourceEvent
   */
  filterSafeData({ type, data }) {
    switch (type) {
      case INPUT_TYPES.HEADER:
        return this.filterSafeHeaders(data);
      default:
        return data;
    }
  }

  /**
   *
   * Filters out any headers that either match a list of "safe" values
   * or match the specification for specific header see ./validators
   * for info on each header validator
   *
   * @param   {Object} data key/value of request headers
   * @returns {Object} A headers object with safe headers filtered out
   */
  filterSafeHeaders(data) {
    return _.reduce(
      data,
      (accum, value, key) => {
        const headerName = key.toLowerCase();

        if (SAFE_HEADER_VALUES.indexOf(value) !== -1) {
          return accum;
        }

        const isSafeHeader = headerValidators[headerName];

        if (typeof isSafeHeader === 'function' && isSafeHeader(value)) {
          return accum;
        }

        accum[headerName] = value;
        return accum;
      },
      {}
    );
  }

  /**
   * Samples collected for the current request will be processed.
   * The security logger will make an entry for each sample collected;
   *
   * @param {Array<Rule>} rules
   * @param {Samples} samples
   */
  submitFindings(rules, samples) {
    const findings = this.createFindings(rules, samples);
    const aggregated = SampleAggregator.aggregate(findings, (finding) => this.wwFilter(finding));
    for (const finding of aggregated) {
      agentEmitter.emit('attack', finding);
    }
  }

  /**
   * See: https://contrast.atlassian.net/browse/NODE-670
   * The way we collect findings in SR vs node
   * input analysis differs.  It may be because of bugs in
   * node input analysis but since that is going away soon
   * we have forked how we add findings from SR input analysis
   * from node agent
   *
   * @param {Object} params
   * @param {Array} findings to report
   * @param {Set} ruleSamples of all samples for a given rule
   * @param {Rule} protect rule object
   * @param {Boolean} speedracer speedracer analysis is being used. this
   * includes agent-lib, which uses the speedracer logic.
   */
  addFindings({ findings, ruleSamples, rule, speedracer }) {
    const qsSamples = [];
    let hasEffectiveParamInputs = false;

    for (const sample of ruleSamples) {
      if (sample.input.type === INPUT_TYPES.URI) {
        // forget about URL things
        continue;
      }

      // is the sample a parameter name or value?
      hasEffectiveParamInputs = hasEffectiveParamInputs ||
        INPUT_TYPES.PARAMETER_VALUE === sample.input.type ||
        INPUT_TYPES.PARAMETER_NAME === sample.input.type;

      // saving reference to QUERYSTRING sample in case
      // there are no Parameter type samples for rule
      if (sample.input.type === INPUT_TYPES.QUERYSTRING) {
        qsSamples.push(sample);
      } else {
        findings.push({
          rule,
          ruleId: rule.id,
          sample,
          status: sample.getStatus()
        });
      }
    }

    // https://contrast.atlassian.net/browse/NODE-660 - only report one attack
    // when there are both QUERYSTRING and PARAMETER_VALUE types for a given rule.
    if (qsSamples.length > 0 && !hasEffectiveParamInputs) {
      for (const qsSample of qsSamples) {
        findings.push({
          rule,
          ruleId: rule.id,
          sample: qsSample,
          status: qsSample.getStatus()
        });
      }
    }
  }

  /**
   * From a set of rules, it creates a collection "findings"
   * for all samples collected from each rule.
   *
   * @param   {Rule[]} rules Rules from which to build findings
   * @returns {Object[]}     The findings from the rules
   */
  // eslint-disable-next-line default-param-last
  createFindings(rules = [], samples) {
    const findings = [];
    const speedracer = this.reporter.speedracer &&
      this.config.agent.node.speedracer_input_analysis;

    for (const rule of rules) {
      const { id } = rule;
      const ruleSamples = samples.getAll(id);
      // no need to call add findings if no samples
      if (ruleSamples.size === 0) {
        continue;
      }

      // only support SR format now; previously there was logic to handle node
      // analysis differently than SR analysis. agent-lib mimics SR, so both
      // should be the same now.
      this.addFindings({ findings, ruleSamples, rule, speedracer });
    }

    return findings;
  }

  // worth-watching filter. this is located here so agent-lib isn't exposed to the
  // sample aggregator any more than necessary (agentLibBit is exposed).
  //
  // returns true if the finding should be reported as a probe, else false
  wwFilter(finding) {
    const { agentLibBit } = finding.rule;
    const { _type, _value: input } = finding.sample.input;
    const type = this.agentLib.InputType[_type];

    const alFinding = this.agentLib.scoreAtom(agentLibBit, input, type);
    if (!alFinding) {
      return false;
    }
    if (alFinding.length > 1) {
      logger.debug(`scoreAtom() returned ${alFinding.length} findings`);
    }
    return alFinding[0].score >= 90;
  }
}

module.exports = ProtectService;
