[Security Solution] Siem signals -> alerts as data field and index aliases#106049
[Security Solution] Siem signals -> alerts as data field and index aliases#106049marshallmain merged 39 commits intoelastic:masterfrom
Conversation
|
Pinging @elastic/security-detections-response (Team:Detections and Resp) |
|
Pinging @elastic/security-solution (Team: SecuritySolution) |
|
|
||
| const concreteIndexName = `${alias}-000001`; | ||
|
|
||
| if (!aliasExists) { |
There was a problem hiding this comment.
I removed this check so that we can add the .alerts-security.alerts alias to the .siem-signals indices before the .alerts-security.alerts concrete indices are actually created. Otherwise, we would have to wait until the first new alert was written to .alerts before we could add the alias to .siem-signals. This way we can always treat all existing alerts as though they are in .alerts and don't have to worry about handling old and new alerts separately.
There was a problem hiding this comment.
This check has now been replaced with a more specific check - querying to find concrete indices rather than the alias.
| response.body.items.length > 0 && | ||
| response.body.items?.[0]?.index?.error?.type === 'index_not_found_exception' | ||
| (response.body.items?.[0]?.index?.error?.type === 'index_not_found_exception' || | ||
| response.body.items?.[0]?.index?.error?.type === 'illegal_argument_exception') |
There was a problem hiding this comment.
illegal_argument_exception is returned if the alias exists but has no write_index.
There was a problem hiding this comment.
Unfortunately illegal_argument_exception can be returned for many reasons other than the lack of a write index. I can imagine two ways to reduce the risk of misinterpretation:
- Parse the error message (has other risks).
- Query the alias for
is_write_alias.
Do you think it's safe enough to just assume the reason?
There was a problem hiding this comment.
That's a good point, I'll add logic similar to the old "alias exists" check in createWriteTargetIfNeeded but instead checking to ensure the alias has no write target before attempting to create a write target for it. You're right that otherwise we could end up with illegal_argument_exceptions that cause us to try creating a new write target and that would cause problems if the alias already has a write target.
There was a problem hiding this comment.
I replaced the aliasExists check with a more general "index exists" check on the AAD indices. This makes the createWriteTargetIfNeeded function safer to call optimistically when we encounter index_not_found or illegal_argument exceptions while still allowing the security solution to add AAD aliases to our .siem-signals indices. Now, if we do get an illegal_argument_exception for a reason other than "no concrete AAD indices exist", then createWriteTargetIfNeeded will return without making new indices and potentially making things worse.
weltenwort
left a comment
There was a problem hiding this comment.
Since I'm lacking any context about the SIEM signals indices I limited myself to inspecting the changes in the rule_registry. I left two questions about that below.
Aside from that I'm seeing when running Kibana against a cluster with existing .siem-signals indices:
[error][plugins][ruleRegistry] Failed to PUT mapping for alias .siem-signals-amsterdam: Missing required parameter: body
| response.body.items.length > 0 && | ||
| response.body.items?.[0]?.index?.error?.type === 'index_not_found_exception' | ||
| (response.body.items?.[0]?.index?.error?.type === 'index_not_found_exception' || | ||
| response.body.items?.[0]?.index?.error?.type === 'illegal_argument_exception') |
There was a problem hiding this comment.
Unfortunately illegal_argument_exception can be returned for many reasons other than the lack of a write index. I can imagine two ways to reduce the risk of misinterpretation:
- Parse the error message (has other risks).
- Query the alias for
is_write_alias.
Do you think it's safe enough to just assume the reason?
| }, | ||
| }); | ||
| } catch (err) { | ||
| // something might have created the index already, that sounds OK |
There was a problem hiding this comment.
What if the index already exists, but isn't set as the write index for the alias? Wouldn't we have to add the alias here?
There was a problem hiding this comment.
Is it possible to get into a scenario where the index exists but the alias has no write target, other than by an admin manually creating the index but creating it incorrectly? My assumption was that this function is the only place where the .alerts index can be created so we can rely on it being correctly associated with the alias as the write index if it exists. I was under the impression that the error suppression here was to avoid throwing an error if multiple rule executors attempt to create the index at the same time.
Perhaps if we get a resource_already_exists_exception we could fetch the index settings to ensure that whatever created the index created it correctly?
There was a problem hiding this comment.
I can't think of a way this code would produce inconsistent alias settings, but I've seen many situations in which backup scripts have not correctly restored all aliases. We might not be able to automatically fix all such situations, but we should try to to make the error message as useful as possible IMHO.
What if we split setting the write index for an existing index into a separate function and call it or createWriteTargetIfNeeded depending on the original error message (index_not_found_exception or illegal_argument_exceptions + alias check)?
There was a problem hiding this comment.
For both index_not_found_exception and illegal_argument_exception we will want to be able to create a new index to be the write target. Can we punt on adding the alias to existing indices for now and just log the errors? For the security solution use case, we need to be able to create a write index when the alias already exists and has no write index, but we don't need to support adding the alias to existing AAD indices.
Going back to the original question
What if the index already exists, but isn't set as the write index for the alias? Wouldn't we have to add the alias here?
I think not - if the index exists but isn't set as the write index, something has gone wrong but I don't think we have enough information to safely attempt to automatically fix it. If this happens due to a scenario like backup scripts not restoring everything correctly, then (with the latest changes I pushed up) the initial bulk index would fail, we'd call createWriteTargetIfNeeded, and it would find the existing AAD indices even if they aren't aliased correctly so it would not modify the alias or make any indices. The writer would then attempt to write again, fail again, and throw the error up to the rule executor to be logged and maybe displayed in the UI. At that point the error should be available to bubble up to users and tell them that their alias has no write index. Does that sound reasonable?
Re: automatically fixing things, I'm imagining a case where a user restores .alerts-000002 and .alerts-000003 from backups but accidentally sets neither as the write index. If we optimistically create .alerts-000001 as the new write index and set it up with ILM, then everything works fine for a while but eventually when ES attempts to rollover to .alerts-000002 the rollover fails and would require manual intervention to get back to normal. To me, it seems worse to have the system seem to work normally for a while and then fail much later since it would be harder to figure out the root cause (it's easier to see the correlation between restoring backups and failing to write alerts if there's not a long time between the restore and when alert writes started failing).
There was a problem hiding this comment.
good points 👍 showing a relatively accurate error to the user should be enough for now
|
@weltenwort re: good catch, thanks! |
| } catch (err) { | ||
| // something might have created the index already, that sounds OK | ||
| if (err?.meta?.body?.error?.type !== 'resource_already_exists_exception') { | ||
| // If the index already exists and it's the write index for the alias, |
There was a problem hiding this comment.
If the index already exists, will we ever get here? indicesExist will have been true in line 127. The error is silent in that case.
There was a problem hiding this comment.
It's still possible to get here if multiple code paths attempt to create the index at the same time. It can be triggered in testing if the index doesn't exist yet with a construct like
ruleDataClient.getWriter().bulk(request);
ruleDataClient.getWriter().bulk(request);
in this case both calls race to attempt to make the index and often one hits a resource_already_exists_exception. It's also theoretically possible that some other source (e.g. user, backup script) could make the index in between the indices exist check and the index creation, so by fetching the index after getting the exception we can check that it was created correctly.
There was a problem hiding this comment.
True. But if the lack of a write index was the cause for this function being called we'd just fail silently because indicesExist === true. Would raising an exception in an else branch for the top level check do the trick?
There was a problem hiding this comment.
Ah I see. I was counting on the bulk retry to fail again in that case and throw the error, but I didn't notice that it wasn't checking for errors on the retry. I added handling for both in 28722b1 - the retry bulk call now throws errors if it encounters any, and if createWriteTargetIfNeeded finds existing indices then it checks to make sure one of them is the write index and throws an error otherwise. I think the bulk retry error handling alone would be sufficient to avoid silently failing, but checking the alias specifically inside createWriteTargetIfNeeded allows us to have a more specific error message for that case so I added both.
| const ALERT_EVALUATION_VALUE = `${ALERT_NAMESPACE}.evaluation.value` as const; | ||
| const ALERT_ID = `${ALERT_NAMESPACE}.id` as const; | ||
| const ALERT_OWNER = `${ALERT_NAMESPACE}.owner` as const; | ||
| const ALERT_CONSUMERS = `${ALERT_NAMESPACE}.consumers` as const; |
There was a problem hiding this comment.
Are these going to be shared? There are a bunch of fields in security_solution, not in here and I'm just trying to figure out whether they should be added here or just kept within security_solution i.e. all the original_event fields here:
There was a problem hiding this comment.
Yeah the fields here are expected to be used by all solutions that use alerts as data. The original_event fields are security-specific because other solutions are not attempting to preserve the whole source documents (so they don't need to preserve the original event.* fields when they populate fields like event.kind: signal)
There was a problem hiding this comment.
Shouldn't it be kibana.consumers? Here it's kibana.alert.consumers.
There was a problem hiding this comment.
I'm not sure. For now I just made it similar to the existing ALERT_PRODUCER. I think as long as we're using the constants from this package, follow up naming discussions can be had to change the field names as needed.
There was a problem hiding this comment.
I'm just looking at the spec and I can't find kibana.alert.consumers there, but:
I think it's kibana.consumers because the concept of shareability between features might be more broad than the alerting domain.
Not something that this PR should be responsible for, so I think we can merge it as is, but I'd double-check this naming at the next alerts-as-data meeting.
| @@ -34,6 +34,7 @@ const ALERT_EVALUATION_THRESHOLD = `${ALERT_NAMESPACE}.evaluation.threshold` as | |||
| const ALERT_EVALUATION_VALUE = `${ALERT_NAMESPACE}.evaluation.value` as const; | |||
| const ALERT_ID = `${ALERT_NAMESPACE}.id` as const; | |||
| const ALERT_OWNER = `${ALERT_NAMESPACE}.owner` as const; | |||
There was a problem hiding this comment.
Is my understanding correct that kibana.alert.owner is going to get removed in favour of kibana.consumers?
| const ALERT_EVALUATION_VALUE = `${ALERT_NAMESPACE}.evaluation.value` as const; | ||
| const ALERT_ID = `${ALERT_NAMESPACE}.id` as const; | ||
| const ALERT_OWNER = `${ALERT_NAMESPACE}.owner` as const; | ||
| const ALERT_CONSUMERS = `${ALERT_NAMESPACE}.consumers` as const; |
There was a problem hiding this comment.
Shouldn't it be kibana.consumers? Here it's kibana.alert.consumers.
| index: concreteIndexName, | ||
| }); | ||
| if (!existingIndices[concreteIndexName]?.aliases?.[alias]?.is_write_index) { | ||
| throw Error( |
There was a problem hiding this comment.
Nit: throw new (although seems like there's no difference for the built-in Error constructor, it may or may not work with custom exceptions)
There was a problem hiding this comment.
as of ES5 they're supposed to be equivalent, right? https://es5.github.io/#x15.11.1
There was a problem hiding this comment.
Yes, but for a custom exception this might not work.
Example:
class CustomError extends Error {
constructor(value) {
super('Hello this is my message');
this.value = value;
}
}
let e = CustomError(42);
console.log(e.value); // ?Actually, I just checked it in Chrome, and calling CustomError(42) without new just throws TypeError: Class constructor CustomError cannot be invoked without 'new', seems like V8 has a special handling for class constructors.
However, this won't work the same way for any constructor defined as a function. In general, something like that would be needed:
function User(name) {
if (!new.target) { // if you run me without new
return new User(name); // ...I will add new for you
}
this.name = name;
}
let john = User("John"); // redirects call to new User
alert(john.name); // JohnSee https://javascript.info/constructor-new#constructor-mode-test-new-target
Maybe I was overthinking when trying to explain it, but for me it's quite simple - let's use new everywhere and in all cases when calling constructors for the sake of safety and consistency. It's like ===.
Sorry, I'm 🚲 🏠 🎨 'ing
| ([_, aliasesObject]) => aliasesObject.aliases[alias]?.is_write_index | ||
| ) | ||
| ) { | ||
| throw Error( |
| (response.body.items.every( | ||
| (item) => item.index?.error?.type === 'index_not_found_exception' | ||
| ) || | ||
| response.body.items.every( | ||
| (item) => item.index?.error?.type === 'illegal_argument_exception' | ||
| )) |
There was a problem hiding this comment.
When can we get illegal_argument_exception and why?
There was a problem hiding this comment.
illegal_argument_exception can be returned when trying to write to an alias that exists but has no write index
| await esClient.indices.putMapping({ | ||
| index: indexName, | ||
| body: newMapping, | ||
| allow_no_indices: true, | ||
| } as estypes.IndicesPutMappingRequest); |
There was a problem hiding this comment.
Seems like it sequentially updates mappings of every concrete index matching the alias (index). If there's a lot of concrete indices, this can take additional time spent for network roundtrips etc.
Is there a reason why we can't update all concrete indices at once via the alias?
If yes, should we maybe split it into buckets of N indices (e.g. 10 or 25), run updates in parallel within each bucket and process buckets sequentially?
There was a problem hiding this comment.
Yeah bucketing these requests and making them in parallel would be much better. Unfortunately we can't do them all at once because we need to extract the original version from the mapping to preserve it, but we also need to add the aliases_version so that we know which indices have had the compatibility-aliases added. If we do it all at once, then if we apply the mapping across indices with different versions then the versions will be wrong for at least some of them.
There was a problem hiding this comment.
Unfortunately we can't do them all at once because we need to extract the original
versionfrom the mapping to preserve it
Is it because the PUT mappings request replaces the whole _meta object? Sounds counter-intuitive since PUT mappings behaves more like a PATCH, so I'd expect it to replace _meta.aliases_version and preserve _meta.version.
Maybe I'm just not getting this (if yes, sorry about that):
If we do it all at once, then if we apply the mapping across indices with different versions then the versions will be wrong for at least some of them.
There was a problem hiding this comment.
Oh yeah PUT mapping behaves like a normal PUT with the _meta field unlike the mappings and replaces the whole thing. I was expecting the same thing you did initially and tried including only the aliases_version in the meta object and it ended up removing the version field.
| const templateExists = await getTemplateExists(esClient, index); | ||
| const templateExists = await esClient.indices.existsIndexTemplate({ name: index }); | ||
| if (templateExists) { | ||
| await deleteTemplate(esClient, index); |
There was a problem hiding this comment.
Similar question here: are getTemplateExists and deleteTemplate now deprecated and shouldn't be used (so should be deleted probably)?
Or maybe can we update them with the new implementation or rename to getLegacyTemplateExists and add a new one getNewTemplateExists?
There was a problem hiding this comment.
I think they're unused now. Do you think it's worth using functions like that as simple wrappers of ES API calls? Without having any extra logic in the functions it seems like they're extra overhead without much benefit.
| /** | ||
| @constant | ||
| @type {number} | ||
| @description This value represents the version of the field aliases that map the new field names | ||
| used for alerts-as-data to the old signal.* field names. If any .siem-signals-<space id> indices | ||
| have an aliases_version less than this value, the detections UI will call create_index_route and | ||
| and go through the index update process. Increment this number if making changes to the field | ||
| aliases we use to make signals forwards-compatible. | ||
| */ | ||
| export const SIGNALS_FIELD_ALIASES_VERSION = 1; |
There was a problem hiding this comment.
Thank you for adding comments here and in other places, it's very helpful 👍
| export const SIGNALS_FIELD_ALIASES_VERSION = 1; | ||
| export const MIN_EQL_RULE_INDEX_VERSION = 2; | ||
| export const ALIAS_VERSION_FIELD = 'aliases_version'; |
There was a problem hiding this comment.
Nit: I'd rename these 2 constants to ALERTS_AS_DATA_FIELD_ALIASES_VERSION and ALERTS_AS_DATA_FIELD_ALIASES_VERSION_FIELD.
| // TODO: convert the aliases to FieldMaps. Requires enhancing FieldMap to support alias path. | ||
| // Split aliases by component template since we need to alias some fields in technical field mappings, | ||
| // some fields in security solution specific component template. | ||
| const aliases: Record<string, estypes.MappingProperty> = {}; | ||
| Object.entries(aadFieldConversion).forEach(([key, value]) => { | ||
| aliases[key] = { | ||
| type: 'alias', | ||
| path: value, | ||
| }; | ||
| }); |
There was a problem hiding this comment.
This is not used yet, right?
Are they going to be included into an existing component template (security.alerts-mappings), a new component template or into the index template?
There was a problem hiding this comment.
Yeah this isn't used yet. I had planned to add them into the mapping as a single unit, but the problem is you can't add aliases to a mapping unless the field that is being aliased exists in that mapping - and since we're splitting the mapping into different component templates, we need to add the appropriate aliases to each component individually. I'm afraid we have to add aliases to the technical fields too, which is currently handled by the rule registry.
There was a problem hiding this comment.
I see, sounds not very good... Maybe we can workaround it somehow, I'll need to think about it and play with ES API... I don't have a clear suggestion right now.
Np at this point!
|
@elasticmachine merge upstream |
💚 Build Succeeded
Metrics [docs]Async chunks
Page load bundle
Unknown metric groupsAPI count
API count missing comments
History
To update your PR or re-run it, just comment with: |
banderror
left a comment
There was a problem hiding this comment.
I will go ahead and approve this PR. It looks good enough to me, minor issues can be addressed in follow-up PRs. I think at this point we need to start integrating our PRs more often to start testing everything in integration asap.
Thanks @marshallmain, it's a big and complex one 💪
💔 Backport failed
To backport manually run: |
…iases (#106049) (#107817) * Add aliases mapping signal fields to alerts as data fields * Add aliases mapping alerts as data fields to signal fields * Replace siem signals templates per space and add AAD index aliases to siem signals indices * Remove first version of new mapping json file * Convert existing legacy siem-signals templates to new ES templates * Catch 404 if siem signals templates were already updated * Enhance error message when index exists but is not write index for alias * Check if alias write index exists before creating new write index * More robust write target creation logic * Add RBAC required fields for AAD to siem signals indices * Fix index name in index mapping update * Throw errors if bulk retry fails or existing indices are not writeable * Add new template to routes even without experimental rule registry flag enabled * Check template version before updating template * First pass at modifying routes to handle inserting field aliases * Always insert field aliases when create_index_route is called * Update snapshot test * Remove template update logic from plugin setup * Use aliases_version field to detect if aliases need update * Fix bugs * oops update snapshot * Use internal user for PUT alias to fix perms issue * Update comment * Disable new resource creation if ruleRegistryEnabled * Only attempt to add aliases if siem-signals index already exists * Fix types, add aliases to aad indices, use package field names * Undo adding aliases to AAD indices * Remove unused import * Update test and snapshot oops * Filter out kibana.* fields from generated signals * Update cypress test to account for new fields in table * Properly handle space ids with dashes in them Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> # Conflicts: # x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts # x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts
…iases (elastic#106049) * Add aliases mapping signal fields to alerts as data fields * Add aliases mapping alerts as data fields to signal fields * Replace siem signals templates per space and add AAD index aliases to siem signals indices * Remove first version of new mapping json file * Convert existing legacy siem-signals templates to new ES templates * Catch 404 if siem signals templates were already updated * Enhance error message when index exists but is not write index for alias * Check if alias write index exists before creating new write index * More robust write target creation logic * Add RBAC required fields for AAD to siem signals indices * Fix index name in index mapping update * Throw errors if bulk retry fails or existing indices are not writeable * Add new template to routes even without experimental rule registry flag enabled * Check template version before updating template * First pass at modifying routes to handle inserting field aliases * Always insert field aliases when create_index_route is called * Update snapshot test * Remove template update logic from plugin setup * Use aliases_version field to detect if aliases need update * Fix bugs * oops update snapshot * Use internal user for PUT alias to fix perms issue * Update comment * Disable new resource creation if ruleRegistryEnabled * Only attempt to add aliases if siem-signals index already exists * Fix types, add aliases to aad indices, use package field names * Undo adding aliases to AAD indices * Remove unused import * Update test and snapshot oops * Filter out kibana.* fields from generated signals * Update cypress test to account for new fields in table * Properly handle space ids with dashes in them Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
…-png-pdf-report-type * 'master' of github.com:elastic/kibana: (392 commits) update linting doc (elastic#105748) [APM] Various improvements from elastic#104851 (elastic#107726) Update dependency @elastic/charts to v33.2.0 (master) (elastic#107842) Fix default route link on kibana homepage (elastic#107809) [APM] Invalidate trackPageview on route change (elastic#107741) Service map backend links (elastic#107317) [index patterns] index pattern create modal (elastic#101853) [RAC] integrating rbac search strategy with alert table (elastic#107242) [Security Solution] Siem signals -> alerts as data field and index aliases (elastic#106049) [Metrics UI] Add checkbox to optionally drop partial buckets (elastic#107676) [Metrics UI] Fix metric threshold preview regression (elastic#107674) Disable Product check in @elastic/elasticsearch-js (elastic#107642) [App Search] Migrate Crawler Status Indicator, Crawler Status Banner, and Crawl Request polling (elastic#107603) [Security Solution, Lists] Replace legacy imports from 'elasticsearch' package (elastic#107226) [maps] asset tracking tutorial (elastic#104552) [scripts/build_ts_refs] when using `--clean` initialize caches (elastic#107777) Upgrade EUI to v36.1.0 (elastic#107231) [RAC] [TGrid] Implements cell actions in the TGrid (elastic#107771) Realign cypress/ccs_integration with cypress/integration (elastic#107743) Allow optional OSS to X-Pack dependencies (elastic#107432) ... # Conflicts: # x-pack/examples/reporting_example/public/application.tsx # x-pack/examples/reporting_example/public/components/app.tsx # x-pack/plugins/canvas/public/services/legacy/stubs/reporting.ts # x-pack/plugins/reporting/common/types.ts # x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx # x-pack/plugins/reporting/public/management/mount_management_section.tsx # x-pack/plugins/reporting/public/management/report_listing.test.tsx # x-pack/plugins/reporting/public/plugin.ts # x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx # x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts
| it('Displays the unmapped field on the table', () => { | ||
| const expectedUnmmappedField = { | ||
| row: 55, | ||
| row: 88, |
* Ensures no constant_keyword mappings * Bumps index version by 1, since it was already bumped by 10 for 7.15 in elastic#106049
* Update signals mappings to include ECS 1.11 * Ensures no constant_keyword mappings * Bumps index version by 1, since it was already bumped by 10 for 7.15 in #106049 * Remove threat.indicator mappings from signals indices Until the old, 7.14 enrichment mappings (which define threat.indicator as nested) are in our rearview, we cannot add the official, non-nested threat.indicator mappings as they'll conflict.
* Update signals mappings to include ECS 1.11 * Ensures no constant_keyword mappings * Bumps index version by 1, since it was already bumped by 10 for 7.15 in elastic#106049 * Remove threat.indicator mappings from signals indices Until the old, 7.14 enrichment mappings (which define threat.indicator as nested) are in our rearview, we cannot add the official, non-nested threat.indicator mappings as they'll conflict.
* Update signals mappings to include ECS 1.11 * Ensures no constant_keyword mappings * Bumps index version by 1, since it was already bumped by 10 for 7.15 in #106049 * Remove threat.indicator mappings from signals indices Until the old, 7.14 enrichment mappings (which define threat.indicator as nested) are in our rearview, we cannot add the official, non-nested threat.indicator mappings as they'll conflict. Co-authored-by: Ryland Herrick <ryalnd@gmail.com>

Summary
Addresses #100103
The purpose of this work is to make the .siem-signals indices forwards-compatible and the alerts-as-data indices backwards-compatible. The field aliases introduced in this PR map the
signalfields to their correspondingkibana.alertcounterparts (if they exist). Some deprecatedsignalfields such assignal.parentwill be removed inkibana.alertso they won't be available anymore. Forwards-compatibility on .siem-signals means we only have to maintain a single set of queries/visualizations in our UI and they will still work on old data. Backwards compatibility on alerts as data allows existing user rules, queries, and visualizations against .siem-signals to work seamlessly against the new alerts-as-data indices as well.Testing:
Adding the aliases is done by visiting the Alerts page in the Security Solution with the proper permissions (the same way an admin visits the page initially to create the .siem-signals resources). Admins do not need any permissions beyond what is currently required (https://www.elastic.co/guide/en/security/7.x/detections-permissions-section.html).
Done:
Follow up work:
Checklist
Delete any items that are not applicable to this PR.
Risk Matrix
Delete this section if it is not applicable to this PR.
Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release.
When forming the risk matrix, consider some of the following examples and how they may potentially impact the change:
For maintainers