@oneuptime/common 12.0.16 → 12.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/DetectionRule.ts +81 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1788600000000-AddDetectionRuleIncidentColumns.ts +39 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Utils/SecurityEvent/DetectionRuleEvaluator.ts +303 -46
- package/Tests/App/Dashboard/SecurityEventsDetectionRulesPage.test.tsx +250 -0
- package/Tests/App/Dashboard/SecurityEventsMonitorStepForm.test.tsx +106 -0
- package/Tests/App/Dashboard/SecurityEventsMonitorsPage.test.tsx +185 -0
- package/Tests/App/Dashboard/SecurityEventsSetupGuide.test.tsx +200 -0
- package/Tests/Models/DetectionRuleCreateContract.test.ts +35 -0
- package/Tests/Server/Utils/SecurityEvent/DetectionRuleEvaluator.test.ts +438 -0
- package/Types/SecurityEvent/DetectionFindingConstants.ts +24 -0
- package/build/dist/Models/DatabaseModels/DetectionRule.js +81 -0
- package/build/dist/Models/DatabaseModels/DetectionRule.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788600000000-AddDetectionRuleIncidentColumns.js +24 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788600000000-AddDetectionRuleIncidentColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js +220 -24
- package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js.map +1 -1
- package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js +18 -0
- package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js.map +1 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Project from "./Project";
|
|
2
2
|
import User from "./User";
|
|
3
3
|
import AlertSeverity from "./AlertSeverity";
|
|
4
|
+
import IncidentSeverity from "./IncidentSeverity";
|
|
4
5
|
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
|
5
6
|
import Route from "../../Types/API/Route";
|
|
6
7
|
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
|
@@ -293,6 +294,41 @@ export default class DetectionRule extends BaseModel {
|
|
|
293
294
|
})
|
|
294
295
|
public shouldWriteDetectionFinding?: boolean = undefined;
|
|
295
296
|
|
|
297
|
+
/*
|
|
298
|
+
* Default FALSE, unlike shouldCreateAlert. An incident is the heavy
|
|
299
|
+
* machinery — workspace channels, SLAs, on-call escalation, status-page
|
|
300
|
+
* visibility — and a rule imported from a community Sigma pack must not
|
|
301
|
+
* open one per match group unless somebody chose that. The evaluator
|
|
302
|
+
* gates on === true for the same reason: an unset column must read as
|
|
303
|
+
* off, not as "probably on".
|
|
304
|
+
*/
|
|
305
|
+
@ColumnAccessControl({
|
|
306
|
+
create: createPermissions,
|
|
307
|
+
read: readPermissions,
|
|
308
|
+
update: updatePermissions,
|
|
309
|
+
})
|
|
310
|
+
@TableColumn({
|
|
311
|
+
required: true,
|
|
312
|
+
type: TableColumnType.Boolean,
|
|
313
|
+
canReadOnRelationQuery: true,
|
|
314
|
+
title: "Create Incidents",
|
|
315
|
+
description:
|
|
316
|
+
"Whether matches also open OneUptime incidents. Off by default: incidents drive on-call, SLAs and status pages, so opt in per rule.",
|
|
317
|
+
defaultValue: false,
|
|
318
|
+
/*
|
|
319
|
+
* Without this, checkRequiredFields 400s any create payload that
|
|
320
|
+
* omits the flag — including every API client written before the
|
|
321
|
+
* column existed — and the DB DEFAULT false can never apply.
|
|
322
|
+
*/
|
|
323
|
+
isDefaultValueColumn: true,
|
|
324
|
+
})
|
|
325
|
+
@Column({
|
|
326
|
+
nullable: false,
|
|
327
|
+
type: ColumnType.Boolean,
|
|
328
|
+
default: false,
|
|
329
|
+
})
|
|
330
|
+
public shouldCreateIncident?: boolean = undefined;
|
|
331
|
+
|
|
296
332
|
@ColumnAccessControl({
|
|
297
333
|
create: createPermissions,
|
|
298
334
|
read: readPermissions,
|
|
@@ -337,6 +373,51 @@ export default class DetectionRule extends BaseModel {
|
|
|
337
373
|
})
|
|
338
374
|
public alertSeverityId?: ObjectID = undefined;
|
|
339
375
|
|
|
376
|
+
@ColumnAccessControl({
|
|
377
|
+
create: createPermissions,
|
|
378
|
+
read: readPermissions,
|
|
379
|
+
update: updatePermissions,
|
|
380
|
+
})
|
|
381
|
+
@TableColumn({
|
|
382
|
+
manyToOneRelationColumn: "incidentSeverityId",
|
|
383
|
+
type: TableColumnType.Entity,
|
|
384
|
+
modelType: IncidentSeverity,
|
|
385
|
+
title: "Incident Severity",
|
|
386
|
+
description:
|
|
387
|
+
"Severity of incidents opened by this rule. Defaults from the Sigma rule's level, mapped onto this project's incident severities, when unset.",
|
|
388
|
+
})
|
|
389
|
+
@ManyToOne(
|
|
390
|
+
() => {
|
|
391
|
+
return IncidentSeverity;
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
eager: false,
|
|
395
|
+
nullable: true,
|
|
396
|
+
onDelete: "SET NULL",
|
|
397
|
+
orphanedRowAction: "nullify",
|
|
398
|
+
},
|
|
399
|
+
)
|
|
400
|
+
@JoinColumn({ name: "incidentSeverityId" })
|
|
401
|
+
public incidentSeverity?: IncidentSeverity = undefined;
|
|
402
|
+
|
|
403
|
+
@ColumnAccessControl({
|
|
404
|
+
create: createPermissions,
|
|
405
|
+
read: readPermissions,
|
|
406
|
+
update: updatePermissions,
|
|
407
|
+
})
|
|
408
|
+
@TableColumn({
|
|
409
|
+
type: TableColumnType.ObjectID,
|
|
410
|
+
title: "Incident Severity ID",
|
|
411
|
+
description:
|
|
412
|
+
"ID of the incident severity for incidents opened by this rule.",
|
|
413
|
+
})
|
|
414
|
+
@Column({
|
|
415
|
+
type: ColumnType.ObjectID,
|
|
416
|
+
nullable: true,
|
|
417
|
+
transformer: ObjectID.getDatabaseTransformer(),
|
|
418
|
+
})
|
|
419
|
+
public incidentSeverityId?: ObjectID = undefined;
|
|
420
|
+
|
|
340
421
|
/*
|
|
341
422
|
* Evaluator-owned state. Written only by the detection engine cron,
|
|
342
423
|
* never by users.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { MigrationInterface, QueryRunner } from "typeorm";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Detection rules gain an incident option: shouldCreateIncident (default
|
|
5
|
+
* FALSE, unlike shouldCreateAlert's default true — incidents drive on-call,
|
|
6
|
+
* SLAs and status pages, so a rule must opt in) and an optional per-rule
|
|
7
|
+
* IncidentSeverity override, mirroring the alertSeverityId column. SET NULL
|
|
8
|
+
* on severity delete matches the alert pair: losing a severity should
|
|
9
|
+
* degrade the rule to level-based mapping, not delete it.
|
|
10
|
+
*/
|
|
11
|
+
export class AddDetectionRuleIncidentColumns1788600000000
|
|
12
|
+
implements MigrationInterface
|
|
13
|
+
{
|
|
14
|
+
public name: string = "AddDetectionRuleIncidentColumns1788600000000";
|
|
15
|
+
|
|
16
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
17
|
+
await queryRunner.query(
|
|
18
|
+
`ALTER TABLE "DetectionRule" ADD "shouldCreateIncident" boolean NOT NULL DEFAULT false`,
|
|
19
|
+
);
|
|
20
|
+
await queryRunner.query(
|
|
21
|
+
`ALTER TABLE "DetectionRule" ADD "incidentSeverityId" uuid`,
|
|
22
|
+
);
|
|
23
|
+
await queryRunner.query(
|
|
24
|
+
`ALTER TABLE "DetectionRule" ADD CONSTRAINT "FK_b1cda3642956897cd47dec5fcf8" FOREIGN KEY ("incidentSeverityId") REFERENCES "IncidentSeverity"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
29
|
+
await queryRunner.query(
|
|
30
|
+
`ALTER TABLE "DetectionRule" DROP CONSTRAINT "FK_b1cda3642956897cd47dec5fcf8"`,
|
|
31
|
+
);
|
|
32
|
+
await queryRunner.query(
|
|
33
|
+
`ALTER TABLE "DetectionRule" DROP COLUMN "incidentSeverityId"`,
|
|
34
|
+
);
|
|
35
|
+
await queryRunner.query(
|
|
36
|
+
`ALTER TABLE "DetectionRule" DROP COLUMN "shouldCreateIncident"`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -539,6 +539,7 @@ import { AddLlmCostBudget1788200000000 } from "./1788200000000-AddLlmCostBudget"
|
|
|
539
539
|
import { AddLlmModelPrice1788300000000 } from "./1788300000000-AddLlmModelPrice";
|
|
540
540
|
import { AddMarketingConversionAttribution1788400000000 } from "./1788400000000-AddMarketingConversionAttribution";
|
|
541
541
|
import { RemoveLlmCostBudgetAlertColumns1788500000000 } from "./1788500000000-RemoveLlmCostBudgetAlertColumns";
|
|
542
|
+
import { AddDetectionRuleIncidentColumns1788600000000 } from "./1788600000000-AddDetectionRuleIncidentColumns";
|
|
542
543
|
import { MigrationName1787142779538 } from "./1787142779538-MigrationName";
|
|
543
544
|
import { MigrationName1787156982416 } from "./1787156982416-MigrationName";
|
|
544
545
|
|
|
@@ -1086,4 +1087,5 @@ export default [
|
|
|
1086
1087
|
AddLlmModelPrice1788300000000,
|
|
1087
1088
|
AddMarketingConversionAttribution1788400000000,
|
|
1088
1089
|
RemoveLlmCostBudgetAlertColumns1788500000000,
|
|
1090
|
+
AddDetectionRuleIncidentColumns1788600000000,
|
|
1089
1091
|
];
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import DetectionRule from "../../../Models/DatabaseModels/DetectionRule";
|
|
2
2
|
import Alert from "../../../Models/DatabaseModels/Alert";
|
|
3
3
|
import AlertSeverity from "../../../Models/DatabaseModels/AlertSeverity";
|
|
4
|
+
import Incident from "../../../Models/DatabaseModels/Incident";
|
|
5
|
+
import IncidentSeverity from "../../../Models/DatabaseModels/IncidentSeverity";
|
|
4
6
|
import LIMIT_MAX, { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
|
|
5
7
|
import OneUptimeDate from "../../../Types/Date";
|
|
6
8
|
import ObjectID from "../../../Types/ObjectID";
|
|
7
9
|
import SortOrder from "../../../Types/BaseDatabase/SortOrder";
|
|
10
|
+
import Includes from "../../../Types/BaseDatabase/Includes";
|
|
8
11
|
import { JSONObject } from "../../../Types/JSON";
|
|
9
12
|
import NormalizedSecurityEvent from "../../../Types/SecurityEvent/NormalizedSecurityEvent";
|
|
10
13
|
import OcsfSeverity, {
|
|
@@ -12,10 +15,21 @@ import OcsfSeverity, {
|
|
|
12
15
|
} from "../../../Types/SecurityEvent/OcsfSeverity";
|
|
13
16
|
import { ocsfCategoryForClassUid } from "../../../Types/SecurityEvent/OcsfEventClass";
|
|
14
17
|
import SigmaRule, { SigmaLevel } from "../../../Types/SecurityEvent/SigmaRule";
|
|
18
|
+
import {
|
|
19
|
+
DETECTION_FINDING_CLASS_NAME,
|
|
20
|
+
DETECTION_FINDING_CLASS_UID,
|
|
21
|
+
DETECTION_GROUP_VALUE_ATTRIBUTE,
|
|
22
|
+
DETECTION_MATCH_COUNT_ATTRIBUTE,
|
|
23
|
+
DETECTION_RULE_ID_ATTRIBUTE,
|
|
24
|
+
DETECTION_RULE_NAME_ATTRIBUTE,
|
|
25
|
+
DETECTION_SIGMA_ID_ATTRIBUTE,
|
|
26
|
+
} from "../../../Types/SecurityEvent/DetectionFindingConstants";
|
|
15
27
|
import SigmaRuleParser from "../../../Utils/SecurityEvent/Sigma/SigmaRuleParser";
|
|
16
28
|
import MetricSeriesFingerprint from "../../../Utils/Metrics/MetricSeriesFingerprint";
|
|
17
29
|
import AlertService from "../../Services/AlertService";
|
|
18
30
|
import AlertSeverityService from "../../Services/AlertSeverityService";
|
|
31
|
+
import IncidentService from "../../Services/IncidentService";
|
|
32
|
+
import IncidentSeverityService from "../../Services/IncidentSeverityService";
|
|
19
33
|
import DetectionRuleService from "../../Services/DetectionRuleService";
|
|
20
34
|
import OTelIngestService, {
|
|
21
35
|
TelemetryServiceMetadata,
|
|
@@ -32,7 +46,6 @@ import SigmaClickhouseCompiler, {
|
|
|
32
46
|
} from "./Sigma/SigmaClickhouseCompiler";
|
|
33
47
|
import { buildSecurityEventDbRow } from "./SecurityEventRow";
|
|
34
48
|
|
|
35
|
-
const DETECTION_FINDING_CLASS_UID: number = 2004;
|
|
36
49
|
const DETECTIONS_SERVICE_NAME: string = "OneUptime Detections";
|
|
37
50
|
|
|
38
51
|
/*
|
|
@@ -58,6 +71,7 @@ export interface DetectionRuleEvaluationResult {
|
|
|
58
71
|
matchedGroups: number;
|
|
59
72
|
totalMatches: number;
|
|
60
73
|
alertsCreated: number;
|
|
74
|
+
incidentsCreated: number;
|
|
61
75
|
findingsWritten: number;
|
|
62
76
|
error: string | null;
|
|
63
77
|
}
|
|
@@ -84,7 +98,9 @@ export default class DetectionRuleEvaluator {
|
|
|
84
98
|
groupByField: true,
|
|
85
99
|
shouldCreateAlert: true,
|
|
86
100
|
shouldWriteDetectionFinding: true,
|
|
101
|
+
shouldCreateIncident: true,
|
|
87
102
|
alertSeverityId: true,
|
|
103
|
+
incidentSeverityId: true,
|
|
88
104
|
lastEvaluatedAt: true,
|
|
89
105
|
},
|
|
90
106
|
skip: 0,
|
|
@@ -189,6 +205,7 @@ export default class DetectionRuleEvaluator {
|
|
|
189
205
|
);
|
|
190
206
|
|
|
191
207
|
let alertsCreated: number = 0;
|
|
208
|
+
let incidentsCreated: number = 0;
|
|
192
209
|
let findingsWritten: number = 0;
|
|
193
210
|
let totalMatches: number = 0;
|
|
194
211
|
|
|
@@ -207,6 +224,22 @@ export default class DetectionRuleEvaluator {
|
|
|
207
224
|
});
|
|
208
225
|
}
|
|
209
226
|
|
|
227
|
+
/*
|
|
228
|
+
* === true, not !== false like the alert gate: incidents are the
|
|
229
|
+
* heavy machinery (on-call, SLAs, status pages), and the column
|
|
230
|
+
* defaults to false — a rule fetched without the column selected
|
|
231
|
+
* must read as off, never as "probably on".
|
|
232
|
+
*/
|
|
233
|
+
if (rule.shouldCreateIncident === true) {
|
|
234
|
+
incidentsCreated = await this.openIncidentsForMatches({
|
|
235
|
+
rule,
|
|
236
|
+
parsedRule,
|
|
237
|
+
matchedGroups,
|
|
238
|
+
startTime,
|
|
239
|
+
endTime,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
210
243
|
if (rule.shouldWriteDetectionFinding !== false) {
|
|
211
244
|
findingsWritten = await this.writeDetectionFindings({
|
|
212
245
|
rule,
|
|
@@ -233,6 +266,7 @@ export default class DetectionRuleEvaluator {
|
|
|
233
266
|
matchedGroups: matchedGroups.length,
|
|
234
267
|
totalMatches,
|
|
235
268
|
alertsCreated,
|
|
269
|
+
incidentsCreated,
|
|
236
270
|
findingsWritten,
|
|
237
271
|
error: null,
|
|
238
272
|
};
|
|
@@ -272,9 +306,16 @@ export default class DetectionRuleEvaluator {
|
|
|
272
306
|
},
|
|
273
307
|
);
|
|
274
308
|
|
|
309
|
+
/*
|
|
310
|
+
* Scoped to THIS rule's candidate fingerprints (≤ MAX_GROUPS_PER_
|
|
311
|
+
* EVALUATION), not a scan of every open alert: a project with more
|
|
312
|
+
* open alerts than LIMIT_PER_PROJECT would otherwise age still-open
|
|
313
|
+
* detections out of the fetched window and re-open them every cycle.
|
|
314
|
+
*/
|
|
275
315
|
const openAlerts: Array<Alert> = await AlertService.findBy({
|
|
276
316
|
query: {
|
|
277
317
|
projectId,
|
|
318
|
+
seriesFingerprint: new Includes(fingerprints),
|
|
278
319
|
currentAlertState: {
|
|
279
320
|
isResolvedState: false,
|
|
280
321
|
},
|
|
@@ -312,43 +353,152 @@ export default class DetectionRuleEvaluator {
|
|
|
312
353
|
|
|
313
354
|
const alert: Alert = new Alert();
|
|
314
355
|
alert.projectId = projectId;
|
|
315
|
-
alert.title = matchGroup
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
356
|
+
alert.title = this.buildMatchTitle(rule, matchGroup);
|
|
357
|
+
alert.description = this.buildMatchDescription({
|
|
358
|
+
rule,
|
|
359
|
+
parsedRule,
|
|
360
|
+
matchGroup,
|
|
361
|
+
startTime: data.startTime,
|
|
362
|
+
endTime: data.endTime,
|
|
363
|
+
});
|
|
364
|
+
alert.alertSeverityId = alertSeverityId;
|
|
365
|
+
alert.seriesFingerprint = fingerprint;
|
|
366
|
+
alert.isCreatedAutomatically = true;
|
|
367
|
+
alert.rootCause = `Sigma detection rule "${rule.name}" matched security events.`;
|
|
320
368
|
|
|
321
|
-
|
|
322
|
-
|
|
369
|
+
try {
|
|
370
|
+
await AlertService.create({
|
|
371
|
+
data: alert,
|
|
372
|
+
props: {
|
|
373
|
+
isRoot: true,
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
created++;
|
|
377
|
+
} catch (error) {
|
|
378
|
+
logger.error(
|
|
379
|
+
`DetectionRuleEvaluator: failed creating alert for rule ${rule.id?.toString()}:`,
|
|
380
|
+
);
|
|
381
|
+
logger.error(error);
|
|
323
382
|
}
|
|
383
|
+
}
|
|
324
384
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
385
|
+
return created;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/*
|
|
389
|
+
* The incident twin of openAlertsForMatches: same fingerprint, same
|
|
390
|
+
* title and description, deduped against the project's UNRESOLVED
|
|
391
|
+
* incidents the same way alerts dedupe against open alerts. Kept as a
|
|
392
|
+
* separate method rather than a parameterized one because the two
|
|
393
|
+
* models genuinely differ where it matters — severity comes from
|
|
394
|
+
* IncidentSeverity, and IncidentService.create is far heavier
|
|
395
|
+
* (workspace channels, SLAs, on-call execution), so each create is
|
|
396
|
+
* wrapped so one failing project cannot sink the whole rule.
|
|
397
|
+
*
|
|
398
|
+
* Detection incidents carry no monitors, so monitor-driven auto-resolve
|
|
399
|
+
* never touches them: the fingerprint dedupe is the only thing keeping
|
|
400
|
+
* a still-firing rule from stacking incidents. Dedupe must therefore
|
|
401
|
+
* run BEFORE create — incident numbers are user-visible and consumed
|
|
402
|
+
* per create.
|
|
403
|
+
*/
|
|
404
|
+
private static async openIncidentsForMatches(data: {
|
|
405
|
+
rule: DetectionRule;
|
|
406
|
+
parsedRule: SigmaRule;
|
|
407
|
+
matchedGroups: Array<DetectionMatchGroup>;
|
|
408
|
+
startTime: Date;
|
|
409
|
+
endTime: Date;
|
|
410
|
+
}): Promise<number> {
|
|
411
|
+
const { rule, parsedRule, matchedGroups } = data;
|
|
412
|
+
const projectId: ObjectID = rule.projectId!;
|
|
413
|
+
|
|
414
|
+
const incidentSeverityId: ObjectID | null =
|
|
415
|
+
await this.resolveIncidentSeverityId({
|
|
416
|
+
projectId,
|
|
417
|
+
rule,
|
|
418
|
+
parsedRule,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
if (!incidentSeverityId) {
|
|
422
|
+
logger.warn(
|
|
423
|
+
`DetectionRuleEvaluator: project ${projectId.toString()} has no incident severities; skipping incident creation for rule ${rule.id?.toString()}.`,
|
|
331
424
|
);
|
|
425
|
+
return 0;
|
|
426
|
+
}
|
|
332
427
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
428
|
+
const fingerprints: Array<string> = matchedGroups.map(
|
|
429
|
+
(matchGroup: DetectionMatchGroup): string => {
|
|
430
|
+
return this.buildFingerprint(rule.id!, matchGroup.groupValue);
|
|
431
|
+
},
|
|
432
|
+
);
|
|
336
433
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
434
|
+
/*
|
|
435
|
+
* Same fingerprint-scoped dedupe as the alert path — and it matters
|
|
436
|
+
* more here: a missed fingerprint re-fires IncidentService.create's
|
|
437
|
+
* whole side-effect train and burns a user-visible incident number.
|
|
438
|
+
*/
|
|
439
|
+
const openIncidents: Array<Incident> = await IncidentService.findBy({
|
|
440
|
+
query: {
|
|
441
|
+
projectId,
|
|
442
|
+
seriesFingerprint: new Includes(fingerprints),
|
|
443
|
+
currentIncidentState: {
|
|
444
|
+
isResolvedState: false,
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
select: {
|
|
448
|
+
_id: true,
|
|
449
|
+
seriesFingerprint: true,
|
|
450
|
+
},
|
|
451
|
+
skip: 0,
|
|
452
|
+
limit: LIMIT_PER_PROJECT,
|
|
453
|
+
props: {
|
|
454
|
+
isRoot: true,
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const openFingerprints: Set<string> = new Set<string>(
|
|
459
|
+
openIncidents
|
|
460
|
+
.map((incident: Incident): string => {
|
|
461
|
+
return incident.seriesFingerprint || "";
|
|
462
|
+
})
|
|
463
|
+
.filter((fingerprint: string): boolean => {
|
|
464
|
+
return Boolean(fingerprint);
|
|
465
|
+
}),
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
let created: number = 0;
|
|
469
|
+
|
|
470
|
+
for (let index: number = 0; index < matchedGroups.length; index++) {
|
|
471
|
+
const matchGroup: DetectionMatchGroup = matchedGroups[index]!;
|
|
472
|
+
const fingerprint: string = fingerprints[index]!;
|
|
473
|
+
|
|
474
|
+
if (openFingerprints.has(fingerprint)) {
|
|
475
|
+
continue;
|
|
341
476
|
}
|
|
342
477
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
478
|
+
const incident: Incident = new Incident();
|
|
479
|
+
incident.projectId = projectId;
|
|
480
|
+
incident.title = this.buildMatchTitle(rule, matchGroup);
|
|
481
|
+
incident.description = this.buildMatchDescription({
|
|
482
|
+
rule,
|
|
483
|
+
parsedRule,
|
|
484
|
+
matchGroup,
|
|
485
|
+
startTime: data.startTime,
|
|
486
|
+
endTime: data.endTime,
|
|
487
|
+
});
|
|
488
|
+
incident.incidentSeverityId = incidentSeverityId;
|
|
489
|
+
incident.seriesFingerprint = fingerprint;
|
|
490
|
+
incident.isCreatedAutomatically = true;
|
|
491
|
+
incident.rootCause = `Sigma detection rule "${rule.name}" matched security events.`;
|
|
348
492
|
|
|
349
493
|
try {
|
|
350
|
-
|
|
351
|
-
|
|
494
|
+
/*
|
|
495
|
+
* Per-incident try/catch, like the alert path — but here it also
|
|
496
|
+
* guards IncidentService.onBeforeCreate, which throws when the
|
|
497
|
+
* project has no "created" incident state. That cannot be
|
|
498
|
+
* pre-checked the way an empty severity list can.
|
|
499
|
+
*/
|
|
500
|
+
await IncidentService.create({
|
|
501
|
+
data: incident,
|
|
352
502
|
props: {
|
|
353
503
|
isRoot: true,
|
|
354
504
|
},
|
|
@@ -356,7 +506,7 @@ export default class DetectionRuleEvaluator {
|
|
|
356
506
|
created++;
|
|
357
507
|
} catch (error) {
|
|
358
508
|
logger.error(
|
|
359
|
-
`DetectionRuleEvaluator: failed creating
|
|
509
|
+
`DetectionRuleEvaluator: failed creating incident for rule ${rule.id?.toString()}:`,
|
|
360
510
|
);
|
|
361
511
|
logger.error(error);
|
|
362
512
|
}
|
|
@@ -365,6 +515,50 @@ export default class DetectionRuleEvaluator {
|
|
|
365
515
|
return created;
|
|
366
516
|
}
|
|
367
517
|
|
|
518
|
+
private static buildMatchTitle(
|
|
519
|
+
rule: DetectionRule,
|
|
520
|
+
matchGroup: DetectionMatchGroup,
|
|
521
|
+
): string {
|
|
522
|
+
return matchGroup.groupValue
|
|
523
|
+
? `[Detection] ${rule.name} — ${matchGroup.groupValue}`
|
|
524
|
+
: `[Detection] ${rule.name}`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private static buildMatchDescription(data: {
|
|
528
|
+
rule: DetectionRule;
|
|
529
|
+
parsedRule: SigmaRule;
|
|
530
|
+
matchGroup: DetectionMatchGroup;
|
|
531
|
+
startTime: Date;
|
|
532
|
+
endTime: Date;
|
|
533
|
+
}): string {
|
|
534
|
+
const { rule, parsedRule, matchGroup } = data;
|
|
535
|
+
const descriptionParts: Array<string> = [];
|
|
536
|
+
|
|
537
|
+
if (rule.description || parsedRule.description) {
|
|
538
|
+
descriptionParts.push(rule.description || parsedRule.description);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
descriptionParts.push(
|
|
542
|
+
`Detection rule matched ${matchGroup.matchCount} security event${
|
|
543
|
+
matchGroup.matchCount === 1 ? "" : "s"
|
|
544
|
+
} between ${OneUptimeDate.getDateAsFormattedString(
|
|
545
|
+
data.startTime,
|
|
546
|
+
)} and ${OneUptimeDate.getDateAsFormattedString(data.endTime)}.`,
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
if (matchGroup.sampleMessage) {
|
|
550
|
+
descriptionParts.push(`Sample event: ${matchGroup.sampleMessage}`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (matchGroup.sampleObservables.length > 0) {
|
|
554
|
+
descriptionParts.push(
|
|
555
|
+
`Observables: ${matchGroup.sampleObservables.slice(0, 20).join(", ")}`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return descriptionParts.join("\n\n");
|
|
560
|
+
}
|
|
561
|
+
|
|
368
562
|
private static async writeDetectionFindings(data: {
|
|
369
563
|
rule: DetectionRule;
|
|
370
564
|
parsedRule: SigmaRule;
|
|
@@ -405,7 +599,7 @@ export default class DetectionRuleEvaluator {
|
|
|
405
599
|
categoryUid,
|
|
406
600
|
categoryName,
|
|
407
601
|
classUid: DETECTION_FINDING_CLASS_UID,
|
|
408
|
-
className:
|
|
602
|
+
className: DETECTION_FINDING_CLASS_NAME,
|
|
409
603
|
activityName: "Create",
|
|
410
604
|
severityId: OcsfSeverityId[severityName],
|
|
411
605
|
severityName,
|
|
@@ -439,14 +633,14 @@ export default class DetectionRuleEvaluator {
|
|
|
439
633
|
]
|
|
440
634
|
: matchGroup.sampleObservables,
|
|
441
635
|
attributes: {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
636
|
+
[DETECTION_RULE_ID_ATTRIBUTE]: rule.id!.toString(),
|
|
637
|
+
[DETECTION_RULE_NAME_ATTRIBUTE]: rule.name || parsedRule.title,
|
|
638
|
+
[DETECTION_MATCH_COUNT_ATTRIBUTE]: String(matchGroup.matchCount),
|
|
445
639
|
...(matchGroup.groupValue
|
|
446
|
-
? {
|
|
640
|
+
? { [DETECTION_GROUP_VALUE_ATTRIBUTE]: matchGroup.groupValue }
|
|
447
641
|
: {}),
|
|
448
642
|
...(parsedRule.id
|
|
449
|
-
? {
|
|
643
|
+
? { [DETECTION_SIGMA_ID_ATTRIBUTE]: parsedRule.id }
|
|
450
644
|
: {}),
|
|
451
645
|
},
|
|
452
646
|
};
|
|
@@ -506,16 +700,81 @@ export default class DetectionRuleEvaluator {
|
|
|
506
700
|
},
|
|
507
701
|
});
|
|
508
702
|
|
|
703
|
+
return this.pickSeverityByPrecedence({
|
|
704
|
+
severities,
|
|
705
|
+
explicitSeverityId: data.rule.alertSeverityId,
|
|
706
|
+
level: data.parsedRule.level,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/*
|
|
711
|
+
* Incident severity precedence, identical in shape to the alert
|
|
712
|
+
* resolver: the rule's explicit incident severity (validated to belong
|
|
713
|
+
* to this project — IncidentService.onBeforeCreate rejects cross-project
|
|
714
|
+
* ids with a thrown exception, so pre-validating here keeps a stale id
|
|
715
|
+
* a soft fallback instead of a hard failure), else a name match on the
|
|
716
|
+
* Sigma level, else severity by rank. Null only when the project has no
|
|
717
|
+
* incident severities at all.
|
|
718
|
+
*/
|
|
719
|
+
private static async resolveIncidentSeverityId(data: {
|
|
720
|
+
projectId: ObjectID;
|
|
721
|
+
rule: DetectionRule;
|
|
722
|
+
parsedRule: SigmaRule;
|
|
723
|
+
}): Promise<ObjectID | null> {
|
|
724
|
+
const severities: Array<IncidentSeverity> =
|
|
725
|
+
await IncidentSeverityService.findBy({
|
|
726
|
+
query: {
|
|
727
|
+
projectId: data.projectId,
|
|
728
|
+
},
|
|
729
|
+
select: {
|
|
730
|
+
_id: true,
|
|
731
|
+
name: true,
|
|
732
|
+
},
|
|
733
|
+
sort: {
|
|
734
|
+
order: SortOrder.Ascending,
|
|
735
|
+
},
|
|
736
|
+
skip: 0,
|
|
737
|
+
limit: LIMIT_PER_PROJECT,
|
|
738
|
+
props: {
|
|
739
|
+
isRoot: true,
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
return this.pickSeverityByPrecedence({
|
|
744
|
+
severities,
|
|
745
|
+
explicitSeverityId: data.rule.incidentSeverityId,
|
|
746
|
+
level: data.parsedRule.level,
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/*
|
|
751
|
+
* The precedence logic both resolvers share. Severities arrive sorted
|
|
752
|
+
* by order ascending — lowest order is most severe, per the model's
|
|
753
|
+
* own convention — so "most severe" is the first element and "least
|
|
754
|
+
* severe" the last. An explicit id that is not in the list (deleted, or
|
|
755
|
+
* belonging to another project) falls through silently rather than
|
|
756
|
+
* failing the rule.
|
|
757
|
+
*/
|
|
758
|
+
private static pickSeverityByPrecedence<
|
|
759
|
+
TSeverity extends {
|
|
760
|
+
id?: ObjectID | null | undefined;
|
|
761
|
+
name?: string | undefined;
|
|
762
|
+
},
|
|
763
|
+
>(data: {
|
|
764
|
+
severities: Array<TSeverity>;
|
|
765
|
+
explicitSeverityId: ObjectID | undefined;
|
|
766
|
+
level: SigmaLevel;
|
|
767
|
+
}): ObjectID | null {
|
|
768
|
+
const { severities, explicitSeverityId, level } = data;
|
|
769
|
+
|
|
509
770
|
if (severities.length === 0) {
|
|
510
771
|
return null;
|
|
511
772
|
}
|
|
512
773
|
|
|
513
|
-
if (
|
|
514
|
-
const explicit:
|
|
515
|
-
(severity:
|
|
516
|
-
return (
|
|
517
|
-
severity.id?.toString() === data.rule.alertSeverityId?.toString()
|
|
518
|
-
);
|
|
774
|
+
if (explicitSeverityId) {
|
|
775
|
+
const explicit: TSeverity | undefined = severities.find(
|
|
776
|
+
(severity: TSeverity): boolean => {
|
|
777
|
+
return severity.id?.toString() === explicitSeverityId.toString();
|
|
519
778
|
},
|
|
520
779
|
);
|
|
521
780
|
|
|
@@ -524,10 +783,8 @@ export default class DetectionRuleEvaluator {
|
|
|
524
783
|
}
|
|
525
784
|
}
|
|
526
785
|
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
const nameMatch: AlertSeverity | undefined = severities.find(
|
|
530
|
-
(severity: AlertSeverity): boolean => {
|
|
786
|
+
const nameMatch: TSeverity | undefined = severities.find(
|
|
787
|
+
(severity: TSeverity): boolean => {
|
|
531
788
|
return (severity.name || "").toLowerCase() === level.toLowerCase();
|
|
532
789
|
},
|
|
533
790
|
);
|
|
@@ -539,7 +796,7 @@ export default class DetectionRuleEvaluator {
|
|
|
539
796
|
const isSevere: boolean =
|
|
540
797
|
level === SigmaLevel.Critical || level === SigmaLevel.High;
|
|
541
798
|
|
|
542
|
-
const chosen:
|
|
799
|
+
const chosen: TSeverity = isSevere
|
|
543
800
|
? severities[0]!
|
|
544
801
|
: severities[severities.length - 1]!;
|
|
545
802
|
|