diff --git a/packages/flare/bin/constants.py b/packages/flare/bin/constants.py index 6374a9b..e96173e 100644 --- a/packages/flare/bin/constants.py +++ b/packages/flare/bin/constants.py @@ -14,6 +14,7 @@ class PasswordKeys(Enum): API_KEY = "api_key" TENANT_ID = "tenant_id" INGEST_METADATA_ONLY = "ingest_metadata_only" + SEVERITIES_FILTER = "severities_filter" class CollectionKeys(Enum): diff --git a/packages/flare/bin/cron_job_ingest_events.py b/packages/flare/bin/cron_job_ingest_events.py index 6b287c1..343b57a 100644 --- a/packages/flare/bin/cron_job_ingest_events.py +++ b/packages/flare/bin/cron_job_ingest_events.py @@ -93,6 +93,7 @@ def main( api_key = get_api_key(storage_passwords=storage_passwords) tenant_id = get_tenant_id(storage_passwords=storage_passwords) ingest_metadata_only = get_ingest_metadata_only(storage_passwords=storage_passwords) + severities_filter = get_severities_filter(storage_passwords=storage_passwords) save_last_fetched(kvstore=kvstore) save_last_ingested_tenant_id(kvstore=kvstore, tenant_id=tenant_id) @@ -103,6 +104,7 @@ def main( api_key=api_key, tenant_id=tenant_id, ingest_metadata_only=ingest_metadata_only, + severities=severities_filter, ): save_last_fetched(kvstore=kvstore) @@ -158,6 +160,18 @@ def get_ingest_metadata_only(storage_passwords: StoragePasswords) -> bool: ) +def get_severities_filter(storage_passwords: StoragePasswords) -> list[str]: + severities_filter = get_storage_password_value( + storage_passwords=storage_passwords, + password_key=PasswordKeys.SEVERITIES_FILTER.value, + ) + + if severities_filter: + return severities_filter.split(",") + + return [] + + def get_next(kvstore: KVStoreCollections, tenant_id: int) -> Optional[str]: return get_collection_value( kvstore=kvstore, key=f"{CollectionKeys.get_next_token(tenantId=tenant_id)}" @@ -281,6 +295,7 @@ def fetch_feed( api_key: str, tenant_id: int, ingest_metadata_only: bool, + severities: list[str], ) -> Iterator[tuple[dict, str]]: try: flare_api = FlareAPI(api_key=api_key, tenant_id=tenant_id) @@ -292,6 +307,7 @@ def fetch_feed( next=next, start_date=start_date, ingest_metadata_only=ingest_metadata_only, + severities=severities, ): yield event_next except Exception as e: diff --git a/packages/flare/bin/flare.py b/packages/flare/bin/flare.py index 6b1086a..aed545b 100644 --- a/packages/flare/bin/flare.py +++ b/packages/flare/bin/flare.py @@ -50,10 +50,12 @@ def fetch_feed_events( next: Optional[str] = None, start_date: Optional[date] = None, ingest_metadata_only: bool, + severities: list[str], ) -> Iterator[tuple[dict, str]]: for response in self._fetch_event_feed_metadata( next=next, start_date=start_date, + severities=severities, ): event_feed = response.json() self.logger.debug(event_feed) @@ -71,6 +73,7 @@ def _fetch_event_feed_metadata( *, next: Optional[str] = None, start_date: Optional[date] = None, + severities: list[str], ) -> Iterator[requests.Response]: data: Dict[str, Any] = { "from": next if next else None, @@ -79,10 +82,13 @@ def _fetch_event_feed_metadata( "gte": start_date.isoformat() if start_date else date.today().isoformat() - } + }, }, } + if len(severities): + data["severity"] = severities + for response in self.flare_client.scroll( method="POST", url="/firework/v4/events/tenant/_search", @@ -107,3 +113,8 @@ def fetch_tenants(self) -> requests.Response: return self.flare_client.get( url="/firework/v2/me/tenants", ) + + def fetch_filters_severity(self) -> requests.Response: + return self.flare_client.get( + url="/firework/v4/events/filters/severities", + ) diff --git a/packages/flare/bin/flare_external_requests.py b/packages/flare/bin/flare_external_requests.py index 0d09214..47076a1 100644 --- a/packages/flare/bin/flare_external_requests.py +++ b/packages/flare/bin/flare_external_requests.py @@ -41,3 +41,20 @@ def handle_POST(self) -> None: logger.debug(f"FlareUserTenants: {response_json}") self.response.setHeader("Content-Type", "application/json") self.response.write(json.dumps(response_json)) + + +class FlareSeverityFilters(splunk.rest.BaseRestHandler): + def handle_POST(self) -> None: + logger = Logger(class_name=__file__) + payload = self.request["payload"] + params = parse.parse_qs(payload) + + if "apiKey" not in params: + raise Exception("API Key is required") + + flare_api = FlareAPI(api_key=params["apiKey"][0]) + response = flare_api.fetch_filters_severity() + response_json = response.json() + logger.debug(f"FlareSeverityFilters: {response_json}") + self.response.setHeader("Content-Type", "application/json") + self.response.write(json.dumps(response_json)) diff --git a/packages/flare/src/main/resources/splunk/default/restmap.conf b/packages/flare/src/main/resources/splunk/default/restmap.conf index 14e7562..b100ed0 100644 --- a/packages/flare/src/main/resources/splunk/default/restmap.conf +++ b/packages/flare/src/main/resources/splunk/default/restmap.conf @@ -7,3 +7,8 @@ python.version = python3 match=/fetch_user_tenants handler=flare_external_requests.FlareUserTenants python.version = python3 + +[script:flare_external_requests_severity_filters] +match=/fetch_severity_filters +handler=flare_external_requests.FlareSeverityFilters +python.version = python3 diff --git a/packages/flare/src/main/resources/splunk/default/web.conf b/packages/flare/src/main/resources/splunk/default/web.conf index 1563964..b45be14 100644 --- a/packages/flare/src/main/resources/splunk/default/web.conf +++ b/packages/flare/src/main/resources/splunk/default/web.conf @@ -5,3 +5,7 @@ methods=POST [expose:flare_external_requests_user_tenants] pattern=fetch_user_tenants methods=POST + +[expose:flare_external_requests_severity_filters] +pattern=fetch_severity_filters +methods=POST diff --git a/packages/flare/tests/bin/test_flare_wrapper.py b/packages/flare/tests/bin/test_flare_wrapper.py index cbabe47..398659b 100644 --- a/packages/flare/tests/bin/test_flare_wrapper.py +++ b/packages/flare/tests/bin/test_flare_wrapper.py @@ -44,6 +44,7 @@ def test_flare_full_data_without_metadata( next=None, start_date=None, ingest_metadata_only=True, + severities=[], ): assert next_token == expected_return_value["next"] events.append(event) @@ -105,6 +106,7 @@ def test_flare_full_data_with_metadata( next=None, start_date=None, ingest_metadata_only=False, + severities=[], ): assert next_token == expected_return_value["next"] events.append(event) @@ -147,6 +149,7 @@ def test_flare_full_data_with_metadata_and_exception( next=None, start_date=None, ingest_metadata_only=False, + severities=[], ) ) diff --git a/packages/flare/tests/bin/test_ingest_events.py b/packages/flare/tests/bin/test_ingest_events.py index 1d503db..da0cc40 100644 --- a/packages/flare/tests/bin/test_ingest_events.py +++ b/packages/flare/tests/bin/test_ingest_events.py @@ -228,6 +228,7 @@ def test_fetch_feed_expect_exception() -> None: api_key="some_key", tenant_id=11111, ingest_metadata_only=False, + severities=[], ): pass @@ -262,6 +263,7 @@ def test_fetch_feed_expect_feed_response( api_key="some_key", tenant_id=11111, ingest_metadata_only=False, + severities=[], ): assert next_token == next events.append(event) @@ -320,4 +322,5 @@ def test_main_expect_normal_run( api_key="some_api_key", tenant_id=111, ingest_metadata_only=False, + severities=[], ) diff --git a/packages/react-components/src/components/ConfigurationUserPreferencesStep.tsx b/packages/react-components/src/components/ConfigurationUserPreferencesStep.tsx index 02fac2f..49abfb3 100644 --- a/packages/react-components/src/components/ConfigurationUserPreferencesStep.tsx +++ b/packages/react-components/src/components/ConfigurationUserPreferencesStep.tsx @@ -1,20 +1,25 @@ import React, { FC, useEffect, useState } from 'react'; -import { ConfigurationStep, Tenant } from '../models/flare'; +import { ConfigurationStep, Severity, Tenant } from '../models/flare'; import Button from './Button'; import Label from './Label'; import Select from './Select'; import { APP_NAME } from '../models/constants'; import { + convertSeverityFilterToArray, fetchAvailableIndexNames, fetchCurrentIndexName, + fetchSeverityFilters, fetchIngestMetadataOnly, + fetchSeveritiesFilter, fetchTenantId, fetchUserTenants, + getSeverityFilterValue, saveConfiguration, } from '../utils/setupConfiguration'; import './ConfigurationGlobalStep.css'; import './ConfigurationUserPreferencesStep.css'; +import SeverityOptions from './SeverityOptions'; import Switch from './Switch'; import { ToastKeys, toastManager } from './ToastManager'; import Tooltip from './Tooltip'; @@ -28,6 +33,8 @@ const ConfigurationUserPreferencesStep: FC<{ }> = ({ show, configurationStep, apiKey, onNavigateBackClick, onUserPreferencesSaved }) => { const [tenantId, setTenantId] = useState(undefined); const [tenants, setUserTenants] = useState([]); + const [selectedSeverities, setSelectedSeverities] = useState([]); + const [severities, setSeverities] = useState([]); const [indexName, setIndexName] = useState(''); const [indexNames, setIndexNames] = useState([]); const [isIngestingMetadataOnly, setIsIngestingMetadataOnly] = useState(false); @@ -41,7 +48,13 @@ const ConfigurationUserPreferencesStep: FC<{ const handleSubmitUserPreferences = (): void => { setIsLoading(true); - saveConfiguration(apiKey, Number(tenantId), indexName, isIngestingMetadataOnly) + saveConfiguration( + apiKey, + Number(tenantId), + indexName, + isIngestingMetadataOnly, + getSeverityFilterValue(selectedSeverities, severities) + ) .then(() => { setIsLoading(false); toastManager.destroy(ToastKeys.ERROR); @@ -69,17 +82,33 @@ const ConfigurationUserPreferencesStep: FC<{ fetchCurrentIndexName(), fetchUserTenants(apiKey), fetchAvailableIndexNames(), + fetchSeverityFilters(apiKey), + fetchSeveritiesFilter(), ]) - .then(([id, ingestMetadataOnly, index, userTenants, availableIndexNames]) => { - setTenantId(id); - setIsIngestingMetadataOnly(ingestMetadataOnly); - setIndexName(index); - if (id === -1 && userTenants.length > 0) { - setTenantId(userTenants[0].id); + .then( + ([ + id, + ingestMetadataOnly, + index, + userTenants, + availableIndexNames, + allSeverities, + severitiesFilter, + ]) => { + setTenantId(id); + setIsIngestingMetadataOnly(ingestMetadataOnly); + setIndexName(index); + if (id === -1 && userTenants.length > 0) { + setTenantId(userTenants[0].id); + } + setUserTenants(userTenants); + setIndexNames(availableIndexNames); + setSeverities(allSeverities); + setSelectedSeverities( + convertSeverityFilterToArray(severitiesFilter, allSeverities) + ); } - setUserTenants(userTenants); - setIndexNames(availableIndexNames); - }) + ) .catch(() => { toastManager.show({ id: ToastKeys.ERROR, @@ -93,11 +122,13 @@ const ConfigurationUserPreferencesStep: FC<{ setIndexNames([]); setUserTenants([]); setIsLoading(false); + setSeverities([]); + setSelectedSeverities([]); } }, [configurationStep, apiKey]); const isFormValid = (): boolean => { - return tenantId !== undefined; + return tenantId !== undefined && selectedSeverities.length > 0; }; return ( @@ -129,6 +160,31 @@ const ConfigurationUserPreferencesStep: FC<{ })} +
+
+ + +
+ Select the minimal alert severity to ignore less critical events + associated with this identifier. +
+
+ {'To learn more about severities see '} + + Understand Severity Scoring. + +
+
+
+ +
diff --git a/packages/react-components/src/components/SeverityOption.css b/packages/react-components/src/components/SeverityOption.css new file mode 100644 index 0000000..29972e7 --- /dev/null +++ b/packages/react-components/src/components/SeverityOption.css @@ -0,0 +1,28 @@ +.toggle { + position: relative; + width: 1rem; + height: 1rem; + display: inline-block; + z-index: 2; +} + +.toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.dot { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + height: 1rem; + width: 1rem; + border-radius: 50%; + border-width: 1px; + border-style: solid; + display: inline-block; +} diff --git a/packages/react-components/src/components/SeverityOption.tsx b/packages/react-components/src/components/SeverityOption.tsx new file mode 100644 index 0000000..55560db --- /dev/null +++ b/packages/react-components/src/components/SeverityOption.tsx @@ -0,0 +1,47 @@ +import React, { FC, useState } from 'react'; + +import { Severity } from '../models/flare'; +import './SeverityOption.css'; +import './Tooltip.css'; + +const SeverityOption: FC<{ + isChecked?: boolean; + severity: Severity; + onCheckChange: (isChecked: boolean) => void; +}> = ({ isChecked = false, severity, onCheckChange }) => { + const [isShowingTooltip, setShowingTooltip] = useState(false); + + return ( +
+ + +
+ ); +}; + +export default SeverityOption; diff --git a/packages/react-components/src/components/SeverityOptions.css b/packages/react-components/src/components/SeverityOptions.css new file mode 100644 index 0000000..bf6cb35 --- /dev/null +++ b/packages/react-components/src/components/SeverityOptions.css @@ -0,0 +1,6 @@ +#severities-container { + display: flex; + flex-direction: row; + margin-top: 0.5rem; + gap: 0.75rem; +} diff --git a/packages/react-components/src/components/SeverityOptions.tsx b/packages/react-components/src/components/SeverityOptions.tsx new file mode 100644 index 0000000..05b36da --- /dev/null +++ b/packages/react-components/src/components/SeverityOptions.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; + +import { Severity } from '../models/flare'; +import SeverityOption from './SeverityOption'; +import './SeverityOptions.css'; + +const SeverityOptions: FC<{ + severities: Severity[]; + selectedSeverities: Severity[]; + setSelectedSeverities: (selectedSeverities: Severity[]) => void; +}> = ({ severities, selectedSeverities, setSelectedSeverities }) => { + const isSeverityChecked = (severity: Severity): boolean => { + return ( + selectedSeverities.findIndex( + (selectedSeverity) => selectedSeverity.value === severity.value + ) >= 0 + ); + }; + + const handleOnSeverityChange = (severity: Severity, isChecked: boolean): void => { + if (isChecked) { + setSelectedSeverities([...selectedSeverities, severity]); + } else { + setSelectedSeverities( + selectedSeverities.filter( + (selectedSeverity) => selectedSeverity.value !== severity.value + ) + ); + } + }; + + return ( +
+ {severities.map((severity) => { + return ( + + handleOnSeverityChange(severity, isChecked) + } + /> + ); + })} +
+ ); +}; + +export default SeverityOptions; diff --git a/packages/react-components/src/models/constants.ts b/packages/react-components/src/models/constants.ts index 4a7a83f..eacb756 100644 --- a/packages/react-components/src/models/constants.ts +++ b/packages/react-components/src/models/constants.ts @@ -16,6 +16,7 @@ export enum PasswordKeys { API_KEY = 'api_key', TENANT_ID = 'tenant_id', INGEST_METADATA_ONLY = 'ingest_metadata_only', + SEVERITIES_FILTER = 'severities_filter', } export enum CollectionKeys { diff --git a/packages/react-components/src/models/flare.ts b/packages/react-components/src/models/flare.ts index fe84ac3..9a80c9a 100644 --- a/packages/react-components/src/models/flare.ts +++ b/packages/react-components/src/models/flare.ts @@ -8,3 +8,9 @@ export enum ConfigurationStep { UserPreferences = 2, Completed = 3, } + +export interface Severity { + value: string; + label: string; + color: string; +} diff --git a/packages/react-components/src/tests/flare.fixture.tsx b/packages/react-components/src/tests/flare.fixture.tsx new file mode 100644 index 0000000..bc1c954 --- /dev/null +++ b/packages/react-components/src/tests/flare.fixture.tsx @@ -0,0 +1,31 @@ +import { Severity } from '../models/flare'; + +export function getAllSeverities(): Severity[] { + return [ + { + value: 'info', + label: 'Info', + color: '#A7C4FF', + }, + { + value: 'low', + label: 'Low', + color: '#FFE030', + }, + { + value: 'medium', + label: 'Medium', + color: '#F8C100', + }, + { + value: 'high', + label: 'High', + color: '#FF842A', + }, + { + value: 'critical', + label: 'Critical', + color: '#FF0C47', + }, + ]; +} diff --git a/packages/react-components/src/tests/setupConfiguration.unit.tsx b/packages/react-components/src/tests/setupConfiguration.unit.tsx index a352f31..0cbdbd7 100644 --- a/packages/react-components/src/tests/setupConfiguration.unit.tsx +++ b/packages/react-components/src/tests/setupConfiguration.unit.tsx @@ -1,5 +1,45 @@ -import { getRedirectUrl } from '../utils/setupConfiguration'; +import { + convertSeverityFilterToArray, + getRedirectUrl, + getSeverityFilterValue, +} from '../utils/setupConfiguration'; +import { getAllSeverities } from './flare.fixture'; test('Flare Redirect URL', () => { expect(getRedirectUrl()).toBe('/app/flare'); }); + +/** Severity filters */ +test('Select one severity', () => { + const allSeverities = getAllSeverities(); + const selectedSeverities = [allSeverities[0]]; + const severityFilter = getSeverityFilterValue(selectedSeverities, allSeverities); + expect(severityFilter).toBe(allSeverities[0].value); +}); + +test('Select no severity', () => { + const allSeverities = getAllSeverities(); + const selectedSeverities = []; + expect(() => getSeverityFilterValue(selectedSeverities, allSeverities)).toThrow(Error); +}); + +test('Select all severities', () => { + const allSeverities = getAllSeverities(); + const selectedSeverities = [...allSeverities]; + const severityFilter = getSeverityFilterValue(selectedSeverities, allSeverities); + expect(severityFilter).toBe(''); +}); + +test('Convert severity filter (all severities)', () => { + const allSeverities = getAllSeverities(); + const severityFilter = []; + const severities = convertSeverityFilterToArray(severityFilter, allSeverities); + expect(severities).toStrictEqual(allSeverities); +}); + +test('Convert severity filter (part of them)', () => { + const allSeverities = getAllSeverities(); + const severityFilter = [allSeverities[0].value]; + const severities = convertSeverityFilterToArray(severityFilter, allSeverities); + expect(severities).toStrictEqual([allSeverities[0]]); +}); diff --git a/packages/react-components/src/utils/setupConfiguration.ts b/packages/react-components/src/utils/setupConfiguration.ts index 8c2197e..f6c72c9 100644 --- a/packages/react-components/src/utils/setupConfiguration.ts +++ b/packages/react-components/src/utils/setupConfiguration.ts @@ -8,7 +8,7 @@ import { PasswordKeys, STORAGE_REALM, } from '../models/constants'; -import { Tenant } from '../models/flare'; +import { Severity, Tenant } from '../models/flare'; import { SplunkCollectionItem, SplunkRequestResponse, @@ -77,6 +77,16 @@ function fetchUserTenants(apiKey: string): Promise> { ); } +function fetchSeverityFilters(apiKey: string): Promise> { + const service = createService(); + const data = { apiKey }; + return promisify(service.post)('/services/fetch_severity_filters', data).then( + (response: SplunkRequestResponse) => { + return response.data.severities; + } + ); +} + function doesPasswordExist(storage: SplunkStoragePasswordAccessors, key: string): boolean { const passwordId = `${STORAGE_REALM}:${key}:`; @@ -111,7 +121,8 @@ async function saveConfiguration( apiKey: string, tenantId: number, indexName: string, - isIngestingMetadataOnly: boolean + isIngestingMetadataOnly: boolean, + severitiesFilter: string ): Promise { const service = createService(); const storagePasswords = await promisify(service.storagePasswords().fetch)(); @@ -122,6 +133,7 @@ async function saveConfiguration( PasswordKeys.INGEST_METADATA_ONLY, `${isIngestingMetadataOnly}` ); + await savePassword(storagePasswords, PasswordKeys.SEVERITIES_FILTER, `${severitiesFilter}`); await saveIndexForIngestion(service, indexName); const isFirstConfiguration = await fetchIsFirstConfiguration(); if (isFirstConfiguration) { @@ -225,6 +237,15 @@ async function fetchIngestMetadataOnly(): Promise { }); } +async function fetchSeveritiesFilter(): Promise> { + const savedSeverities = await fetchPassword(PasswordKeys.SEVERITIES_FILTER); + if (savedSeverities) { + return savedSeverities.split(','); + } + + return []; +} + async function createFlareIndex(): Promise { const service = createService(); const isFirstConfiguration = await fetchIsFirstConfiguration(); @@ -289,14 +310,49 @@ async function fetchVersionName(defaultValue: string): Promise { return getConfigurationStanzaValue(service, 'app', 'launcher', 'version', defaultValue); } +function convertSeverityFilterToArray( + severitiesFilter: string[], + allSeverities: Severity[] +): Severity[] { + if (severitiesFilter.length === 0) { + return [...allSeverities]; + } + const severities: Severity[] = []; + severitiesFilter.forEach((severityValue) => { + const foundSeverityMatch = allSeverities.find( + (severity) => severity.value === severityValue + ); + if (foundSeverityMatch) { + severities.push(foundSeverityMatch); + } + }); + return severities; +} + +function getSeverityFilterValue(selectedSeverities: Severity[], allSeverities: Severity[]): string { + let severitiesFilter = ''; + + if (selectedSeverities.length === 0) { + throw new Error('At least one severity must be selected'); + } + + // Only set a filter if the user did not select everything + if (selectedSeverities.length !== allSeverities.length) { + severitiesFilter = selectedSeverities.map((severity) => severity.value).join(','); + } + return severitiesFilter; +} + export { createFlareIndex, fetchApiKey, fetchApiKeyValidation, fetchAvailableIndexNames, + fetchSeverityFilters, fetchCollectionItems, fetchCurrentIndexName, fetchIngestMetadataOnly, + fetchSeveritiesFilter, fetchTenantId, fetchUserTenants, fetchVersionName, @@ -304,4 +360,6 @@ export { getRedirectUrl, redirectToHomepage, saveConfiguration, + getSeverityFilterValue, + convertSeverityFilterToArray, };