[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:
Frank Hassanabad 2019-10-28 17:27:39 -06:00 committed by GitHub
parent 69f47a9b90
commit a4f37cd9e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 2326 additions and 362 deletions

View file

@ -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) {

View file

@ -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: [

View file

@ -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: [

View file

@ -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,

View file

@ -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');
});
});
});

View file

@ -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);
};

View file

@ -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,

View file

@ -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'> & {

View file

@ -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,
});
});
});
});

View file

@ -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,

View file

@ -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',

View file

@ -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);

View file

@ -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,

View file

@ -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({});

View file

@ -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 = {

View file

@ -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) {

View file

@ -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

View file

@ -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,
});

View file

@ -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);

View file

@ -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,

View file

@ -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 .;
} &

View file

@ -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"]
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"]
}

View file

@ -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"
}
}
]
}

View file

@ -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"
}
}
]
}

View file

@ -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"
}
}
]
}

View file

@ -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"]
}

View file

@ -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": []
}
}
}

View file

@ -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"
}

View file

@ -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": []
}

View file

@ -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"
}