Skip to content

Commit fa4b91d

Browse files
CAWilson94hop-dev
andauthored
Generalising update detection to work with multiple integrations (elastic#237792)
### Summary This PR introduces support for multiple integrations by gentrifying the query for update detection matchers fields, instead of just the okta 'roles'. The changes also include updating constants and bringing in AD when loading in Saved Objects - monitoring sources. **Index and Matcher Logic** * Updated the logic for determining the last full sync markers index to use the AD users index when the integration is AD, ensuring correct data source usage. * Modified the privileged user search query to dynamically include the relevant matcher field (such as `user.group.name` for AD) in the `_source` fields, supporting flexible matching across integrations. [[1]](diffhunk://#diff-dae1d0e0452709a5c2d74497a2c3a2897584faeff73e3ce3f785044cde4673bcR54) [[2]](diffhunk://#diff-dae1d0e0452709a5c2d74497a2c3a2897584faeff73e3ce3f785044cde4673bcL74-R75) * Updated the pattern matcher service to pass the matcher field for AD integrations when building the search body, ensuring correct field matching for privileged status detection. ## How To Test If stuck at any point, I posted all the commands I have been using in a google doc "paste bin" for reference [ ---> here<---](https://docs.google.com/document/d/1SG5NvSqiFx_-u_I9-TXI1JL7mF2s4pTrJaezatzhHFg/edit?tab=t.0#bookmark=id.bh4tzq75v6z) Some commands are duplicated, but I hope something helps 🤘 ### Update Detection 1. Head over to Document Generator and pull the ad-data branch - https://github.com/CAWilson94/security-documents-generator/tree/ad-data 2.Run Kibana and ES THEN generate a number of users (100 seems good) from `yarn start privileged-user-monitoring`, choosing the integrations option only. 3. Open Kibana dev tools - run engine init `POST kbn:/api/entity_analytics/monitoring/engine/init {}` 4. You will get an error in the console about full sync - no such index for okta entity-default, because these have not been created. Ignore this, or create that index. Up to you. 5. Check the monitoring list and make sure there are privileged users from your sync. Either through minitoring list endpoint or the internal index itself: `GET kbn:/api/entity_analytics/monitoring/users/list` OR ` POST .entity_analytics.monitoring.users-default/_search` From the index, the output looks like this: ``` "hits": [ { "_index": ".entity_analytics.monitoring.users-default", "_id": "4vAjvpkBcs32I8L9OZG9", "_score": 1, "_source": { "entity_analytics_monitoring": { "labels": [ { "field": "user.group.name", "source": "ff1ba7ff-e8cb-47e8-891d-787100ab7c1f", "value": "Enterprise Admins" } ] }, "@timestamp": "2025-10-07T10:06:43.637Z", "event": { "ingested": "2025-10-07T10:06:43.639331Z" }, "user": { "is_privileged": true, "name": "Alexandra.Abernathy", "entity": { "attributes": { "Privileged": true } } }, "labels": { "source_ids": [ "ff1ba7ff-e8cb-47e8-891d-787100ab7c1f" ], "sources": [ "entity_analytics_integration" ] } } }, ``` ### Deletion Detection **Getting Some Data** 1. Head over to Document Generator and pull the ad-data branch - https://github.com/CAWilson94/security-documents-generator/tree/ad-data 2.Run Kibana and ES THEN generate a number of users (100 seems good) from yarn start privileged-user-monitoring, choosing the integrations option only. **Generating FIRST set of fullSync Markers** 1. Have a look at the ad data added, and get the latest and oldest timestamps: ``` GET logs-entityanalytics_ad.user-default/_search { "size": 1, "sort": [{ "@timestamp": "desc" }] } GET logs-entityanalytics_ad.user-default/_search { "size": 1, "sort": [{ "@timestamp": "asc" }] } ``` To create the fullSync Marker wrapper documents outside of these times, make a started event right before the oldest and a completed event right after the latest: e.g. something like: ``` POST logs-entityanalytics_ad.user-default/_bulk { "create": {} } { "@timestamp": "2025-09-28T04:00:19.836Z", // CHANGE THIS "event": { "action": "started", "dataset": "entityanalytics_ad.user", "kind": "asset", "category": ["iam"], "type": ["info"] } } { "create": {} } { "@timestamp": "2025-09-28T05:53:19.836Z", // CHANGE THIS "event": { "action": "completed", "dataset": "entityanalytics_ad.user", "kind": "asset", "category": ["iam"], "type": ["info"] } } ``` **Initalize the Engine, Confirm SO Markers:** 1. (optional) shorten the INTERVAL for the engine to something like 3m, to give time to edit data between syncs: _...entity_analytics/privilege_monitoring/constants.ts_ 2. Initalize engine: `POST kbn:/api/entity_analytics/monitoring/engine/init{}` 3. On FIRST RUN: the engine will: * Look for a full sync in SO * If missing, fallback to the latest complete marker in the index. VERIFY this by looking at the SO: ``` GET kbn:/api/entity_analytics/monitoring/entity_source/list ``` Expected Shape: ``` "integrations": { "syncMarkerIndex": "logs-entityanalytics_ad.user-default", "syncData": { "lastUpdateProcessed": "…", "lastFullSync": "2025-09-28T05:53:19.836Z" ... ``` **No full sync - engine should skip** * Basically, don't add a second set of markers here. It should do nothing, logs should say _"No new full sync for source.."_ **Full sync with NO users in window - ALL Stale** 1. Add new start and complete markers after all the users timestamps, that wrap NO user docs. 2. Next engine run will detect a new full sync - find no users between the markers, and treat all CURRENT privileged (in monitoring index) users, as stale - soft delete. Validate: ``` POST .entity_analytics.monitoring.users-default/_search { "size": 100, "_source": ["user.*","labels.*","entity_analytics_monitoring.*","@timestamp","event.ingested"], "query": { "term": { "user.is_privileged": true } } } ``` Expected: 0 hits **Full sync with subset - only missing are stale** 1. Create Markers that wrap a time window you will populate 2. Create some new documents in AD within these time windows, ensuring they have the same username as some already in the monitoring index. 3. Check monitoring index after sync, see only missing names are soft deleted. ## Testing Notes and Gotcha's Because this has been a long testing journey: * Please make sure when you copy pasta these examples, you are changing timestamps to work with the markers and users created. And thank you for testing 🧪 🥳 --------- Co-authored-by: Mark Hopkin <[email protected]>
1 parent baa2399 commit fa4b91d

File tree

9 files changed

+62
-88
lines changed

9 files changed

+62
-88
lines changed

x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/data_sources/bulk/upsert.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,6 @@ export const bulkUpsertOperationsFactoryShared =
275275

276276
export const makeIntegrationOpsBuilder = (dataClient: PrivilegeMonitoringDataClient) => {
277277
const buildOps = bulkUpsertOperationsFactoryShared(dataClient);
278-
279278
return (usersChunk: PrivMonBulkUser[], source: MonitoringEntitySource) =>
280279
buildOps({
281280
users: usersChunk,
@@ -301,5 +300,5 @@ export const makeIndexOpsBuilder = (dataClient: PrivilegeMonitoringDataClient) =
301300
sourceLabel: 'index_sync',
302301
buildUpdateParams: (user) => ({ source_id: user.sourceId }),
303302
});
304-
return indexOperations;
303+
return indexOperations || [];
305304
};

x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/data_sources/constants.test.ts

Lines changed: 12 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,62 +5,25 @@
55
* 2.0.
66
*/
77
import { defaultMonitoringUsersIndex } from '../../../../../common/entity_analytics/privileged_user_monitoring/utils';
8-
import type { IntegrationType } from './constants';
98
import {
109
getMatchersFor,
1110
getStreamPatternFor,
1211
INTEGRATION_MATCHERS_DETAILED,
13-
INTEGRATION_TYPES,
1412
integrationsSourceIndex,
15-
OKTA_ADMIN_ROLES,
1613
STREAM_INDEX_PATTERNS,
1714
} from './constants';
1815

1916
describe('constants', () => {
2017
const baseIndex = `entity_analytics.privileged_monitoring`;
2118
const baseMonitoringUsersIndex = '.entity_analytics.monitoring';
22-
it('should export all constants', () => {
23-
expect(getMatchersFor).toBeDefined();
24-
});
25-
it('should have correct OKTA_ADMIN_ROLES', () => {
26-
expect(OKTA_ADMIN_ROLES).toMatchInlineSnapshot(`
27-
Array [
28-
"Super Administrator",
29-
"Organization Administrator",
30-
"Group Administrator",
31-
"Application Administrator",
32-
"Mobile Administrator",
33-
"Help Desk Administrator",
34-
"Report Administrator",
35-
"API Access Management Administrator",
36-
"Group Membership Administrator",
37-
"Read-only Administrator",
38-
]
39-
`);
40-
});
41-
42-
/* it('should have correct AD_ADMIN_ROLES', () => {
43-
expect(AD_ADMIN_ROLES).toEqual(['Domain Admins', 'Enterprise Admins']);
44-
expect(AD_ADMIN_ROLES.length).toBe(2);
45-
});*/
46-
47-
it('should have correct INTEGRATION_MATCHERS_DETAILED', () => {
48-
expect(INTEGRATION_MATCHERS_DETAILED.entityanalytics_okta.values).toEqual(OKTA_ADMIN_ROLES);
49-
// expect(INTEGRATION_MATCHERS_DETAILED.ad.values).toEqual(AD_ADMIN_ROLES);
50-
expect(INTEGRATION_MATCHERS_DETAILED.entityanalytics_okta.fields).toEqual(['user.roles']);
51-
// expect(INTEGRATION_MATCHERS_DETAILED.ad.fields).toEqual(['user.roles']);
52-
});
5319

5420
it('getMatchersFor returns correct matcher array', () => {
5521
expect(getMatchersFor('entityanalytics_okta')).toEqual([
5622
INTEGRATION_MATCHERS_DETAILED.entityanalytics_okta,
5723
]);
58-
// expect(getMatchersFor('ad')).toEqual([INTEGRATION_MATCHERS_DETAILED.ad]); add ad in follow-up PR
59-
});
60-
61-
it('IntegrationType type should only allow okta and ad', () => {
62-
const types: IntegrationType[] = ['entityanalytics_okta']; // add ad in follow-up PR
63-
expect(types).toEqual(INTEGRATION_TYPES);
24+
expect(getMatchersFor('entityanalytics_ad')).toEqual([
25+
INTEGRATION_MATCHERS_DETAILED.entityanalytics_ad,
26+
]);
6427
});
6528

6629
it('should generate defaultMonitoringUsersIndex', () => {
@@ -72,22 +35,26 @@ describe('constants', () => {
7235
expect(integrationsSourceIndex('default', 'entityanalytics_okta')).toBe(
7336
`${baseMonitoringUsersIndex}.sources.entityanalytics_okta-default`
7437
);
75-
/* expect(integrationsSourceIndex('space1', 'ad')).toBe(
76-
`${baseMonitoringUsersIndex}.sources.ad-space1`
77-
); */
38+
expect(integrationsSourceIndex('space1', 'entityanalytics_ad')).toBe(
39+
`${baseMonitoringUsersIndex}.sources.entityanalytics_ad-space1`
40+
);
7841
});
7942

8043
it('should generate correct stream index patterns', () => {
8144
expect(STREAM_INDEX_PATTERNS.entityanalytics_okta('default')).toBe(
8245
'logs-entityanalytics_okta.user-default'
8346
);
84-
// expect(STREAM_INDEX_PATTERNS.ad('space1')).toBe('logs-entityanalytics_ad.user-space1');
47+
expect(STREAM_INDEX_PATTERNS.entityanalytics_ad('space1')).toBe(
48+
'logs-entityanalytics_ad.user-space1'
49+
);
8550
});
8651

8752
it('getStreamPatternFor returns correct pattern', () => {
8853
expect(getStreamPatternFor('entityanalytics_okta', 'default')).toBe(
8954
'logs-entityanalytics_okta.user-default'
9055
);
91-
// expect(getStreamPatternFor('ad', 'space1')).toBe('logs-entityanalytics_ad.user-space1');
56+
expect(getStreamPatternFor('entityanalytics_ad', 'space1')).toBe(
57+
'logs-entityanalytics_ad.user-space1'
58+
);
9259
});
9360
});

x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/data_sources/constants.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ export const OKTA_ADMIN_ROLES: string[] = [
2121
'Read-only Administrator',
2222
];
2323

24-
export const AD_ADMIN_ROLES: string[] = ['Domain Admins', 'Enterprise Admins'];
24+
export const AD_ADMIN_GROUPS: string[] = ['Domain Admins', 'Enterprise Admins'];
2525

2626
export const INTEGRATION_MATCHERS_DETAILED: Record<IntegrationType, Matcher> = {
2727
entityanalytics_okta: { fields: ['user.roles'], values: OKTA_ADMIN_ROLES },
28-
// ad: { fields: ['user.roles'], values: AD_ADMIN_ROLES },
28+
entityanalytics_ad: { fields: ['user.group.name'], values: AD_ADMIN_GROUPS },
2929
};
3030

3131
export const getMatchersFor = (integration: IntegrationType): Matcher[] => [
@@ -41,12 +41,12 @@ export const integrationsSourceIndex = (namespace: string, integrationName: stri
4141
export const PRIVILEGE_MONITORING_PRIVILEGE_CHECK_API =
4242
'/api/entity_analytics/monitoring/privileges/privileges';
4343

44-
export const INTEGRATION_TYPES = ['entityanalytics_okta' /* 'ad'*/] as const;
44+
export const INTEGRATION_TYPES = ['entityanalytics_okta', 'entityanalytics_ad'] as const;
4545
export type IntegrationType = (typeof INTEGRATION_TYPES)[number];
4646

4747
export const STREAM_INDEX_PATTERNS: Record<IntegrationType, (namespace: string) => string> = {
4848
entityanalytics_okta: (namespace) => `logs-entityanalytics_okta.user-${namespace}`,
49-
// ad: (namespace) => `logs-entityanalytics_ad.user-${namespace}`,
49+
entityanalytics_ad: (namespace) => `logs-entityanalytics_ad.user-${namespace}`,
5050
};
5151

5252
export const getStreamPatternFor = (integration: IntegrationType, namespace: string): string =>

x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/data_sources/sync/integrations/deletion_detection/deletion_detection.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const createDeletionDetectionService = (
4545
'debug',
4646
`No new full sync for source ${source.id}; skipping deletion detection.`
4747
);
48+
return;
4849
}
4950

5051
const [completedEventTimeStamp, startedEventTimeStamp] = await Promise.all([
@@ -75,8 +76,13 @@ export const createDeletionDetectionService = (
7576
const staleUsers = await findStaleUsers(
7677
source.id,
7778
allIntegrationsUserNames,
78-
'entity_analytics_integration' // TODO: confirm index/type constant
79+
'entity_analytics_integration'
7980
);
81+
82+
if (staleUsers.length === 0) {
83+
dataClient.log('debug', `No stale users to soft delete for source ${source.id}`);
84+
return;
85+
}
8086
const ops = bulkUtilsService.bulkSoftDeleteOperations(
8187
staleUsers,
8288
dataClient.index,
@@ -115,7 +121,6 @@ export const createDeletionDetectionService = (
115121
const usersToDelete: string[] = [];
116122
while (fetchMore) {
117123
const privilegedMonitoringUsers = await esClient.search<never, StaleUsersAggregations>({
118-
// you need to change the name for this type
119124
index: source.indexPattern,
120125
...buildFindUsersSearchBody({
121126
timeGte: startedEventTimeStamp,

x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/data_sources/sync/integrations/update_detection/privileged_status_match.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,19 @@ export const createPatternMatcherService = (
102102
while (fetchMore) {
103103
const response = await esClient.search<never, PrivMatchersAggregation>({
104104
index: source.indexPattern,
105-
...buildPrivilegedSearchBody(script, lastProcessedTimeStamp, afterKey, pageSize),
105+
...buildPrivilegedSearchBody(
106+
script,
107+
lastProcessedTimeStamp,
108+
source.matchers[0].fields[0],
109+
afterKey,
110+
pageSize
111+
),
106112
});
107113

108114
const aggregations = response.aggregations;
109115
const privUserAgg = response.aggregations?.privileged_user_status_since_last_run;
110116
const buckets = privUserAgg?.buckets ?? [];
111117

112-
// process current page
113118
if (buckets.length && aggregations) {
114119
const { users: privMonUsers, maxTimestamp } = await parseAggregationResponse(
115120
aggregations,

x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/data_sources/sync/integrations/update_detection/queries.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const buildMatcherScript = (matcher?: Matcher): estypes.Script => {
5151
export const buildPrivilegedSearchBody = (
5252
script: estypes.Script,
5353
timeGte: string,
54+
matchersField: string,
5455
afterKey?: AfterKey,
5556
pageSize: number = 100
5657
): Omit<estypes.SearchRequest, 'index'> => ({
@@ -71,7 +72,7 @@ export const buildPrivilegedSearchBody = (
7172
size: 1,
7273
sort: [{ '@timestamp': { order: 'desc' as estypes.SortOrder } }],
7374
script_fields: { 'user.is_privileged': { script } },
74-
_source: { includes: ['user', 'roles', '@timestamp'] },
75+
_source: { includes: ['user', matchersField, '@timestamp'] },
7576
},
7677
},
7778
},
@@ -97,12 +98,14 @@ export const applyPrivilegedUpdates = async ({
9798
for (let start = 0; start < users.length; start += chunkSize) {
9899
const chunk = users.slice(start, start + chunkSize);
99100
const operations = opsForIntegration(chunk, source);
100-
const resp = await esClient.bulk({
101-
refresh: 'wait_for',
102-
body: operations,
103-
});
104-
const errors = getErrorFromBulkResponse(resp);
105-
dataClient.log('error', errorsMsg(errors));
101+
if (operations.length > 0) {
102+
const resp = await esClient.bulk({
103+
refresh: 'wait_for',
104+
body: operations,
105+
});
106+
const errors = getErrorFromBulkResponse(resp);
107+
dataClient.log('error', errorsMsg(errors));
108+
}
106109
}
107110
} catch (error) {
108111
dataClient.log('error', `Error applying privileged updates: ${error.message}`);

x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/engine/initialisation_sources_service.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ describe('createInitialisationSourcesService', () => {
6666
const existingSources = [
6767
{ id: '1', name: '.entity_analytics.monitoring.users-default' },
6868
{ id: '2', name: '.entity_analytics.monitoring.sources.entityanalytics_okta-default' },
69+
{ id: '2', name: '.entity_analytics.monitoring.sources.entityanalytics_ad-default' },
6970
];
7071
mockFindAll.mockResolvedValue(existingSources);
7172
mockFindByQuery.mockResolvedValue(existingSources);

x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/engine/initialisation_sources_service.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,9 @@ export const createInitialisationSourcesService = (deps: {
8080
};
8181

8282
const getLastFullSyncMarkersIndex = (namespace: string, integration: IntegrationType) => {
83-
// When using AD, will use the users index: TODO in: https://github.com/elastic/security-team/issues/13990
84-
/* if (integration === 'ad') {
83+
if (integration === 'entityanalytics_ad') {
8584
return getStreamPatternFor(integration, namespace);
86-
}*/
85+
}
8786
// okta has a dedicated index for last full sync markers
8887
return oktaLastFullSyncMarkersIndex(namespace);
8988
};
@@ -98,12 +97,12 @@ const makeIntegrationSource = (namespace: string, integration: IntegrationType)
9897
integrations: { syncMarkerIndex: getLastFullSyncMarkersIndex(namespace, integration) },
9998
});
10099

101-
const buildRequiredSources = (namespace: string, indexPattern: string) => {
100+
function buildRequiredSources(namespace: string, indexPattern: string) {
102101
const integrations = INTEGRATION_TYPES.map((integration) =>
103102
makeIntegrationSource(namespace, integration)
104103
);
105104
return [makeDefaultIndexSource(namespace, indexPattern), ...integrations];
106-
};
105+
}
107106

108107
const makeDefaultIndexSource = (namespace: string, name: string) => ({
109108
type: 'index' as const,

x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/monitoring/trial_license_complete_tier/privileged_users/migrations.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ export default ({ getService }: FtrProviderContext) => {
6565
refresh: 'wait_for',
6666
});
6767

68+
const expectAllDefaultSourcesToExist = async (namespace: string) => {
69+
const sources = await entityAnalyticsApi.listEntitySources({ query: {} }, namespace);
70+
const names = (sources.body as ListEntitySourcesResponse).map((s) => s.name);
71+
72+
expect(names.sort()).toEqual(
73+
[
74+
`.entity_analytics.monitoring.users-${namespace}`,
75+
`.entity_analytics.monitoring.sources.entityanalytics_okta-${namespace}`,
76+
`.entity_analytics.monitoring.sources.entityanalytics_ad-${namespace}`,
77+
].sort()
78+
);
79+
};
80+
6881
const SPACES = ['default', 'space1'];
6982

7083
describe('@ess @serverless @skipInServerlessMKI Entity Analytics Privileged user monitoring Migrations', () => {
@@ -169,39 +182,21 @@ export default ({ getService }: FtrProviderContext) => {
169182

170183
await entityAnalyticsRoutes.runMigrations();
171184

172-
const sources = await entityAnalyticsApi.listEntitySources({ query: {} }, namespace);
173-
const names = (sources.body as ListEntitySourcesResponse).map((s) => s.name);
174-
175-
expect(names).toEqual([
176-
`.entity_analytics.monitoring.users-${namespace}`,
177-
`.entity_analytics.monitoring.sources.entityanalytics_okta-${namespace}`,
178-
]);
185+
await expectAllDefaultSourcesToExist(namespace);
179186
});
180187

181188
it(`should create missing entity source when migration runs one entity source doesn't exist`, async () => {
182189
await deleteEntitySources(namespace, [`.entity_analytics.monitoring.users-${namespace}`]);
183190

184191
await entityAnalyticsRoutes.runMigrations();
185192

186-
const sources = await entityAnalyticsApi.listEntitySources({ query: {} }, namespace);
187-
const names = (sources.body as ListEntitySourcesResponse).map((s) => s.name);
188-
189-
expect(names).toEqual([
190-
`.entity_analytics.monitoring.users-${namespace}`,
191-
`.entity_analytics.monitoring.sources.entityanalytics_okta-${namespace}`,
192-
]);
193+
await expectAllDefaultSourcesToExist(namespace);
193194
});
194195

195196
it(`should work as expected when migration runs and all entity sources exist`, async () => {
196197
await entityAnalyticsRoutes.runMigrations();
197198

198-
const sources = await entityAnalyticsApi.listEntitySources({ query: {} }, namespace);
199-
const names = (sources.body as ListEntitySourcesResponse).map((s) => s.name);
200-
201-
expect(names).toEqual([
202-
`.entity_analytics.monitoring.users-${namespace}`,
203-
`.entity_analytics.monitoring.sources.entityanalytics_okta-${namespace}`,
204-
]);
199+
await expectAllDefaultSourcesToExist(namespace);
205200
});
206201
});
207202
});

0 commit comments

Comments
 (0)