diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/risk_score/__snapshots__/calculate_esql_risk_scores.test.ts.snap b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/risk_score/__snapshots__/calculate_esql_risk_scores.test.ts.snap index b1b76fc74e82e..06763ba1b3b9e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/risk_score/__snapshots__/calculate_esql_risk_scores.test.ts.snap +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/risk_score/__snapshots__/calculate_esql_risk_scores.test.ts.snap @@ -10,7 +10,9 @@ exports[`Calculate risk scores with ESQL ESQL query matches snapshot 1`] = ` kibana.alert.uuid as alert_id, event.kind as category, @timestamp as time - | EVAL input = CONCAT(\\"\\"\\" {\\"risk_score\\": \\"\\"\\"\\", risk_score::keyword, \\"\\"\\"\\", \\"time\\": \\"\\"\\"\\", time::keyword, \\"\\"\\"\\", \\"index\\": \\"\\"\\"\\", _index, \\"\\"\\"\\", \\"rule_name\\": \\"\\"\\"\\", rule_name, \\"\\"\\"\\", \\"category\\": \\"\\"\\"\\", category, \\"\\"\\"\\", \\"id\\": \\"\\"\\"\\", alert_id, \\"\\"\\"\\" } \\"\\"\\") + | EVAL rule_name_b64 = TO_BASE64(rule_name), + category_b64 = TO_BASE64(category) + | EVAL input = CONCAT(\\"\\"\\" {\\"risk_score\\": \\"\\"\\"\\", risk_score::keyword, \\"\\"\\"\\", \\"time\\": \\"\\"\\"\\", time::keyword, \\"\\"\\"\\", \\"index\\": \\"\\"\\"\\", _index, \\"\\"\\"\\", \\"rule_name_b64\\": \\"\\"\\"\\", rule_name_b64, \\"\\"\\"\\", \\"category_b64\\": \\"\\"\\"\\", category_b64, \\"\\"\\"\\", \\"id\\": \\"\\"\\"\\", alert_id, \\"\\"\\"\\" } \\"\\"\\") | STATS alert_count = count(risk_score), scores = MV_PSERIES_WEIGHTED_SUM(TOP(risk_score, 10000, \\"desc\\"), 1.5), diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_esql_risk_scores.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_esql_risk_scores.test.ts index e25ebce03b61d..95cef24d312bd 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_esql_risk_scores.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_esql_risk_scores.test.ts @@ -100,5 +100,189 @@ describe('Calculate risk scores with ESQL', () => { expect(bucket).toEqual(expected); }); + + /* The below tests are a result of https://github.com/elastic/sdh-security-team/issues/1529 */ + + describe('Rule name and category special characters', () => { + it('decodes Base64 encoded rule_name and category', () => { + // Simulate ESQL TO_BASE64 output + const ruleNameWithQuotes = 'Test "Quoted" Alert'; + const categoryWithBackslash = 'signal\\test'; + const ruleNameB64 = Buffer.from(ruleNameWithQuotes, 'utf-8').toString('base64'); + const categoryB64 = Buffer.from(categoryWithBackslash, 'utf-8').toString('base64'); + + const inputs = [ + `{ "risk_score": "75", "time": "2021-08-23T18:00:05.000Z", "index": ".alerts-security.alerts-default", "rule_name_b64": "${ruleNameB64}", "category_b64": "${categoryB64}", "id": "test_id_1" }`, + ]; + const alertCount = 1; + const riskScore = 75; + const entityValue = 'hostname'; + + const esqlResultRow = [alertCount, riskScore, inputs, entityValue]; + + const bucket = buildRiskScoreBucket( + EntityType.host, + '.alerts-security.alerts-default' + )(esqlResultRow as FieldValue[]); + + expect(bucket.top_inputs.risk_details.value.risk_inputs[0].rule_name).toBe( + ruleNameWithQuotes + ); + expect(bucket.top_inputs.risk_details.value.risk_inputs[0].category).toBe( + categoryWithBackslash + ); + }); + + it('handles rule names with double quotes', () => { + const ruleName = 'Alert: "Suspicious Activity" Detected'; + const ruleNameB64 = Buffer.from(ruleName, 'utf-8').toString('base64'); + + const inputs = [ + `{ "risk_score": "80", "time": "2021-08-23T18:00:05.000Z", "index": ".alerts-security.alerts-default", "rule_name_b64": "${ruleNameB64}", "category_b64": "c2lnbmFs", "id": "test_id_1" }`, + ]; + + const esqlResultRow = [1, 80, inputs, 'hostname']; + const bucket = buildRiskScoreBucket( + EntityType.host, + '.alerts-security.alerts-default' + )(esqlResultRow as FieldValue[]); + + expect(bucket.top_inputs.risk_details.value.risk_inputs[0].rule_name).toBe(ruleName); + }); + + it('handles rule names with backslashes', () => { + const ruleName = 'C:\\Windows\\System32\\malware.exe'; + const ruleNameB64 = Buffer.from(ruleName, 'utf-8').toString('base64'); + + const inputs = [ + `{ "risk_score": "90", "time": "2021-08-23T18:00:05.000Z", "index": ".alerts-security.alerts-default", "rule_name_b64": "${ruleNameB64}", "category_b64": "c2lnbmFs", "id": "test_id_1" }`, + ]; + + const esqlResultRow = [1, 90, inputs, 'hostname']; + const bucket = buildRiskScoreBucket( + EntityType.host, + '.alerts-security.alerts-default' + )(esqlResultRow as FieldValue[]); + + expect(bucket.top_inputs.risk_details.value.risk_inputs[0].rule_name).toBe(ruleName); + }); + + it('handles rule names with newlines and tabs', () => { + const ruleName = 'Multi\nLine\tRule'; + const ruleNameB64 = Buffer.from(ruleName, 'utf-8').toString('base64'); + + const inputs = [ + `{ "risk_score": "85", "time": "2021-08-23T18:00:05.000Z", "index": ".alerts-security.alerts-default", "rule_name_b64": "${ruleNameB64}", "category_b64": "c2lnbmFs", "id": "test_id_1" }`, + ]; + + const esqlResultRow = [1, 85, inputs, 'hostname']; + const bucket = buildRiskScoreBucket( + EntityType.host, + '.alerts-security.alerts-default' + )(esqlResultRow as FieldValue[]); + + expect(bucket.top_inputs.risk_details.value.risk_inputs[0].rule_name).toBe(ruleName); + }); + + it('handles rule names with mixed special characters', () => { + const ruleName = 'Alert: "Path\\To\\File"\nWith Newline\tAnd Tab'; + const category = 'Category with "quotes" and \\backslashes\\'; + const ruleNameB64 = Buffer.from(ruleName, 'utf-8').toString('base64'); + const categoryB64 = Buffer.from(category, 'utf-8').toString('base64'); + + const inputs = [ + `{ "risk_score": "95", "time": "2021-08-23T18:00:05.000Z", "index": ".alerts-security.alerts-default", "rule_name_b64": "${ruleNameB64}", "category_b64": "${categoryB64}", "id": "test_id_1" }`, + ]; + + const esqlResultRow = [1, 95, inputs, 'hostname']; + const bucket = buildRiskScoreBucket( + EntityType.host, + '.alerts-security.alerts-default' + )(esqlResultRow as FieldValue[]); + + expect(bucket.top_inputs.risk_details.value.risk_inputs[0].rule_name).toBe(ruleName); + expect(bucket.top_inputs.risk_details.value.risk_inputs[0].category).toBe(category); + }); + + it('handles Unicode characters', () => { + const ruleName = 'Alert: 你好世界 🔥 Émojis'; + const ruleNameB64 = Buffer.from(ruleName, 'utf-8').toString('base64'); + + const inputs = [ + `{ "risk_score": "70", "time": "2021-08-23T18:00:05.000Z", "index": ".alerts-security.alerts-default", "rule_name_b64": "${ruleNameB64}", "category_b64": "c2lnbmFs", "id": "test_id_1" }`, + ]; + + const esqlResultRow = [1, 70, inputs, 'hostname']; + const bucket = buildRiskScoreBucket( + EntityType.host, + '.alerts-security.alerts-default' + )(esqlResultRow as FieldValue[]); + + expect(bucket.top_inputs.risk_details.value.risk_inputs[0].rule_name).toBe(ruleName); + }); + }); + + describe('Backward compatibility', () => { + it('handles old format without Base64 encoding (rule_name without _b64 suffix)', () => { + const inputs = [ + '{ "risk_score": "50", "time": "2021-08-23T18:00:05.000Z", "index": ".alerts-security.alerts-default", "rule_name": "Old Format Rule", "category": "signal", "id": "test_id_1" }', + ]; + const alertCount = 1; + const riskScore = 50; + const entityValue = 'hostname'; + + const esqlResultRow = [alertCount, riskScore, inputs, entityValue]; + + const bucket = buildRiskScoreBucket( + EntityType.host, + '.alerts-security.alerts-default' + )(esqlResultRow as FieldValue[]); + + expect(bucket.top_inputs.risk_details.value.risk_inputs[0].rule_name).toBe( + 'Old Format Rule' + ); + expect(bucket.top_inputs.risk_details.value.risk_inputs[0].category).toBe('signal'); + }); + + it('prefers Base64 encoded fields over plain fields when both exist', () => { + const correctRuleName = 'Rule Name like this would make life so much easier'; + const ruleNameB64 = Buffer.from(correctRuleName, 'utf-8').toString('base64'); + + const inputs = [ + `{ "risk_score": "60", "time": "2021-08-23T18:00:05.000Z", "index": ".alerts-security.alerts-default", "rule_name": "Wrong Name", "rule_name_b64": "${ruleNameB64}", "category": "wrong", "category_b64": "Y29ycmVjdA==", "id": "test_id_1" }`, + ]; + + const esqlResultRow = [1, 60, inputs, 'hostname']; + const bucket = buildRiskScoreBucket( + EntityType.host, + '.alerts-security.alerts-default' + )(esqlResultRow as FieldValue[]); + + expect(bucket.top_inputs.risk_details.value.risk_inputs[0].rule_name).toBe(correctRuleName); + expect(bucket.top_inputs.risk_details.value.risk_inputs[0].category).toBe('correct'); + }); + }); + + describe('Multiple inputs with mixed formats', () => { + it('handles array of inputs with both Base64 and plain text', () => { + const ruleNameB64 = Buffer.from('Test "Quoted" Alert', 'utf-8').toString('base64'); + const inputs = [ + `{ "risk_score": "75", "time": "2021-08-23T18:00:05.000Z", "index": ".alerts-security.alerts-default", "rule_name_b64": "${ruleNameB64}", "category_b64": "c2lnbmFs", "id": "test_id_1" }`, + '{ "risk_score": "50", "time": "2021-08-22T18:00:04.000Z", "index": ".alerts-security.alerts-default", "rule_name": "Plain Rule", "category": "signal", "id": "test_id_2" }', + ]; + + const esqlResultRow = [2, 125, inputs, 'hostname']; + const bucket = buildRiskScoreBucket( + EntityType.host, + '.alerts-security.alerts-default' + )(esqlResultRow as FieldValue[]); + + expect(bucket.top_inputs.risk_details.value.risk_inputs).toHaveLength(2); + expect(bucket.top_inputs.risk_details.value.risk_inputs[0].rule_name).toBe( + 'Test "Quoted" Alert' + ); + expect(bucket.top_inputs.risk_details.value.risk_inputs[1].rule_name).toBe('Plain Rule'); + }); + }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_esql_risk_scores.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_esql_risk_scores.ts index d1a23e4a2edd5..b79ffa2d7137b 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_esql_risk_scores.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_esql_risk_scores.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { isEmpty } from 'lodash'; +import { isEmpty, omit } from 'lodash'; import type { FieldValue, QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; @@ -366,7 +366,9 @@ export const getESQL = ( kibana.alert.uuid as alert_id, event.kind as category, @timestamp as time - | EVAL input = CONCAT(""" {"risk_score": """", risk_score::keyword, """", "time": """", time::keyword, """", "index": """", _index, """", "rule_name": """", rule_name, """\", "category": """", category, """\", "id": \"""", alert_id, """\" } """) + | EVAL rule_name_b64 = TO_BASE64(rule_name), + category_b64 = TO_BASE64(category) + | EVAL input = CONCAT(""" {"risk_score": """", risk_score::keyword, """", "time": """", time::keyword, """", "index": """", _index, """", "rule_name_b64": """", rule_name_b64, """\", "category_b64": """", category_b64, """\", "id": \"""", alert_id, """\" } """) | STATS alert_count = count(risk_score), scores = MV_PSERIES_WEIGHTED_SUM(TOP(risk_score, ${sampleSize}, "desc"), ${RIEMANN_ZETA_S_VALUE}), @@ -390,12 +392,43 @@ export const buildRiskScoreBucket = ]; const inputs = (Array.isArray(_inputs) ? _inputs : [_inputs]).map((input, i) => { - const parsedRiskInputData = JSON.parse(input); + let parsedRiskInputData = JSON.parse('{}'); + let ruleName: string | undefined; + let category: string | undefined; + + try { + // Parse JSON and decode Base64 encoded fields to handle special characters (quotes, backslashes, newlines, etc.) + parsedRiskInputData = JSON.parse(input); + + ruleName = parsedRiskInputData.rule_name_b64 + ? Buffer.from(parsedRiskInputData.rule_name_b64, 'base64').toString('utf-8') + : parsedRiskInputData.rule_name; // Fallback for backward compatibility + category = parsedRiskInputData.category_b64 + ? Buffer.from(parsedRiskInputData.category_b64, 'base64').toString('utf-8') + : parsedRiskInputData.category; // Fallback for backward compatibility + } catch { + // Attempt to use fallback values if parsedRiskInputData was parsed but decoding failed + if (parsedRiskInputData && Object.keys(parsedRiskInputData).length > 0) { + ruleName = parsedRiskInputData.rule_name; + category = parsedRiskInputData.category; + } + } + const value = parseFloat(parsedRiskInputData.risk_score); const currentScore = value / Math.pow(i + 1, RIEMANN_ZETA_S_VALUE); - const { risk_score: _, ...otherFields } = parsedRiskInputData; + const otherFields = omit(parsedRiskInputData, [ + 'risk_score', + 'rule_name', + 'rule_name_b64', + 'category', + 'category_b64', + ]); + return { + id: parsedRiskInputData.id, ...otherFields, + rule_name: ruleName, + category, score: value, contribution: currentScore / RIEMANN_ZETA_VALUE, index, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/types.ts index 0f9ce035f1587..523d63aaae7a3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/types.ts @@ -48,6 +48,7 @@ export interface SearchHitRiskInput { id: string; index: string; rule_name?: string; + category?: string; time?: string; score?: number; contribution?: number;