@oneuptime/common 12.0.16 → 12.0.18

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.
Files changed (36) hide show
  1. package/Models/DatabaseModels/DetectionRule.ts +81 -0
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1788600000000-AddDetectionRuleIncidentColumns.ts +39 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/BillingService.ts +8 -0
  5. package/Server/Utils/SecurityEvent/DetectionRuleEvaluator.ts +303 -46
  6. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +17 -0
  7. package/Tests/App/Dashboard/SecurityEventsDetectionRulesPage.test.tsx +250 -0
  8. package/Tests/App/Dashboard/SecurityEventsMonitorStepForm.test.tsx +106 -0
  9. package/Tests/App/Dashboard/SecurityEventsMonitorsPage.test.tsx +185 -0
  10. package/Tests/App/Dashboard/SecurityEventsSetupGuide.test.tsx +200 -0
  11. package/Tests/Models/DetectionRuleCreateContract.test.ts +35 -0
  12. package/Tests/Server/Services/BillingService.test.ts +20 -0
  13. package/Tests/Server/Utils/SecurityEvent/DetectionRuleEvaluator.test.ts +438 -0
  14. package/Tests/Types/Kubernetes/KubernetesObjectParsers.test.ts +794 -0
  15. package/Tests/Types/Kubernetes/KubernetesRightSizing.test.ts +53 -0
  16. package/Tests/Types/Measurement/MeasurementAggregationType.test.ts +100 -0
  17. package/Tests/Types/Monitor/MonitorStepConfigHelpers.test.ts +114 -0
  18. package/Tests/Types/Workspace/WorkspaceType.test.ts +53 -0
  19. package/Tests/Utils/Dashboard/Components/DashboardComponentDefaults.test.ts +257 -0
  20. package/Types/Billing/PayAsYouGoPricing.ts +1 -1
  21. package/Types/SecurityEvent/DetectionFindingConstants.ts +24 -0
  22. package/build/dist/Models/DatabaseModels/DetectionRule.js +81 -0
  23. package/build/dist/Models/DatabaseModels/DetectionRule.js.map +1 -1
  24. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788600000000-AddDetectionRuleIncidentColumns.js +24 -0
  25. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788600000000-AddDetectionRuleIncidentColumns.js.map +1 -0
  26. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  27. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  28. package/build/dist/Server/Services/BillingService.js +6 -0
  29. package/build/dist/Server/Services/BillingService.js.map +1 -1
  30. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js +220 -24
  31. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js.map +1 -1
  32. package/build/dist/Types/Billing/PayAsYouGoPricing.js +1 -1
  33. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -1
  34. package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js +18 -0
  35. package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js.map +1 -0
  36. 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
  ];
@@ -2074,6 +2074,14 @@ export class BillingService extends BaseService {
2074
2074
  return "price_1U0iWJANuQdJ93r7iVAXwhdP";
2075
2075
  }
2076
2076
 
2077
+ if (productType === ProductType.SecurityEvents) {
2078
+ if (this.isTestEnvironment()) {
2079
+ return "price_1U7efaANuQdJ93r74hFVOgdS";
2080
+ }
2081
+
2082
+ return "price_1U7edTANuQdJ93r7hR9jpBfv";
2083
+ }
2084
+
2077
2085
  throw new BadDataException(
2078
2086
  "Plan with productType " + productType + " not found",
2079
2087
  );
@@ -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.groupValue
316
- ? `[Detection] ${rule.name} — ${matchGroup.groupValue}`
317
- : `[Detection] ${rule.name}`;
318
-
319
- const descriptionParts: Array<string> = [];
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
- if (rule.description || parsedRule.description) {
322
- descriptionParts.push(rule.description || parsedRule.description);
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
- descriptionParts.push(
326
- `Detection rule matched ${matchGroup.matchCount} security event${
327
- matchGroup.matchCount === 1 ? "" : "s"
328
- } between ${OneUptimeDate.getDateAsFormattedString(
329
- data.startTime,
330
- )} and ${OneUptimeDate.getDateAsFormattedString(data.endTime)}.`,
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
- if (matchGroup.sampleMessage) {
334
- descriptionParts.push(`Sample event: ${matchGroup.sampleMessage}`);
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
- if (matchGroup.sampleObservables.length > 0) {
338
- descriptionParts.push(
339
- `Observables: ${matchGroup.sampleObservables.slice(0, 20).join(", ")}`,
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
- alert.description = descriptionParts.join("\n\n");
344
- alert.alertSeverityId = alertSeverityId;
345
- alert.seriesFingerprint = fingerprint;
346
- alert.isCreatedAutomatically = true;
347
- alert.rootCause = `Sigma detection rule "${rule.name}" matched security events.`;
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
- await AlertService.create({
351
- data: alert,
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 alert for rule ${rule.id?.toString()}:`,
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: "Detection Finding",
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
- "oneuptime.detection.rule_id": rule.id!.toString(),
443
- "oneuptime.detection.rule_name": rule.name || parsedRule.title,
444
- "oneuptime.detection.match_count": String(matchGroup.matchCount),
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
- ? { "oneuptime.detection.group_value": matchGroup.groupValue }
640
+ ? { [DETECTION_GROUP_VALUE_ATTRIBUTE]: matchGroup.groupValue }
447
641
  : {}),
448
642
  ...(parsedRule.id
449
- ? { "oneuptime.detection.sigma_id": parsedRule.id }
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 (data.rule.alertSeverityId) {
514
- const explicit: AlertSeverity | undefined = severities.find(
515
- (severity: AlertSeverity): boolean => {
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 level: SigmaLevel = data.parsedRule.level;
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: AlertSeverity = isSevere
799
+ const chosen: TSeverity = isSevere
543
800
  ? severities[0]!
544
801
  : severities[severities.length - 1]!;
545
802
 
@@ -120,6 +120,23 @@ describe("Pay as you go notices", () => {
120
120
  expect(card).toHaveTextContent("Starting at");
121
121
  });
122
122
 
123
+ it("names security events among the pillars billed at the telemetry rate", () => {
124
+ /*
125
+ * Security events ingest on the same key and meter at the same
126
+ * $0.10/GB as the other telemetry pillars. This sentence is the body of
127
+ * the consent checkbox a user has to tick to create a key, so a pillar
128
+ * missing from it is a pillar the user is charged for without ever
129
+ * having been told.
130
+ */
131
+ render(<TelemetryPayAsYouGoCard />);
132
+
133
+ expect(
134
+ screen.getByTestId("telemetry-pay-as-you-go-card"),
135
+ ).toHaveTextContent(
136
+ "Logs, traces, metrics, profiles and security events are billed at",
137
+ );
138
+ });
139
+
123
140
  it("says the charge stops when the key is deleted, so the notice is not just a threat", () => {
124
141
  render(<TelemetryPayAsYouGoCard />);
125
142