Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}),
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface SearchHitRiskInput {
id: string;
index: string;
rule_name?: string;
category?: string;
time?: string;
score?: number;
contribution?: number;
Expand Down