import { get , flow } from 'lodash' ; import moment from 'moment' ; import rison , { RisonObject , RisonValue } from 'rison-node' ; import { parseInterval } from '../../../common/util/parse_interval' ; import { escapeForElasticsearchQuery , replaceStringTokens } from './string_utils' ; import { UrlConfig , KibanaUrlConfig , CustomUrlAnomalyRecordDoc , } from '../../../common/types/custom_urls' ; import { AnomalyRecordDoc } from '../../../common/types/anomalies' ; const TIME_RANGE_AUTO = 'auto' ; export function replaceTokensInUrlValue ( customUrlConfig , jobBucketSpanSecs , doc , timeFieldName ) { const urlValue = customUrlConfig . url_value ; const timestamp = doc [ timeFieldName ] ; const timeRangeInterval = 'time_range' in customUrlConfig && customUrlConfig . time_range ? parseInterval ( customUrlConfig . time_range ) : null ; const record = { ... doc } as CustomUrlAnomalyRecordDoc ; if ( urlValue . includes ( '$earliest$' ) ) { const earliestMoment = moment ( timestamp ) ; if ( timeRangeInterval !== null ) { earliestMoment . subtract ( timeRangeInterval ) ; } else { earliestMoment . subtract ( jobBucketSpanSecs , 's' ) ; } record . earliest = earliestMoment . toISOString ( ) ; } if ( urlValue . includes ( '$latest$' ) ) { const latestMoment = moment ( timestamp ) . add ( jobBucketSpanSecs , 's' ) ; if ( timeRangeInterval !== null ) { latestMoment . add ( timeRangeInterval ) ; } else { latestMoment . add ( jobBucketSpanSecs , 's' ) ; } record . latest = latestMoment . toISOString ( ) ; } return getUrlForRecord ( customUrlConfig , record ) ; } export function getUrlForRecord ( urlConfig , record ) { if ( isKibanaUrl ( urlConfig ) === true ) { return buildKibanaUrl ( urlConfig , record ) ; } else { const urlPath = replaceStringTokens ( urlConfig . url_value , record , false ) ; return urlPath ; } } export function openCustomUrlWindow ( url , urlConfig , basePath ) { if ( /^(?:[a-z]+:)?\/\//i . test ( urlConfig . url_value ) === false ) { if ( url . charAt ( 0 ) !== '/' ) { url = `${ basePath }${ isKibanaUrl ( urlConfig ) ? '/app/' : '/' }${ url }` ; } window . open ( url , '_blank' ) ; } else { const newWindow = window . open ( url , '_blank' , 'noopener,noreferrer' ) ; if ( newWindow !== undefined && newWindow !== null ) { newWindow . opener = null ; } } } function isKibanaUrl ( urlConfig ) { const urlValue = urlConfig . url_value ; return ( urlValue . startsWith ( 'discover#/' ) || urlValue . startsWith ( 'dashboards#/' ) || urlValue . startsWith ( 'apm#/' ) || urlValue . startsWith ( 'metrics/' ) || urlValue . startsWith ( 'security/' ) || urlValue . startsWith ( 'siem#/' ) ) ; } /**
 * Escape any double quotes in the value for correct use in KQL.
 */ export function escapeForKQL ( value ) { return String ( value ) . replace ( /\"/g , '\\"' ) ; } type GetResultTokenValue = ( v : string ) => string ; export const isRisonObject = ( value ) : value is RisonObject => { return value !== null && typeof value === 'object' ; } ; /**
 * Helper to grab field value from the string containing field value & name
 * which also handle special characters like colons and spaces
 * `odd:field$name&:"$odd:field$name&$"` => 'odd:field$name&'
 */ export const getQueryField = ( str ) : string => { let fieldName = '' ; for ( let i = 0 ; i < str . length ; i ++ ) { if ( str [ i ] === '$' ) { let foundIdxToSplit = i ; for ( let idx = foundIdxToSplit ; idx > - 1 ; idx -- ) { if ( str [ idx ] === ':' ) { foundIdxToSplit = idx ; break ; } } fieldName = str . slice ( 0 , foundIdxToSplit ) . trim ( ) ; let fieldValue = str . slice ( foundIdxToSplit , str . length ) ; const fieldValueStart = fieldValue . indexOf ( '$' ) ; const fieldValueEnd = fieldValue . lastIndexOf ( '$' ) ; fieldValue = fieldValue . slice ( fieldValueStart , fieldValueEnd + 1 ) ; if ( fieldValue === `$${ fieldName }$` ) { break ; } } } return fieldName ; } ; const getQueryStringResultProvider = ( record , getResultTokenValue ) => ( resultPrefix , queryString , resultPostfix , isKuery ) : string => { const URL_LENGTH_LIMIT = 2000 ; let availableCharactersLeft = URL_LENGTH_LIMIT - resultPrefix . length - resultPostfix . length ; const testStr = queryString ; const queryFields = testStr . split ( /\sand\s/i ) . map ( ( v ) => getQueryField ( String ( v ) . replace ( /\\/g , '' ) ) ) ; const queryParts = [ ] ; const joinOperator = ' AND ' ; fieldsLoop : for ( let i = 0 ; i < queryFields . length ; i ++ ) { const field = queryFields [ i ] ; const fieldName = isKuery ? `"${ queryFields [ i ] }"` : escapeForElasticsearchQuery ( field ) ; let tokenValues = get ( record , field ) || null ; if ( tokenValues === null ) { continue ; } tokenValues = Array . isArray ( tokenValues ) ? tokenValues : [ tokenValues ] ; let result = '' ; for ( let j = 0 ; j < tokenValues . length ; j ++ ) { const part = `${ j > 0 ? ' OR ' : '' }${ fieldName }:"${ getResultTokenValue ( tokenValues [ j ] ) }"` ; if ( availableCharactersLeft < part . length ) { if ( result . length > 0 ) { queryParts . push ( j > 0 ? `(${ result })` : result ) ; } break fieldsLoop ; } result += part ; availableCharactersLeft -= result . length ; } if ( result . length > 0 ) { queryParts . push ( tokenValues . length > 1 ? `(${ result })` : result ) ; } } return queryParts . join ( joinOperator ) ; } ; /**
 * Builds a Kibana dashboard or Discover URL from the supplied config, with any
 * dollar delimited tokens substituted from the supplied anomaly record.
 */ function buildKibanaUrl ( urlConfig , record ) { const urlValue = urlConfig . url_value ; const isLuceneQueryLanguage = urlValue . includes ( 'language:lucene' ) ; const queryLanguageEscapeCallback = isLuceneQueryLanguage ? escapeForElasticsearchQuery : escapeForKQL ; const commonEscapeCallback = flow ( encodeURIComponent ) ; const replaceSingleTokenValues = ( str ) => { const getResultTokenValue = flow ( isLuceneQueryLanguage ? < T > ( v ) => v : queryLanguageEscapeCallback , commonEscapeCallback ) ; return str . replace ( /\$([^?&$\'"]+)\$(\/)?/g , ( match , name , slash = '' ) => { let tokenValue = get ( record , name ) ; tokenValue = Array . isArray ( tokenValue ) ? tokenValue [ 0 ] : tokenValue ; return tokenValue === undefined ? '' : getResultTokenValue ( tokenValue ) + slash ; } ) ; } ; return flow ( decodeURIComponent , ( str ) => str . replace ( '$earliest$' , record . earliest ) . replace ( '$latest$' , record . latest ) , ( str ) => { const getResultTokenValue = flow ( queryLanguageEscapeCallback , commonEscapeCallback ) ; const getQueryStringResult = getQueryStringResultProvider ( record , getResultTokenValue ) ; const match = str . match ( /(.+)(\(.*\blanguage:(?:lucene|kuery)\b.*?\))(.+)/ ) ; if ( match !== null && match [ 2 ] !== undefined ) { const [ , prefix , queryDef , postfix ] = match ; const isKuery = queryDef . indexOf ( 'language:kuery' ) > - 1 ; const q = rison . decode ( queryDef ) ; if ( isRisonObject ( q ) && q . hasOwnProperty ( 'query' ) ) { const [ resultPrefix , resultPostfix ] = [ prefix , postfix ] . map ( replaceSingleTokenValues ) ; const resultQuery = getQueryStringResult ( resultPrefix , q . query as string , resultPostfix , isKuery ) ; return `${ resultPrefix }${ rison . encode ( { ... q , query : resultQuery } ) }${ resultPostfix }` ; } } return str . replace ( /(.+&kuery=)(.*?)[^!](&.+)/ , ( fullMatch , prefix , queryString , postfix ) => { const [ resultPrefix , resultPostfix ] = [ prefix , postfix ] . map ( replaceSingleTokenValues ) ; const resultQuery = getQueryStringResult ( resultPrefix , queryString , resultPostfix , str . indexOf ( 'language:kuery' ) > - 1 ) ; return `${ resultPrefix }${ resultQuery }${ resultPostfix }` ; } ) ; } , replaceSingleTokenValues ) ( urlValue ) ; } export function isValidLabel ( label , savedCustomUrls ) { let isValid = label !== undefined && label . trim ( ) . length > 0 ; if ( isValid === true && savedCustomUrls !== undefined ) { const existingLabels = savedCustomUrls . map ( ( customUrl ) => customUrl . url_name ) ; isValid = ! existingLabels . includes ( label ) ; } return isValid ; } export function isValidTimeRange ( timeRange ) { if ( timeRange === undefined || timeRange . length === 0 || timeRange === TIME_RANGE_AUTO ) { return true ; } const interval = parseInterval ( timeRange ) ; return interval !== null ; }