[SIEM] [Detection Engine] Adds filtering abilities to the KQL REST API (#49451)
## Summary * Removes the older beginner KQL type of signal creation in favor of newer version with filtering * Adds ability to create KQL or lucene queries that will work with the UI filters * UI state with the filters are now savable to re-hydrate UI's on the front end * Adds `saved_id` ability so the UI can tether dynamic saved queries with signals * Changed `it` to `test` as `it` is not the alias we use for tests * Updated script which converts older saved searches to work with newer mechanism * Fixed script to accept proper ndjson lines * Adds validation unit tests for the endpoint * Increases validation strictness of the endpoints * Adds more data scripts for testing scenarios * https://github.com/elastic/kibana/issues/47013 ## Testing * Run `./hard_reset.sh` script * Test with both algorithms through this toggle before starting kibana: `export USE_REINDEX_API=true` * Convert older saved searches to compatible new query filters by running: `./convert_saved_search_to_signals.sh ~/projects/saved_searches /tmp/signals` * Post them`./post_signal.sh /tmp/signals/*.json` * Hard reset again * Test smaller set of signals and REST endpoints using the typical scripts of: ```sh ./post_signal.sh ./read_signal.sh ./find_signals.sh ./update_signal.sh ./delete_signal.sh ``` or test using POSTMAN, etc... If you want to test validation. If you see any validation issues let me know as I have validation testing files and can easily fix them add another unit test to the growing large collection we have now. Change in your advanced settings of SIEM to use your signals index you configured for verification that the signals show up. ### Checklist Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR. ~~- [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)~~ ~~- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)~~ ~~- [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~~ - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios ~~- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~~ ### For maintainers ~~- [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~ ~~- [ ] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~
This commit is contained in:
parent
69f47a9b90
commit
a4f37cd9e0
|
@ -30,7 +30,7 @@ const path = require('path');
|
|||
// into another repository.
|
||||
const INTERVAL = '24h';
|
||||
const SEVERITY = 'low';
|
||||
const TYPE = 'kql';
|
||||
const TYPE = 'query';
|
||||
const FROM = 'now-24h';
|
||||
const TO = 'now';
|
||||
const INDEX = ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'];
|
||||
|
@ -70,43 +70,74 @@ async function main() {
|
|||
const files = process.argv[2];
|
||||
const outputDir = process.argv[3];
|
||||
|
||||
const savedSearchesJson = walk(files).filter(file => file.endsWith('.ndjson'));
|
||||
const savedSearchesJson = walk(files).filter(file => {
|
||||
return !path.basename(file).startsWith('.') && file.endsWith('.ndjson');
|
||||
});
|
||||
|
||||
const savedSearchesParsed = savedSearchesJson.reduce((accum, json) => {
|
||||
const jsonFile = fs.readFileSync(json, 'utf8');
|
||||
try {
|
||||
const parsedFile = JSON.parse(jsonFile);
|
||||
parsedFile._file = json;
|
||||
parsedFile.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.parse(
|
||||
parsedFile.attributes.kibanaSavedObjectMeta.searchSourceJSON
|
||||
);
|
||||
return [...accum, parsedFile];
|
||||
} catch (err) {
|
||||
return accum;
|
||||
}
|
||||
const jsonLines = jsonFile.split(/\r{0,1}\n/);
|
||||
const parsedLines = jsonLines.reduce((accum, line, index) => {
|
||||
try {
|
||||
const parsedLine = JSON.parse(line);
|
||||
if (index !== 0) {
|
||||
parsedLine._file = `${json.substring(0, json.length - '.ndjson'.length)}_${String(
|
||||
index
|
||||
)}.ndjson`;
|
||||
} else {
|
||||
parsedLine._file = json;
|
||||
}
|
||||
parsedLine.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.parse(
|
||||
parsedLine.attributes.kibanaSavedObjectMeta.searchSourceJSON
|
||||
);
|
||||
return [...accum, parsedLine];
|
||||
} catch (err) {
|
||||
console.log('error parsing a line in this file:', json);
|
||||
return accum;
|
||||
}
|
||||
}, []);
|
||||
return [...accum, ...parsedLines];
|
||||
}, []);
|
||||
|
||||
savedSearchesParsed.forEach(savedSearch => {
|
||||
const fileToWrite = cleanupFileName(savedSearch._file);
|
||||
savedSearchesParsed.forEach(
|
||||
({
|
||||
_file,
|
||||
attributes: {
|
||||
description,
|
||||
title,
|
||||
kibanaSavedObjectMeta: {
|
||||
searchSourceJSON: {
|
||||
query: { query, language },
|
||||
filter,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) => {
|
||||
const fileToWrite = cleanupFileName(_file);
|
||||
|
||||
const query = savedSearch.attributes.kibanaSavedObjectMeta.searchSourceJSON.query.query;
|
||||
if (query != null && query.trim() !== '') {
|
||||
const outputMessage = {
|
||||
id: fileToWrite,
|
||||
description: savedSearch.attributes.description || savedSearch.attributes.title,
|
||||
index: INDEX,
|
||||
interval: INTERVAL,
|
||||
name: savedSearch.attributes.title,
|
||||
severity: SEVERITY,
|
||||
type: TYPE,
|
||||
from: FROM,
|
||||
to: TO,
|
||||
kql: savedSearch.attributes.kibanaSavedObjectMeta.searchSourceJSON.query.query,
|
||||
};
|
||||
if (query != null && query.trim() !== '') {
|
||||
const outputMessage = {
|
||||
id: fileToWrite,
|
||||
description: description || title,
|
||||
index: INDEX,
|
||||
interval: INTERVAL,
|
||||
name: title,
|
||||
severity: SEVERITY,
|
||||
type: TYPE,
|
||||
from: FROM,
|
||||
to: TO,
|
||||
query,
|
||||
language,
|
||||
filters: filter,
|
||||
};
|
||||
|
||||
fs.writeFileSync(`${outputDir}/${fileToWrite}.json`, JSON.stringify(outputMessage, null, 2));
|
||||
fs.writeFileSync(
|
||||
`${outputDir}/${fileToWrite}.json`,
|
||||
JSON.stringify(outputMessage, null, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
|
|
|
@ -4,41 +4,25 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
|
||||
|
||||
interface BuildEventsScrollQuery {
|
||||
index: string[];
|
||||
from: string;
|
||||
to: string;
|
||||
kql: string | undefined;
|
||||
filter: Record<string, {}> | undefined;
|
||||
filter: unknown;
|
||||
size: number;
|
||||
scroll: string;
|
||||
}
|
||||
|
||||
export const getFilter = (kql: string | undefined, filter: Record<string, {}> | undefined) => {
|
||||
if (kql != null) {
|
||||
return toElasticsearchQuery(fromKueryExpression(kql), null);
|
||||
} else if (filter != null) {
|
||||
return filter;
|
||||
} else {
|
||||
// TODO: Re-visit this error (which should never happen) when we do signal errors for the UI
|
||||
throw new TypeError('either kql or filter should be set');
|
||||
}
|
||||
};
|
||||
|
||||
export const buildEventsScrollQuery = ({
|
||||
index,
|
||||
from,
|
||||
to,
|
||||
kql,
|
||||
filter,
|
||||
size,
|
||||
scroll,
|
||||
}: BuildEventsScrollQuery) => {
|
||||
const kqlOrFilter = getFilter(kql, filter);
|
||||
const filterWithTime = [
|
||||
kqlOrFilter,
|
||||
filter,
|
||||
{
|
||||
bool: {
|
||||
filter: [
|
||||
|
|
|
@ -4,8 +4,6 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
|
||||
|
||||
// TODO: Re-index is just a temporary solution in order to speed up development
|
||||
// of any front end pieces. This should be replaced with a combination of the file
|
||||
// build_events_query.ts and any scrolling/scaling solutions from that particular
|
||||
|
@ -17,9 +15,8 @@ interface BuildEventsReIndexParams {
|
|||
from: string;
|
||||
to: string;
|
||||
signalsIndex: string;
|
||||
maxDocs: string;
|
||||
filter: Record<string, {}> | undefined;
|
||||
kql: string | undefined;
|
||||
maxDocs: number;
|
||||
filter: unknown;
|
||||
severity: string;
|
||||
name: string;
|
||||
timeDetected: string;
|
||||
|
@ -29,17 +26,6 @@ interface BuildEventsReIndexParams {
|
|||
references: string[];
|
||||
}
|
||||
|
||||
export const getFilter = (kql: string | undefined, filter: Record<string, {}> | undefined) => {
|
||||
if (kql != null) {
|
||||
return toElasticsearchQuery(fromKueryExpression(kql), null);
|
||||
} else if (filter != null) {
|
||||
return filter;
|
||||
} else {
|
||||
// TODO: Re-visit this error (which should never happen) when we do signal errors for the UI
|
||||
throw new TypeError('either kql or filter should be set');
|
||||
}
|
||||
};
|
||||
|
||||
export const buildEventsReIndex = ({
|
||||
description,
|
||||
index,
|
||||
|
@ -48,7 +34,6 @@ export const buildEventsReIndex = ({
|
|||
signalsIndex,
|
||||
maxDocs,
|
||||
filter,
|
||||
kql,
|
||||
severity,
|
||||
name,
|
||||
timeDetected,
|
||||
|
@ -57,11 +42,10 @@ export const buildEventsReIndex = ({
|
|||
type,
|
||||
references,
|
||||
}: BuildEventsReIndexParams) => {
|
||||
const kqlOrFilter = getFilter(kql, filter);
|
||||
const indexPatterns = index.map(element => `"${element}"`).join(',');
|
||||
const refs = references.map(element => `"${element}"`).join(',');
|
||||
const filterWithTime = [
|
||||
kqlOrFilter,
|
||||
filter,
|
||||
{
|
||||
bool: {
|
||||
filter: [
|
||||
|
|
|
@ -17,10 +17,13 @@ export const updateIfIdExists = async ({
|
|||
enabled,
|
||||
filter,
|
||||
from,
|
||||
query,
|
||||
language,
|
||||
savedId,
|
||||
filters,
|
||||
id,
|
||||
index,
|
||||
interval,
|
||||
kql,
|
||||
maxSignals,
|
||||
name,
|
||||
severity,
|
||||
|
@ -36,10 +39,13 @@ export const updateIfIdExists = async ({
|
|||
enabled,
|
||||
filter,
|
||||
from,
|
||||
query,
|
||||
language,
|
||||
savedId,
|
||||
filters,
|
||||
id,
|
||||
index,
|
||||
interval,
|
||||
kql,
|
||||
maxSignals,
|
||||
name,
|
||||
severity,
|
||||
|
@ -62,10 +68,13 @@ export const createSignals = async ({
|
|||
enabled,
|
||||
filter,
|
||||
from,
|
||||
query,
|
||||
language,
|
||||
savedId,
|
||||
filters,
|
||||
id,
|
||||
index,
|
||||
interval,
|
||||
kql,
|
||||
maxSignals,
|
||||
name,
|
||||
severity,
|
||||
|
@ -81,10 +90,13 @@ export const createSignals = async ({
|
|||
enabled,
|
||||
filter,
|
||||
from,
|
||||
query,
|
||||
language,
|
||||
savedId,
|
||||
filters,
|
||||
id,
|
||||
index,
|
||||
interval,
|
||||
kql,
|
||||
maxSignals,
|
||||
name,
|
||||
severity,
|
||||
|
@ -115,7 +127,10 @@ export const createSignals = async ({
|
|||
index,
|
||||
from,
|
||||
filter,
|
||||
kql,
|
||||
query,
|
||||
language,
|
||||
savedId,
|
||||
filters,
|
||||
maxSignals,
|
||||
name,
|
||||
severity,
|
||||
|
|
|
@ -0,0 +1,438 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { getQueryFilter, getFilter } from './get_filter';
|
||||
import { savedObjectsClientMock } from 'src/core/server/mocks';
|
||||
import { AlertServices } from '../../../../../alerting/server/types';
|
||||
|
||||
describe('get_filter', () => {
|
||||
let savedObjectsClient = savedObjectsClientMock.create();
|
||||
savedObjectsClient.get = jest.fn().mockImplementation(() => ({
|
||||
attributes: {
|
||||
query: { query: 'host.name: linux', language: 'kuery' },
|
||||
filters: [],
|
||||
},
|
||||
}));
|
||||
let servicesMock: AlertServices = {
|
||||
savedObjectsClient,
|
||||
callCluster: jest.fn(),
|
||||
alertInstanceFactory: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
savedObjectsClient = savedObjectsClientMock.create();
|
||||
savedObjectsClient.get = jest.fn().mockImplementation(() => ({
|
||||
attributes: {
|
||||
query: { query: 'host.name: linux', language: 'kuery' },
|
||||
language: 'kuery',
|
||||
filters: [],
|
||||
},
|
||||
}));
|
||||
servicesMock = {
|
||||
savedObjectsClient,
|
||||
callCluster: jest.fn(),
|
||||
alertInstanceFactory: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getQueryFilter', () => {
|
||||
test('it should work with an empty filter as kuery', () => {
|
||||
const esQuery = getQueryFilter('host.name: linux', 'kuery', [], ['auditbeat-*']);
|
||||
expect(esQuery).toEqual({
|
||||
bool: {
|
||||
must: [],
|
||||
filter: [
|
||||
{
|
||||
bool: {
|
||||
should: [
|
||||
{
|
||||
match: {
|
||||
'host.name': 'linux',
|
||||
},
|
||||
},
|
||||
],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
should: [],
|
||||
must_not: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it should work with an empty filter as lucene', () => {
|
||||
const esQuery = getQueryFilter('host.name: linux', 'lucene', [], ['auditbeat-*']);
|
||||
expect(esQuery).toEqual({
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
query_string: {
|
||||
query: 'host.name: linux',
|
||||
analyze_wildcard: true,
|
||||
time_zone: 'Zulu',
|
||||
},
|
||||
},
|
||||
],
|
||||
filter: [],
|
||||
should: [],
|
||||
must_not: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it should work with a simple filter as a kuery', () => {
|
||||
const esQuery = getQueryFilter(
|
||||
'host.name: windows',
|
||||
'kuery',
|
||||
[
|
||||
{
|
||||
meta: {
|
||||
alias: 'custom label here',
|
||||
disabled: false,
|
||||
key: 'host.name',
|
||||
negate: false,
|
||||
params: {
|
||||
query: 'siem-windows',
|
||||
},
|
||||
type: 'phrase',
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
'host.name': 'siem-windows',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
['auditbeat-*']
|
||||
);
|
||||
expect(esQuery).toEqual({
|
||||
bool: {
|
||||
must: [],
|
||||
filter: [
|
||||
{
|
||||
bool: {
|
||||
should: [
|
||||
{
|
||||
match: {
|
||||
'host.name': 'windows',
|
||||
},
|
||||
},
|
||||
],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
match_phrase: {
|
||||
'host.name': 'siem-windows',
|
||||
},
|
||||
},
|
||||
],
|
||||
should: [],
|
||||
must_not: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it should work with a simple filter that is disabled as a kuery', () => {
|
||||
const esQuery = getQueryFilter(
|
||||
'host.name: windows',
|
||||
'kuery',
|
||||
[
|
||||
{
|
||||
meta: {
|
||||
alias: 'custom label here',
|
||||
disabled: true,
|
||||
key: 'host.name',
|
||||
negate: false,
|
||||
params: {
|
||||
query: 'siem-windows',
|
||||
},
|
||||
type: 'phrase',
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
'host.name': 'siem-windows',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
['auditbeat-*']
|
||||
);
|
||||
expect(esQuery).toEqual({
|
||||
bool: {
|
||||
must: [],
|
||||
filter: [
|
||||
{
|
||||
bool: {
|
||||
should: [
|
||||
{
|
||||
match: {
|
||||
'host.name': 'windows',
|
||||
},
|
||||
},
|
||||
],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
should: [],
|
||||
must_not: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it should work with a simple filter as a lucene', () => {
|
||||
const esQuery = getQueryFilter(
|
||||
'host.name: windows',
|
||||
'lucene',
|
||||
[
|
||||
{
|
||||
meta: {
|
||||
alias: 'custom label here',
|
||||
disabled: false,
|
||||
key: 'host.name',
|
||||
negate: false,
|
||||
params: {
|
||||
query: 'siem-windows',
|
||||
},
|
||||
type: 'phrase',
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
'host.name': 'siem-windows',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
['auditbeat-*']
|
||||
);
|
||||
expect(esQuery).toEqual({
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
query_string: {
|
||||
query: 'host.name: windows',
|
||||
analyze_wildcard: true,
|
||||
time_zone: 'Zulu',
|
||||
},
|
||||
},
|
||||
],
|
||||
filter: [
|
||||
{
|
||||
match_phrase: {
|
||||
'host.name': 'siem-windows',
|
||||
},
|
||||
},
|
||||
],
|
||||
should: [],
|
||||
must_not: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it should work with a simple filter that is disabled as a lucene', () => {
|
||||
const esQuery = getQueryFilter(
|
||||
'host.name: windows',
|
||||
'lucene',
|
||||
[
|
||||
{
|
||||
meta: {
|
||||
alias: 'custom label here',
|
||||
disabled: true,
|
||||
key: 'host.name',
|
||||
negate: false,
|
||||
params: {
|
||||
query: 'siem-windows',
|
||||
},
|
||||
type: 'phrase',
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
'host.name': 'siem-windows',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
['auditbeat-*']
|
||||
);
|
||||
expect(esQuery).toEqual({
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
query_string: {
|
||||
query: 'host.name: windows',
|
||||
analyze_wildcard: true,
|
||||
time_zone: 'Zulu',
|
||||
},
|
||||
},
|
||||
],
|
||||
filter: [],
|
||||
should: [],
|
||||
must_not: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilter', () => {
|
||||
test('returns a filter if given a type of filter as is', async () => {
|
||||
const filter = await getFilter({
|
||||
type: 'filter',
|
||||
filter: { something: '1' },
|
||||
filters: undefined,
|
||||
language: undefined,
|
||||
query: undefined,
|
||||
savedId: undefined,
|
||||
services: servicesMock,
|
||||
index: ['auditbeat-*'],
|
||||
});
|
||||
expect(filter).toEqual({
|
||||
something: '1',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns a query if given a type of query', async () => {
|
||||
const filter = await getFilter({
|
||||
type: 'query',
|
||||
filter: undefined,
|
||||
filters: undefined,
|
||||
language: 'kuery',
|
||||
query: 'host.name: siem',
|
||||
savedId: undefined,
|
||||
services: servicesMock,
|
||||
index: ['auditbeat-*'],
|
||||
});
|
||||
expect(filter).toEqual({
|
||||
bool: {
|
||||
must: [],
|
||||
filter: [
|
||||
{
|
||||
bool: {
|
||||
should: [
|
||||
{
|
||||
match: {
|
||||
'host.name': 'siem',
|
||||
},
|
||||
},
|
||||
],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
should: [],
|
||||
must_not: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('throws on type query if query is undefined', async () => {
|
||||
await expect(
|
||||
getFilter({
|
||||
type: 'query',
|
||||
filter: undefined,
|
||||
filters: undefined,
|
||||
language: undefined,
|
||||
query: 'host.name: siem',
|
||||
savedId: undefined,
|
||||
services: servicesMock,
|
||||
index: ['auditbeat-*'],
|
||||
})
|
||||
).rejects.toThrow('query, filters, and index parameter should be defined');
|
||||
});
|
||||
|
||||
test('throws on type query if language is undefined', async () => {
|
||||
await expect(
|
||||
getFilter({
|
||||
type: 'query',
|
||||
filter: undefined,
|
||||
filters: undefined,
|
||||
language: 'kuery',
|
||||
query: undefined,
|
||||
savedId: undefined,
|
||||
services: servicesMock,
|
||||
index: ['auditbeat-*'],
|
||||
})
|
||||
).rejects.toThrow('query, filters, and index parameter should be defined');
|
||||
});
|
||||
|
||||
test('throws on type query if index is undefined', async () => {
|
||||
await expect(
|
||||
getFilter({
|
||||
type: 'query',
|
||||
filter: undefined,
|
||||
filters: undefined,
|
||||
language: 'kuery',
|
||||
query: 'host.name: siem',
|
||||
savedId: undefined,
|
||||
services: servicesMock,
|
||||
index: undefined,
|
||||
})
|
||||
).rejects.toThrow('query, filters, and index parameter should be defined');
|
||||
});
|
||||
|
||||
test('returns a saved query if given a type of query', async () => {
|
||||
const filter = await getFilter({
|
||||
type: 'saved_query',
|
||||
filter: undefined,
|
||||
filters: undefined,
|
||||
language: undefined,
|
||||
query: undefined,
|
||||
savedId: 'some-id',
|
||||
services: servicesMock,
|
||||
index: ['auditbeat-*'],
|
||||
});
|
||||
expect(filter).toEqual({
|
||||
bool: {
|
||||
filter: [
|
||||
{ bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } },
|
||||
],
|
||||
must: [],
|
||||
must_not: [],
|
||||
should: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('throws on saved query if saved_id is undefined', async () => {
|
||||
await expect(
|
||||
getFilter({
|
||||
type: 'saved_query',
|
||||
filter: undefined,
|
||||
filters: undefined,
|
||||
language: undefined,
|
||||
query: undefined,
|
||||
savedId: undefined,
|
||||
services: servicesMock,
|
||||
index: ['auditbeat-*'],
|
||||
})
|
||||
).rejects.toThrow('savedId parameter should be defined');
|
||||
});
|
||||
|
||||
test('throws on saved query if index is undefined', async () => {
|
||||
await expect(
|
||||
getFilter({
|
||||
type: 'saved_query',
|
||||
filter: undefined,
|
||||
filters: undefined,
|
||||
language: undefined,
|
||||
query: undefined,
|
||||
savedId: 'some-id',
|
||||
services: servicesMock,
|
||||
index: undefined,
|
||||
})
|
||||
).rejects.toThrow('savedId parameter should be defined');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { buildEsQuery } from '@kbn/es-query';
|
||||
import { Query } from 'src/plugins/data/common/query';
|
||||
import { AlertServices } from '../../../../../alerting/server/types';
|
||||
import { SignalAlertParams, PartialFilter } from './types';
|
||||
import { assertUnreachable } from '../../../utils/build_query';
|
||||
|
||||
export const getQueryFilter = (
|
||||
query: string,
|
||||
language: string,
|
||||
filters: PartialFilter[],
|
||||
index: string[]
|
||||
) => {
|
||||
const indexPattern = {
|
||||
fields: [],
|
||||
title: index.join(),
|
||||
};
|
||||
const queries: Query[] = [{ query, language }];
|
||||
const config = {
|
||||
allowLeadingWildcards: true,
|
||||
queryStringOptions: { analyze_wildcard: true },
|
||||
ignoreFilterIfFieldNotInIndex: false,
|
||||
dateFormatTZ: 'Zulu',
|
||||
};
|
||||
const esQuery = buildEsQuery(
|
||||
indexPattern,
|
||||
queries,
|
||||
filters.filter(f => f.meta != null && f.meta.disabled === false),
|
||||
config
|
||||
);
|
||||
return esQuery;
|
||||
};
|
||||
|
||||
interface GetFilterArgs {
|
||||
type: SignalAlertParams['type'];
|
||||
filter: Record<string, {}> | undefined;
|
||||
filters: PartialFilter[] | undefined;
|
||||
language: string | undefined;
|
||||
query: string | undefined;
|
||||
savedId: string | undefined;
|
||||
services: AlertServices;
|
||||
index: string[] | undefined;
|
||||
}
|
||||
|
||||
export const getFilter = async ({
|
||||
filter,
|
||||
filters,
|
||||
index,
|
||||
language,
|
||||
savedId,
|
||||
services,
|
||||
type,
|
||||
query,
|
||||
}: GetFilterArgs): Promise<unknown> => {
|
||||
switch (type) {
|
||||
case 'query': {
|
||||
if (query != null && language != null && index != null) {
|
||||
return getQueryFilter(query, language, filters || [], index);
|
||||
} else {
|
||||
throw new TypeError('query, filters, and index parameter should be defined');
|
||||
}
|
||||
}
|
||||
case 'saved_query': {
|
||||
if (savedId != null && index != null) {
|
||||
const savedObject = await services.savedObjectsClient.get('query', savedId);
|
||||
return getQueryFilter(
|
||||
savedObject.attributes.query.query,
|
||||
savedObject.attributes.query.language,
|
||||
savedObject.attributes.filters,
|
||||
index
|
||||
);
|
||||
} else {
|
||||
throw new TypeError('savedId parameter should be defined');
|
||||
}
|
||||
}
|
||||
case 'filter': {
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
return assertUnreachable(type);
|
||||
};
|
|
@ -18,6 +18,7 @@ import { buildEventsScrollQuery } from './build_events_query';
|
|||
// bulk scroll class
|
||||
import { scrollAndBulkIndex } from './utils';
|
||||
import { SignalAlertTypeDefinition } from './types';
|
||||
import { getFilter } from './get_filter';
|
||||
|
||||
export const signalsAlertType = ({ logger }: { logger: Logger }): SignalAlertTypeDefinition => {
|
||||
return {
|
||||
|
@ -31,7 +32,10 @@ export const signalsAlertType = ({ logger }: { logger: Logger }): SignalAlertTyp
|
|||
filter: schema.nullable(schema.object({}, { allowUnknowns: true })),
|
||||
id: schema.string(),
|
||||
index: schema.arrayOf(schema.string()),
|
||||
kql: schema.nullable(schema.string()),
|
||||
language: schema.nullable(schema.string()),
|
||||
savedId: schema.nullable(schema.string()),
|
||||
query: schema.nullable(schema.string()),
|
||||
filters: schema.nullable(schema.arrayOf(schema.object({}, { allowUnknowns: true }))),
|
||||
maxSignals: schema.number({ defaultValue: 100 }),
|
||||
name: schema.string(),
|
||||
severity: schema.string(),
|
||||
|
@ -51,7 +55,10 @@ export const signalsAlertType = ({ logger }: { logger: Logger }): SignalAlertTyp
|
|||
from,
|
||||
id,
|
||||
index,
|
||||
kql,
|
||||
filters,
|
||||
language,
|
||||
savedId,
|
||||
query,
|
||||
maxSignals,
|
||||
name,
|
||||
references,
|
||||
|
@ -65,13 +72,23 @@ export const signalsAlertType = ({ logger }: { logger: Logger }): SignalAlertTyp
|
|||
const scroll = scrollLock ? scrollLock : '1m';
|
||||
const size = scrollSize ? scrollSize : 400;
|
||||
|
||||
const esFilter = await getFilter({
|
||||
type,
|
||||
filter,
|
||||
filters,
|
||||
language,
|
||||
query,
|
||||
savedId,
|
||||
services,
|
||||
index,
|
||||
});
|
||||
|
||||
// TODO: Turn these options being sent in into a template for the alert type
|
||||
const noReIndex = buildEventsScrollQuery({
|
||||
index,
|
||||
from,
|
||||
to,
|
||||
kql,
|
||||
filter,
|
||||
filter: esFilter,
|
||||
size,
|
||||
scroll,
|
||||
});
|
||||
|
@ -79,7 +96,6 @@ export const signalsAlertType = ({ logger }: { logger: Logger }): SignalAlertTyp
|
|||
const reIndex = buildEventsReIndex({
|
||||
index,
|
||||
from,
|
||||
kql,
|
||||
to,
|
||||
// TODO: Change this out once we have solved
|
||||
// https://github.com/elastic/kibana/issues/47002
|
||||
|
@ -88,7 +104,7 @@ export const signalsAlertType = ({ logger }: { logger: Logger }): SignalAlertTyp
|
|||
description,
|
||||
name,
|
||||
timeDetected: new Date().toISOString(),
|
||||
filter,
|
||||
filter: esFilter,
|
||||
maxDocs: maxSignals,
|
||||
ruleRevision: 1,
|
||||
id,
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import { get } from 'lodash/fp';
|
||||
|
||||
import Hapi from 'hapi';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { SIGNALS_ID } from '../../../../common/constants';
|
||||
import {
|
||||
Alert,
|
||||
|
@ -18,21 +19,38 @@ import { AlertsClient } from '../../../../../alerting/server/alerts_client';
|
|||
import { ActionsClient } from '../../../../../actions/server/actions_client';
|
||||
import { SearchResponse } from '../../types';
|
||||
|
||||
export type PartialFilter = Partial<Filter>;
|
||||
|
||||
export interface SignalAlertParams {
|
||||
description: string;
|
||||
from: string;
|
||||
id: string;
|
||||
index: string[];
|
||||
interval: string;
|
||||
enabled: boolean;
|
||||
filter: Record<string, {}> | undefined;
|
||||
kql: string | undefined;
|
||||
maxSignals: string;
|
||||
filters: PartialFilter[] | undefined;
|
||||
from: string;
|
||||
index: string[];
|
||||
interval: string;
|
||||
id: string;
|
||||
language: string | undefined;
|
||||
maxSignals: number;
|
||||
name: string;
|
||||
severity: string;
|
||||
type: 'filter' | 'kql';
|
||||
to: string;
|
||||
query: string | undefined;
|
||||
references: string[];
|
||||
savedId: string | undefined;
|
||||
severity: string;
|
||||
to: string;
|
||||
type: 'filter' | 'query' | 'saved_query';
|
||||
}
|
||||
|
||||
export type SignalAlertParamsRest = Omit<SignalAlertParams, 'maxSignals' | 'saved_id'> & {
|
||||
saved_id: SignalAlertParams['savedId'];
|
||||
max_signals: SignalAlertParams['maxSignals'];
|
||||
};
|
||||
|
||||
export interface FindParamsRest {
|
||||
per_page: number;
|
||||
page: number;
|
||||
sort_field: string;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface Clients {
|
||||
|
@ -73,9 +91,7 @@ export type SignalAlertType = Alert & {
|
|||
};
|
||||
|
||||
export interface SignalsRequest extends Hapi.Request {
|
||||
payload: Omit<SignalAlertParams, 'maxSignals'> & {
|
||||
max_signals: string;
|
||||
};
|
||||
payload: SignalAlertParamsRest;
|
||||
}
|
||||
|
||||
export type SignalExecutorOptions = Omit<AlertExecutorOptions, 'params'> & {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { calculateInterval, calculateKqlAndFilter } from './update_signals';
|
||||
import { calculateInterval } from './update_signals';
|
||||
|
||||
describe('update_signals', () => {
|
||||
describe('#calculateInterval', () => {
|
||||
|
@ -23,30 +23,4 @@ describe('update_signals', () => {
|
|||
expect(interval).toEqual('5m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#calculateKqlAndFilter', () => {
|
||||
test('given a undefined kql filter it returns a null kql', () => {
|
||||
const kqlFilter = calculateKqlAndFilter(undefined, {});
|
||||
expect(kqlFilter).toEqual({
|
||||
filter: {},
|
||||
kql: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('given a undefined filter it returns a null filter', () => {
|
||||
const kqlFilter = calculateKqlAndFilter('some kql string', undefined);
|
||||
expect(kqlFilter).toEqual({
|
||||
filter: null,
|
||||
kql: 'some kql string',
|
||||
});
|
||||
});
|
||||
|
||||
test('given both a undefined filter and undefined kql it returns both as undefined', () => {
|
||||
const kqlFilter = calculateKqlAndFilter(undefined, undefined);
|
||||
expect(kqlFilter).toEqual({
|
||||
filter: undefined,
|
||||
kql: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -22,30 +22,20 @@ export const calculateInterval = (
|
|||
}
|
||||
};
|
||||
|
||||
export const calculateKqlAndFilter = (
|
||||
kql: string | undefined,
|
||||
filter: {} | undefined
|
||||
): { kql: string | null | undefined; filter: {} | null | undefined } => {
|
||||
if (filter != null) {
|
||||
return { kql: null, filter };
|
||||
} else if (kql != null) {
|
||||
return { kql, filter: null };
|
||||
} else {
|
||||
return { kql: undefined, filter: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSignal = async ({
|
||||
alertsClient,
|
||||
actionsClient, // TODO: Use this whenever we add feature support for different action types
|
||||
description,
|
||||
enabled,
|
||||
query,
|
||||
language,
|
||||
savedId,
|
||||
filters,
|
||||
filter,
|
||||
from,
|
||||
id,
|
||||
index,
|
||||
interval,
|
||||
kql,
|
||||
maxSignals,
|
||||
name,
|
||||
severity,
|
||||
|
@ -63,18 +53,19 @@ export const updateSignal = async ({
|
|||
|
||||
const alertTypeParams = signal.alertTypeParams || {};
|
||||
|
||||
const { kql: nextKql, filter: nextFilter } = calculateKqlAndFilter(kql, filter);
|
||||
|
||||
const nextAlertTypeParams = defaults(
|
||||
{
|
||||
...alertTypeParams,
|
||||
},
|
||||
{
|
||||
description,
|
||||
filter: nextFilter,
|
||||
filter,
|
||||
from,
|
||||
query,
|
||||
language,
|
||||
savedId,
|
||||
filters,
|
||||
index,
|
||||
kql: nextKql,
|
||||
maxSignals,
|
||||
name,
|
||||
severity,
|
||||
|
|
|
@ -6,21 +6,30 @@
|
|||
|
||||
import { ServerInjectOptions } from 'hapi';
|
||||
import { ActionResult } from '../../../../../../actions/server/types';
|
||||
import { SignalAlertParamsRest } from '../../alerts/types';
|
||||
|
||||
// The Omit of filter is because of a Hapi Server Typing issue that I am unclear
|
||||
// where it comes from. I would hope to remove the "filter" as an omit at some point
|
||||
// when we upgrade and Hapi Server is ok with the filter.
|
||||
export const typicalPayload = (): Partial<Omit<SignalAlertParamsRest, 'filter'>> => ({
|
||||
id: 'rule-1',
|
||||
description: 'Detecting root and admin users',
|
||||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
interval: '5m',
|
||||
name: 'Detect Root/Admin Users',
|
||||
type: 'query',
|
||||
from: 'now-6m',
|
||||
to: 'now',
|
||||
severity: 'high',
|
||||
query: 'user.name: root or user.name: admin',
|
||||
language: 'kuery',
|
||||
});
|
||||
|
||||
export const getUpdateRequest = (): ServerInjectOptions => ({
|
||||
method: 'PUT',
|
||||
url: '/api/siem/signals',
|
||||
payload: {
|
||||
id: 'rule-1',
|
||||
description: 'Detecting root and admin users',
|
||||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
interval: '5m',
|
||||
name: 'Detect Root/Admin Users',
|
||||
severity: 'high',
|
||||
type: 'kql',
|
||||
from: 'now-6m',
|
||||
to: 'now',
|
||||
kql: 'user.name: root or user.name: admin',
|
||||
...typicalPayload(),
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -50,16 +59,7 @@ export const getCreateRequest = (): ServerInjectOptions => ({
|
|||
method: 'POST',
|
||||
url: '/api/siem/signals',
|
||||
payload: {
|
||||
id: 'rule-1',
|
||||
description: 'Detecting root and admin users',
|
||||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
interval: '5m',
|
||||
name: 'Detect Root/Admin Users',
|
||||
severity: 'high',
|
||||
type: 'kql',
|
||||
from: 'now-6m',
|
||||
to: 'now',
|
||||
kql: 'user.name: root or user.name: admin',
|
||||
...typicalPayload(),
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -79,12 +79,13 @@ export const createAlertResult = () => ({
|
|||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
from: 'now-6m',
|
||||
filter: null,
|
||||
kql: 'user.name: root or user.name: admin',
|
||||
query: 'user.name: root or user.name: admin',
|
||||
maxSignals: 100,
|
||||
name: 'Detect Root/Admin Users',
|
||||
severity: 'high',
|
||||
to: 'now',
|
||||
type: 'kql',
|
||||
type: 'query',
|
||||
language: 'kuery',
|
||||
references: [],
|
||||
},
|
||||
interval: '5m',
|
||||
|
@ -133,12 +134,13 @@ export const updateAlertResult = () => ({
|
|||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
from: 'now-6m',
|
||||
filter: null,
|
||||
kql: 'user.name: root or user.name: admin',
|
||||
query: 'user.name: root or user.name: admin',
|
||||
maxSignals: 100,
|
||||
name: 'Detect Root/Admin Users',
|
||||
severity: 'high',
|
||||
to: 'now',
|
||||
type: 'kql',
|
||||
type: 'query',
|
||||
language: 'kuery',
|
||||
references: [],
|
||||
},
|
||||
interval: '5m',
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
createActionResult,
|
||||
createAlertResult,
|
||||
getCreateRequest,
|
||||
typicalPayload,
|
||||
} from './__mocks__/request_responses';
|
||||
|
||||
describe('create_signals', () => {
|
||||
|
@ -30,7 +31,7 @@ describe('create_signals', () => {
|
|||
});
|
||||
|
||||
describe('status codes with actionClient and alertClient', () => {
|
||||
it('returns 200 when deleting a single signal with a valid actionClient and alertClient', async () => {
|
||||
test('returns 200 when creating a single signal with a valid actionClient and alertClient', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
actionsClient.create.mockResolvedValue(createActionResult());
|
||||
|
@ -39,21 +40,21 @@ describe('create_signals', () => {
|
|||
expect(statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 404 if actionClient is not available on the route', async () => {
|
||||
test('returns 404 if actionClient is not available on the route', async () => {
|
||||
const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration();
|
||||
createSignalsRoute(serverWithoutActionClient);
|
||||
const { statusCode } = await serverWithoutActionClient.inject(getCreateRequest());
|
||||
expect(statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 if alertClient is not available on the route', async () => {
|
||||
test('returns 404 if alertClient is not available on the route', async () => {
|
||||
const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration();
|
||||
createSignalsRoute(serverWithoutAlertClient);
|
||||
const { statusCode } = await serverWithoutAlertClient.inject(getCreateRequest());
|
||||
expect(statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 if alertClient and actionClient are both not available on the route', async () => {
|
||||
test('returns 404 if alertClient and actionClient are both not available on the route', async () => {
|
||||
const {
|
||||
serverWithoutActionOrAlertClient,
|
||||
} = createMockServerWithoutActionOrAlertClientDecoration();
|
||||
|
@ -64,100 +65,73 @@ describe('create_signals', () => {
|
|||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('returns 400 if id is not given', async () => {
|
||||
test('returns 400 if id is not given', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
actionsClient.create.mockResolvedValue(createActionResult());
|
||||
alertsClient.create.mockResolvedValue(createAlertResult());
|
||||
// missing id should throw a 400
|
||||
const { id, ...noId } = typicalPayload();
|
||||
const request: ServerInjectOptions = {
|
||||
method: 'POST',
|
||||
url: '/api/siem/signals',
|
||||
payload: {
|
||||
// missing id should throw a 400
|
||||
description: 'Detecting root and admin users',
|
||||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
interval: '5m',
|
||||
name: 'Detect Root/Admin Users',
|
||||
severity: 'high',
|
||||
type: 'kql',
|
||||
from: 'now-6m',
|
||||
to: 'now',
|
||||
kql: 'user.name: root or user.name: admin',
|
||||
},
|
||||
payload: noId,
|
||||
};
|
||||
const { statusCode } = await server.inject(request);
|
||||
expect(statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 200 if type is kql', async () => {
|
||||
test('returns 200 if type is query', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
actionsClient.create.mockResolvedValue(createActionResult());
|
||||
alertsClient.create.mockResolvedValue(createAlertResult());
|
||||
const { type, ...noType } = typicalPayload();
|
||||
const request: ServerInjectOptions = {
|
||||
method: 'POST',
|
||||
url: '/api/siem/signals',
|
||||
payload: {
|
||||
id: 'rule-1',
|
||||
description: 'Detecting root and admin users',
|
||||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
interval: '5m',
|
||||
name: 'Detect Root/Admin Users',
|
||||
severity: 'high',
|
||||
type: 'kql',
|
||||
from: 'now-6m',
|
||||
to: 'now',
|
||||
kql: 'user.name: root or user.name: admin',
|
||||
...noType,
|
||||
type: 'query',
|
||||
},
|
||||
};
|
||||
const { statusCode } = await server.inject(request);
|
||||
expect(statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 200 if type is filter', async () => {
|
||||
test('returns 200 if type is filter', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
actionsClient.create.mockResolvedValue(createActionResult());
|
||||
alertsClient.create.mockResolvedValue(createAlertResult());
|
||||
const request: ServerInjectOptions = {
|
||||
// Cannot type request with a ServerInjectOptions as the type system complains
|
||||
// about the property filter involving Hapi types, so I left it off for now
|
||||
const { language, query, type, ...noType } = typicalPayload();
|
||||
const request = {
|
||||
method: 'POST',
|
||||
url: '/api/siem/signals',
|
||||
payload: {
|
||||
id: 'rule-1',
|
||||
description: 'Detecting root and admin users',
|
||||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
interval: '5m',
|
||||
name: 'Detect Root/Admin Users',
|
||||
severity: 'high',
|
||||
...noType,
|
||||
type: 'filter',
|
||||
from: 'now-6m',
|
||||
to: 'now',
|
||||
kql: 'user.name: root or user.name: admin',
|
||||
filter: {},
|
||||
},
|
||||
};
|
||||
const { statusCode } = await server.inject(request);
|
||||
expect(statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 400 if type is not filter or kql', async () => {
|
||||
test('returns 400 if type is not filter or kql', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
actionsClient.create.mockResolvedValue(createActionResult());
|
||||
alertsClient.create.mockResolvedValue(createAlertResult());
|
||||
const { type, ...noType } = typicalPayload();
|
||||
const request: ServerInjectOptions = {
|
||||
method: 'POST',
|
||||
url: '/api/siem/signals',
|
||||
payload: {
|
||||
id: 'rule-1',
|
||||
description: 'Detecting root and admin users',
|
||||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
interval: '5m',
|
||||
name: 'Detect Root/Admin Users',
|
||||
severity: 'high',
|
||||
type: 'something-made-up', // This is a made up type that causes the 400
|
||||
from: 'now-6m',
|
||||
to: 'now',
|
||||
kql: 'user.name: root or user.name: admin',
|
||||
...noType,
|
||||
type: 'something-made-up',
|
||||
},
|
||||
};
|
||||
const { statusCode } = await server.inject(request);
|
|
@ -5,10 +5,10 @@
|
|||
*/
|
||||
|
||||
import Hapi from 'hapi';
|
||||
import Joi from 'joi';
|
||||
import { isFunction } from 'lodash/fp';
|
||||
import { createSignals } from '../alerts/create_signals';
|
||||
import { SignalsRequest } from '../alerts/types';
|
||||
import { createSignalsSchema } from './schemas';
|
||||
|
||||
export const createCreateSignalsRoute: Hapi.ServerRoute = {
|
||||
method: 'POST',
|
||||
|
@ -19,24 +19,7 @@ export const createCreateSignalsRoute: Hapi.ServerRoute = {
|
|||
options: {
|
||||
abortEarly: false,
|
||||
},
|
||||
payload: Joi.object({
|
||||
description: Joi.string().required(),
|
||||
enabled: Joi.boolean().default(true),
|
||||
filter: Joi.object(),
|
||||
from: Joi.string().required(),
|
||||
id: Joi.string().required(),
|
||||
index: Joi.array().required(),
|
||||
interval: Joi.string().default('5m'),
|
||||
kql: Joi.string(),
|
||||
max_signals: Joi.number().default(100),
|
||||
name: Joi.string().required(),
|
||||
severity: Joi.string().required(),
|
||||
to: Joi.string().required(),
|
||||
type: Joi.string()
|
||||
.valid('filter', 'kql')
|
||||
.required(),
|
||||
references: Joi.array().default([]),
|
||||
}).xor('filter', 'kql'),
|
||||
payload: createSignalsSchema,
|
||||
},
|
||||
},
|
||||
async handler(request: SignalsRequest, headers) {
|
||||
|
@ -44,8 +27,12 @@ export const createCreateSignalsRoute: Hapi.ServerRoute = {
|
|||
description,
|
||||
enabled,
|
||||
filter,
|
||||
kql,
|
||||
from,
|
||||
query,
|
||||
language,
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
saved_id: savedId,
|
||||
filters,
|
||||
id,
|
||||
index,
|
||||
interval,
|
||||
|
@ -59,7 +46,6 @@ export const createCreateSignalsRoute: Hapi.ServerRoute = {
|
|||
} = request.payload;
|
||||
|
||||
const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null;
|
||||
|
||||
const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null;
|
||||
|
||||
if (!alertsClient || !actionsClient) {
|
||||
|
@ -73,10 +59,13 @@ export const createCreateSignalsRoute: Hapi.ServerRoute = {
|
|||
enabled,
|
||||
filter,
|
||||
from,
|
||||
query,
|
||||
language,
|
||||
savedId,
|
||||
filters,
|
||||
id,
|
||||
index,
|
||||
interval,
|
||||
kql,
|
||||
maxSignals,
|
||||
name,
|
||||
severity,
|
||||
|
|
|
@ -28,7 +28,7 @@ describe('delete_signals', () => {
|
|||
});
|
||||
|
||||
describe('status codes with actionClient and alertClient', () => {
|
||||
it('returns 200 when deleting a single signal with a valid actionClient and alertClient', async () => {
|
||||
test('returns 200 when deleting a single signal with a valid actionClient and alertClient', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
alertsClient.delete.mockResolvedValue({});
|
||||
|
@ -36,21 +36,21 @@ describe('delete_signals', () => {
|
|||
expect(statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 404 if actionClient is not available on the route', async () => {
|
||||
test('returns 404 if actionClient is not available on the route', async () => {
|
||||
const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration();
|
||||
deleteSignalsRoute(serverWithoutActionClient);
|
||||
const { statusCode } = await serverWithoutActionClient.inject(getDeleteRequest());
|
||||
expect(statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 if alertClient is not available on the route', async () => {
|
||||
test('returns 404 if alertClient is not available on the route', async () => {
|
||||
const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration();
|
||||
deleteSignalsRoute(serverWithoutAlertClient);
|
||||
const { statusCode } = await serverWithoutAlertClient.inject(getDeleteRequest());
|
||||
expect(statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 if alertClient and actionClient are both not available on the route', async () => {
|
||||
test('returns 404 if alertClient and actionClient are both not available on the route', async () => {
|
||||
const {
|
||||
serverWithoutActionOrAlertClient,
|
||||
} = createMockServerWithoutActionOrAlertClientDecoration();
|
||||
|
@ -61,7 +61,7 @@ describe('delete_signals', () => {
|
|||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('returns 404 if given a non-existent id', async () => {
|
||||
test('returns 404 if given a non-existent id', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
alertsClient.delete.mockResolvedValue({});
|
||||
|
|
|
@ -28,7 +28,7 @@ describe('find_signals', () => {
|
|||
});
|
||||
|
||||
describe('status codes with actionClient and alertClient', () => {
|
||||
it('returns 200 when deleting a single signal with a valid actionClient and alertClient', async () => {
|
||||
test('returns 200 when finding a single signal with a valid actionClient and alertClient', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
actionsClient.find.mockResolvedValue({
|
||||
page: 1,
|
||||
|
@ -41,21 +41,21 @@ describe('find_signals', () => {
|
|||
expect(statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 404 if actionClient is not available on the route', async () => {
|
||||
test('returns 404 if actionClient is not available on the route', async () => {
|
||||
const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration();
|
||||
findSignalsRoute(serverWithoutActionClient);
|
||||
const { statusCode } = await serverWithoutActionClient.inject(getFindRequest());
|
||||
expect(statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 if alertClient is not available on the route', async () => {
|
||||
test('returns 404 if alertClient is not available on the route', async () => {
|
||||
const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration();
|
||||
findSignalsRoute(serverWithoutAlertClient);
|
||||
const { statusCode } = await serverWithoutAlertClient.inject(getFindRequest());
|
||||
expect(statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 if alertClient and actionClient are both not available on the route', async () => {
|
||||
test('returns 404 if alertClient and actionClient are both not available on the route', async () => {
|
||||
const {
|
||||
serverWithoutActionOrAlertClient,
|
||||
} = createMockServerWithoutActionOrAlertClientDecoration();
|
||||
|
@ -66,7 +66,7 @@ describe('find_signals', () => {
|
|||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('returns 400 if a bad query parameter is given', async () => {
|
||||
test('returns 400 if a bad query parameter is given', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
const request: ServerInjectOptions = {
|
||||
|
@ -77,7 +77,7 @@ describe('find_signals', () => {
|
|||
expect(statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 200 if the set of optional query parameters are given', async () => {
|
||||
test('returns 200 if the set of optional query parameters are given', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
const request: ServerInjectOptions = {
|
||||
|
|
|
@ -5,10 +5,10 @@
|
|||
*/
|
||||
|
||||
import Hapi from 'hapi';
|
||||
import Joi from 'joi';
|
||||
import { isFunction } from 'lodash/fp';
|
||||
import { findSignals } from '../alerts/find_signals';
|
||||
import { FindSignalsRequest } from '../alerts/types';
|
||||
import { findSignalsSchema } from './schemas';
|
||||
|
||||
export const createFindSignalRoute: Hapi.ServerRoute = {
|
||||
method: 'GET',
|
||||
|
@ -19,20 +19,7 @@ export const createFindSignalRoute: Hapi.ServerRoute = {
|
|||
options: {
|
||||
abortEarly: false,
|
||||
},
|
||||
query: Joi.object()
|
||||
.keys({
|
||||
per_page: Joi.number()
|
||||
.min(0)
|
||||
.default(20),
|
||||
page: Joi.number()
|
||||
.min(1)
|
||||
.default(1),
|
||||
sort_field: Joi.string(),
|
||||
fields: Joi.array()
|
||||
.items(Joi.string())
|
||||
.single(),
|
||||
})
|
||||
.default(),
|
||||
query: findSignalsSchema,
|
||||
},
|
||||
},
|
||||
async handler(request: FindSignalsRequest, headers) {
|
||||
|
|
|
@ -28,28 +28,28 @@ describe('read_signals', () => {
|
|||
});
|
||||
|
||||
describe('status codes with actionClient and alertClient', () => {
|
||||
it('returns 200 when deleting a single signal with a valid actionClient and alertClient', async () => {
|
||||
test('returns 200 when reading a single signal with a valid actionClient and alertClient', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
const { statusCode } = await server.inject(getReadRequest());
|
||||
expect(statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 404 if actionClient is not available on the route', async () => {
|
||||
test('returns 404 if actionClient is not available on the route', async () => {
|
||||
const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration();
|
||||
readSignalsRoute(serverWithoutActionClient);
|
||||
const { statusCode } = await serverWithoutActionClient.inject(getReadRequest());
|
||||
expect(statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 if alertClient is not available on the route', async () => {
|
||||
test('returns 404 if alertClient is not available on the route', async () => {
|
||||
const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration();
|
||||
readSignalsRoute(serverWithoutAlertClient);
|
||||
const { statusCode } = await serverWithoutAlertClient.inject(getReadRequest());
|
||||
expect(statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 if alertClient and actionClient are both not available on the route', async () => {
|
||||
test('returns 404 if alertClient and actionClient are both not available on the route', async () => {
|
||||
const {
|
||||
serverWithoutActionOrAlertClient,
|
||||
} = createMockServerWithoutActionOrAlertClientDecoration();
|
||||
|
@ -60,7 +60,7 @@ describe('read_signals', () => {
|
|||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('returns 404 if given a non-existent id', async () => {
|
||||
test('returns 404 if given a non-existent id', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
alertsClient.delete.mockResolvedValue({});
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import Joi from 'joi';
|
||||
|
||||
/* eslint-disable @typescript-eslint/camelcase */
|
||||
const description = Joi.string();
|
||||
const enabled = Joi.boolean();
|
||||
const filter = Joi.object();
|
||||
const filters = Joi.array();
|
||||
const from = Joi.string();
|
||||
const id = Joi.string();
|
||||
const index = Joi.array()
|
||||
.items(Joi.string())
|
||||
.single();
|
||||
const interval = Joi.string();
|
||||
const query = Joi.string();
|
||||
const language = Joi.string().valid('kuery', 'lucene');
|
||||
const saved_id = Joi.string();
|
||||
const max_signals = Joi.number().greater(0);
|
||||
const name = Joi.string();
|
||||
const severity = Joi.string();
|
||||
const to = Joi.string();
|
||||
const type = Joi.string().valid('filter', 'query', 'saved_query');
|
||||
const references = Joi.array()
|
||||
.items(Joi.string())
|
||||
.single();
|
||||
const per_page = Joi.number()
|
||||
.min(0)
|
||||
.default(20);
|
||||
const page = Joi.number()
|
||||
.min(1)
|
||||
.default(1);
|
||||
const sort_field = Joi.string();
|
||||
const fields = Joi.array()
|
||||
.items(Joi.string())
|
||||
.single();
|
||||
/* eslint-enable @typescript-eslint/camelcase */
|
||||
|
||||
export const createSignalsSchema = Joi.object({
|
||||
description: description.required(),
|
||||
enabled: enabled.default(true),
|
||||
filter: filter.when('type', { is: 'filter', then: Joi.required(), otherwise: Joi.forbidden() }),
|
||||
filters: filters.when('type', { is: 'query', then: Joi.optional(), otherwise: Joi.forbidden() }),
|
||||
from: from.required(),
|
||||
id: id.required(),
|
||||
index: index.required(),
|
||||
interval: interval.default('5m'),
|
||||
query: query.when('type', { is: 'query', then: Joi.required(), otherwise: Joi.forbidden() }),
|
||||
language: language.when('type', {
|
||||
is: 'query',
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.forbidden(),
|
||||
}),
|
||||
saved_id: saved_id.when('type', {
|
||||
is: 'saved_query',
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.forbidden(),
|
||||
}),
|
||||
max_signals: max_signals.default(100),
|
||||
name: name.required(),
|
||||
severity: severity.required(),
|
||||
to: to.required(),
|
||||
type: type.required(),
|
||||
references: references.default([]),
|
||||
});
|
||||
|
||||
export const updateSignalSchema = Joi.object({
|
||||
description,
|
||||
enabled,
|
||||
filter: filter.when('type', { is: 'filter', then: Joi.optional(), otherwise: Joi.forbidden() }),
|
||||
filters: filters.when('type', { is: 'query', then: Joi.optional(), otherwise: Joi.forbidden() }),
|
||||
from,
|
||||
id,
|
||||
index,
|
||||
interval,
|
||||
query: query.when('type', { is: 'query', then: Joi.optional(), otherwise: Joi.forbidden() }),
|
||||
language: language.when('type', {
|
||||
is: 'query',
|
||||
then: Joi.optional(),
|
||||
otherwise: Joi.forbidden(),
|
||||
}),
|
||||
saved_id: saved_id.when('type', {
|
||||
is: 'saved_query',
|
||||
then: Joi.optional(),
|
||||
otherwise: Joi.forbidden(),
|
||||
}),
|
||||
max_signals,
|
||||
name,
|
||||
severity,
|
||||
to,
|
||||
type,
|
||||
references,
|
||||
});
|
||||
|
||||
export const findSignalsSchema = Joi.object({
|
||||
per_page,
|
||||
page,
|
||||
sort_field,
|
||||
fields,
|
||||
});
|
|
@ -19,6 +19,7 @@ import {
|
|||
updateActionResult,
|
||||
updateAlertResult,
|
||||
getUpdateRequest,
|
||||
typicalPayload,
|
||||
} from './__mocks__/request_responses';
|
||||
|
||||
describe('update_signals', () => {
|
||||
|
@ -31,7 +32,7 @@ describe('update_signals', () => {
|
|||
});
|
||||
|
||||
describe('status codes with actionClient and alertClient', () => {
|
||||
it('returns 200 when deleting a single signal with a valid actionClient and alertClient', async () => {
|
||||
test('returns 200 when updating a single signal with a valid actionClient and alertClient', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
actionsClient.update.mockResolvedValue(updateActionResult());
|
||||
|
@ -40,21 +41,21 @@ describe('update_signals', () => {
|
|||
expect(statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 404 if actionClient is not available on the route', async () => {
|
||||
test('returns 404 if actionClient is not available on the route', async () => {
|
||||
const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration();
|
||||
updateSignalsRoute(serverWithoutActionClient);
|
||||
const { statusCode } = await serverWithoutActionClient.inject(getUpdateRequest());
|
||||
expect(statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 if alertClient is not available on the route', async () => {
|
||||
test('returns 404 if alertClient is not available on the route', async () => {
|
||||
const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration();
|
||||
updateSignalsRoute(serverWithoutAlertClient);
|
||||
const { statusCode } = await serverWithoutAlertClient.inject(getUpdateRequest());
|
||||
expect(statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 if alertClient and actionClient are both not available on the route', async () => {
|
||||
test('returns 404 if alertClient and actionClient are both not available on the route', async () => {
|
||||
const {
|
||||
serverWithoutActionOrAlertClient,
|
||||
} = createMockServerWithoutActionOrAlertClientDecoration();
|
||||
|
@ -65,124 +66,86 @@ describe('update_signals', () => {
|
|||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('returns 400 if id is not given in either the body or the url', async () => {
|
||||
test('returns 400 if id is not given in either the body or the url', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
const { id, ...noId } = typicalPayload();
|
||||
const request: ServerInjectOptions = {
|
||||
method: 'PUT',
|
||||
url: '/api/siem/signals',
|
||||
payload: {
|
||||
// missing id should throw a 400
|
||||
description: 'Detecting root and admin users',
|
||||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
interval: '5m',
|
||||
name: 'Detect Root/Admin Users',
|
||||
severity: 'high',
|
||||
type: 'kql',
|
||||
from: 'now-6m',
|
||||
to: 'now',
|
||||
kql: 'user.name: root or user.name: admin',
|
||||
payload: noId,
|
||||
},
|
||||
};
|
||||
const { statusCode } = await server.inject(request);
|
||||
expect(statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 200 if type is kql', async () => {
|
||||
test('returns 200 if type is query', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
actionsClient.update.mockResolvedValue(updateActionResult());
|
||||
alertsClient.update.mockResolvedValue(updateAlertResult());
|
||||
const { type, ...noType } = typicalPayload();
|
||||
const request: ServerInjectOptions = {
|
||||
method: 'PUT',
|
||||
url: '/api/siem/signals',
|
||||
payload: {
|
||||
id: 'rule-1',
|
||||
description: 'Detecting root and admin users',
|
||||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
interval: '5m',
|
||||
name: 'Detect Root/Admin Users',
|
||||
severity: 'high',
|
||||
type: 'kql',
|
||||
from: 'now-6m',
|
||||
to: 'now',
|
||||
kql: 'user.name: root or user.name: admin',
|
||||
...noType,
|
||||
type: 'query',
|
||||
},
|
||||
};
|
||||
const { statusCode } = await server.inject(request);
|
||||
expect(statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 200 if type is filter', async () => {
|
||||
test('returns 200 if type is filter', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
actionsClient.update.mockResolvedValue(updateActionResult());
|
||||
alertsClient.update.mockResolvedValue(updateAlertResult());
|
||||
const { language, query, type, ...noType } = typicalPayload();
|
||||
const request: ServerInjectOptions = {
|
||||
method: 'PUT',
|
||||
url: '/api/siem/signals',
|
||||
payload: {
|
||||
id: 'rule-1',
|
||||
description: 'Detecting root and admin users',
|
||||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
interval: '5m',
|
||||
name: 'Detect Root/Admin Users',
|
||||
severity: 'high',
|
||||
...noType,
|
||||
type: 'filter',
|
||||
from: 'now-6m',
|
||||
to: 'now',
|
||||
kql: 'user.name: root or user.name: admin',
|
||||
},
|
||||
};
|
||||
const { statusCode } = await server.inject(request);
|
||||
expect(statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 400 if type is not filter or kql', async () => {
|
||||
test('returns 400 if type is not filter or kql', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
actionsClient.update.mockResolvedValue(updateActionResult());
|
||||
alertsClient.update.mockResolvedValue(updateAlertResult());
|
||||
const { type, ...noType } = typicalPayload();
|
||||
const request: ServerInjectOptions = {
|
||||
method: 'PUT',
|
||||
url: '/api/siem/signals',
|
||||
payload: {
|
||||
id: 'rule-1',
|
||||
description: 'Detecting root and admin users',
|
||||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
interval: '5m',
|
||||
name: 'Detect Root/Admin Users',
|
||||
severity: 'high',
|
||||
type: 'something-made-up', // This is a made up type that causes the 400
|
||||
from: 'now-6m',
|
||||
to: 'now',
|
||||
kql: 'user.name: root or user.name: admin',
|
||||
...noType,
|
||||
type: 'something-made-up',
|
||||
},
|
||||
};
|
||||
const { statusCode } = await server.inject(request);
|
||||
expect(statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 200 if id is given in the url but not the payload', async () => {
|
||||
test('returns 200 if id is given in the url but not the payload', async () => {
|
||||
alertsClient.find.mockResolvedValue(getFindResult());
|
||||
alertsClient.get.mockResolvedValue(getResult());
|
||||
actionsClient.update.mockResolvedValue(updateActionResult());
|
||||
alertsClient.update.mockResolvedValue(updateAlertResult());
|
||||
// missing id should throw a 400
|
||||
const { id, ...noId } = typicalPayload();
|
||||
const request: ServerInjectOptions = {
|
||||
method: 'PUT',
|
||||
url: '/api/siem/signals/rule-1',
|
||||
payload: {
|
||||
// missing id should throw a 400
|
||||
description: 'Detecting root and admin users',
|
||||
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
|
||||
interval: '5m',
|
||||
name: 'Detect Root/Admin Users',
|
||||
severity: 'high',
|
||||
type: 'kql',
|
||||
from: 'now-6m',
|
||||
to: 'now',
|
||||
kql: 'user.name: root or user.name: admin',
|
||||
},
|
||||
payload: noId,
|
||||
};
|
||||
const { statusCode } = await server.inject(request);
|
||||
expect(statusCode).toBe(200);
|
||||
|
|
|
@ -9,6 +9,7 @@ import Joi from 'joi';
|
|||
import { isFunction } from 'lodash/fp';
|
||||
import { updateSignal } from '../alerts/update_signals';
|
||||
import { SignalsRequest } from '../alerts/types';
|
||||
import { updateSignalSchema } from './schemas';
|
||||
|
||||
export const createUpdateSignalsRoute: Hapi.ServerRoute = {
|
||||
method: 'PUT',
|
||||
|
@ -26,22 +27,7 @@ export const createUpdateSignalsRoute: Hapi.ServerRoute = {
|
|||
otherwise: Joi.string().required(),
|
||||
}),
|
||||
},
|
||||
payload: Joi.object({
|
||||
description: Joi.string(),
|
||||
enabled: Joi.boolean(),
|
||||
filter: Joi.object(),
|
||||
from: Joi.string(),
|
||||
id: Joi.string(),
|
||||
index: Joi.array(),
|
||||
interval: Joi.string(),
|
||||
kql: Joi.string(),
|
||||
max_signals: Joi.number().default(100),
|
||||
name: Joi.string(),
|
||||
severity: Joi.string(),
|
||||
to: Joi.string(),
|
||||
type: Joi.string().valid('filter', 'kql'),
|
||||
references: Joi.array().default([]),
|
||||
}).nand('filter', 'kql'),
|
||||
payload: updateSignalSchema,
|
||||
},
|
||||
},
|
||||
async handler(request: SignalsRequest, headers) {
|
||||
|
@ -49,8 +35,12 @@ export const createUpdateSignalsRoute: Hapi.ServerRoute = {
|
|||
description,
|
||||
enabled,
|
||||
filter,
|
||||
kql,
|
||||
from,
|
||||
query,
|
||||
language,
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
saved_id: savedId,
|
||||
filters,
|
||||
id,
|
||||
index,
|
||||
interval,
|
||||
|
@ -77,10 +67,13 @@ export const createUpdateSignalsRoute: Hapi.ServerRoute = {
|
|||
enabled,
|
||||
filter,
|
||||
from,
|
||||
query,
|
||||
language,
|
||||
savedId,
|
||||
filters,
|
||||
id: request.params.id ? request.params.id : id,
|
||||
index,
|
||||
interval,
|
||||
kql,
|
||||
maxSignals,
|
||||
name,
|
||||
severity,
|
||||
|
|
|
@ -28,10 +28,11 @@ do {
|
|||
\"interval\": \"24h\",
|
||||
\"name\": \"Detect Root/Admin Users\",
|
||||
\"severity\": \"high\",
|
||||
\"type\": \"kql\",
|
||||
\"type\": \"query\",
|
||||
\"from\": \"now-6m\",
|
||||
\"to\": \"now\",
|
||||
\"kql\": \"user.name: root or user.name: admin\"
|
||||
\"query\": \"user.name: root or user.name: admin\"
|
||||
\"language\": \"kuery\"
|
||||
}" \
|
||||
| jq .;
|
||||
} &
|
||||
|
|
|
@ -5,9 +5,10 @@
|
|||
"interval": "5m",
|
||||
"name": "Detect Root/Admin Users",
|
||||
"severity": "high",
|
||||
"type": "kql",
|
||||
"type": "query",
|
||||
"from": "now-6m",
|
||||
"to": "now",
|
||||
"kql": "user.name: root or user.name: admin",
|
||||
"query": "user.name: root or user.name: admin",
|
||||
"language": "kuery",
|
||||
"references": ["http://www.example.com", "https://ww.example.com"]
|
||||
}
|
||||
|
|
|
@ -5,8 +5,9 @@
|
|||
"interval": "24h",
|
||||
"name": "Detect Root/Admin Users over a long period of time",
|
||||
"severity": "high",
|
||||
"type": "kql",
|
||||
"type": "query",
|
||||
"from": "now-1y",
|
||||
"to": "now",
|
||||
"kql": "user.name: root or user.name: admin"
|
||||
"query": "user.name: root or user.name: admin",
|
||||
"language": "kuery"
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
{
|
||||
"id": "rule-3",
|
||||
"description": "Detecting root and admin users",
|
||||
"description": "Detecting root and admin users as an empty set",
|
||||
"index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"],
|
||||
"interval": "5m",
|
||||
"name": "Detect Root/Admin Users",
|
||||
"severity": "high",
|
||||
"type": "kql",
|
||||
"type": "query",
|
||||
"from": "now-16y",
|
||||
"to": "now-15y",
|
||||
"kql": "user.name: root or user.name: admin"
|
||||
"query": "user.name: root or user.name: admin",
|
||||
"language": "kuery"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"id": "rule-4",
|
||||
"description": "Detecting root and admin users with lucene",
|
||||
"index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"],
|
||||
"interval": "5m",
|
||||
"name": "Detect Root/Admin Users",
|
||||
"severity": "high",
|
||||
"type": "query",
|
||||
"from": "now-6m",
|
||||
"to": "now",
|
||||
"query": "user.name: root or user.name: admin",
|
||||
"language": "lucene",
|
||||
"references": ["http://www.example.com", "https://ww.example.com"]
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"id": "rule-5",
|
||||
"description": "Detecting root and admin users over 24 hours on windows",
|
||||
"index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"],
|
||||
"interval": "5m",
|
||||
"name": "Detect Root/Admin Users",
|
||||
"severity": "high",
|
||||
"type": "query",
|
||||
"from": "now-24h",
|
||||
"to": "now",
|
||||
"query": "user.name: root or user.name: admin",
|
||||
"language": "kuery",
|
||||
"filters": [
|
||||
{
|
||||
"query": {
|
||||
"match_phrase": {
|
||||
"host.name": "siem-windows"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"exists": {
|
||||
"field": "host.hostname"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"id": "rule-6",
|
||||
"description": "Detecting root and admin users",
|
||||
"index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"],
|
||||
"interval": "5m",
|
||||
"name": "Detect Root/Admin Users",
|
||||
"severity": "high",
|
||||
"type": "query",
|
||||
"from": "now-24h",
|
||||
"to": "now",
|
||||
"query": "user.name: root or user.name: admin",
|
||||
"language": "kuery",
|
||||
"filters": [
|
||||
{
|
||||
"$state": {
|
||||
"store": "appState"
|
||||
},
|
||||
"meta": {
|
||||
"alias": "custom label here",
|
||||
"disabled": false,
|
||||
"key": "host.name",
|
||||
"negate": false,
|
||||
"params": {
|
||||
"query": "siem-windows"
|
||||
},
|
||||
"type": "phrase"
|
||||
},
|
||||
"query": {
|
||||
"match_phrase": {
|
||||
"host.name": "siem-windows"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"exists": {
|
||||
"field": "host.hostname"
|
||||
},
|
||||
"meta": {
|
||||
"type": "exists",
|
||||
"disabled": false,
|
||||
"negate": false,
|
||||
"alias": "has a hostname",
|
||||
"key": "host.hostname",
|
||||
"value": "exists"
|
||||
},
|
||||
"$state": {
|
||||
"store": "appState"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"id": "rule-7",
|
||||
"description": "Detecting root and admin users",
|
||||
"index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"],
|
||||
"interval": "5m",
|
||||
"name": "Detect Root/Admin Users",
|
||||
"severity": "high",
|
||||
"type": "query",
|
||||
"from": "now-24h",
|
||||
"to": "now",
|
||||
"query": "user.name: root or user.name: admin",
|
||||
"language": "lucene",
|
||||
"filters": [
|
||||
{
|
||||
"$state": {
|
||||
"store": "appState"
|
||||
},
|
||||
"meta": {
|
||||
"alias": "custom label here",
|
||||
"disabled": false,
|
||||
"key": "host.name",
|
||||
"negate": false,
|
||||
"params": {
|
||||
"query": "siem-windows"
|
||||
},
|
||||
"type": "phrase"
|
||||
},
|
||||
"query": {
|
||||
"match_phrase": {
|
||||
"host.name": "siem-windows"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"exists": {
|
||||
"field": "host.hostname"
|
||||
},
|
||||
"meta": {
|
||||
"type": "exists",
|
||||
"disabled": false,
|
||||
"negate": false,
|
||||
"alias": "has a hostname",
|
||||
"key": "host.hostname",
|
||||
"value": "exists"
|
||||
},
|
||||
"$state": {
|
||||
"store": "appState"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"id": "rule-8",
|
||||
"description": "Detecting root and admin users",
|
||||
"index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"],
|
||||
"interval": "5m",
|
||||
"name": "Detect Root/Admin Users",
|
||||
"severity": "high",
|
||||
"type": "query",
|
||||
"from": "now-6m",
|
||||
"to": "now",
|
||||
"query": "user.name: root or user.name: admin",
|
||||
"language": "kuery",
|
||||
"enabled": false,
|
||||
"references": ["http://www.example.com", "https://ww.example.com"]
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"id": "rule-9999",
|
||||
"description": "Detecting root and admin users",
|
||||
"index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"],
|
||||
"interval": "5m",
|
||||
"name": "Detect Root/Admin Users",
|
||||
"severity": "high",
|
||||
"type": "filter",
|
||||
"from": "now-6m",
|
||||
"to": "now",
|
||||
"filter": {
|
||||
"bool": {
|
||||
"must": [],
|
||||
"filter": [
|
||||
{
|
||||
"bool": {
|
||||
"should": [
|
||||
{
|
||||
"match_phrase": {
|
||||
"host.name": "siem-windows"
|
||||
}
|
||||
}
|
||||
],
|
||||
"minimum_should_match": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"match_phrase": {
|
||||
"winlog.event_id": {
|
||||
"query": "100"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"match_phrase": {
|
||||
"agent.hostname": {
|
||||
"query": "siem-windows"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"should": [],
|
||||
"must_not": []
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"id": "saved-query-1",
|
||||
"description": "Detecting root and admin users",
|
||||
"index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"],
|
||||
"interval": "5m",
|
||||
"name": "Detect Root/Admin Users",
|
||||
"severity": "high",
|
||||
"type": "saved_query",
|
||||
"from": "now-6m",
|
||||
"to": "now",
|
||||
"saved_id": "Test Query From SIEM"
|
||||
}
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"id": "rule-1",
|
||||
"description": "Only watch winlogbeat users",
|
||||
"index": ["winlogbeat-*"],
|
||||
"interval": "9m",
|
||||
"name": "Just watch other winlogbeat users",
|
||||
"severity": "low",
|
||||
"enabled": false,
|
||||
"type": "filter",
|
||||
"from": "now-5d",
|
||||
"to": "now-1d",
|
||||
"kql": "user.name: something_else"
|
||||
"description": "Detecting root and admin users",
|
||||
"index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"],
|
||||
"interval": "5m",
|
||||
"name": "Detect Root/Admin Users",
|
||||
"severity": "high",
|
||||
"type": "query",
|
||||
"from": "now-6m",
|
||||
"to": "now",
|
||||
"query": "user.name: root or user.name: admin",
|
||||
"language": "kuery",
|
||||
"references": []
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
{
|
||||
"id": "rule-3",
|
||||
"id": "rule-longmont",
|
||||
"description": "Detect Longmont activity",
|
||||
"index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"],
|
||||
"interval": "24h",
|
||||
"name": "Detect Longmont activity",
|
||||
"severity": "high",
|
||||
"type": "kql",
|
||||
"type": "query",
|
||||
"from": "now-1y",
|
||||
"to": "now",
|
||||
"kql": "source.as.organization.name: \"Longmont Power & Communications\""
|
||||
"query": "user.name: root or user.name: admin",
|
||||
"language": "kuery"
|
||||
}
|
Loading…
Reference in a new issue