@cryptotaxi247 / CoPilot / commits / 4c83fc27

Custom alerting (#166)

* Add custom alert provisioning functionality * Refactor custom field name validation and provision custom monitoring alerts * Update alert titles in provision.py * Add custom monitoring alert creation endpoint*** ***Add validation for CUSTOMER_CODE in custom monitoring alert provision model * Add session dependency and customer meta retrieval in provision_custom_monitoring_alert_route() * Update streams field to be optional and provide default value * grafana reporting start and custom alert creation * create custom alert iris creation and precommit fixes * get grafana orgs and dashboards * Add endpoints to get Grafana dashboard details and panels * Add Grafana dashboard iframe link generation endpoint * huntress integration * precommit fixes * provision huntress code * Update example value for alert_priority field * added CustomAlertForm * updated CustomAlertForm * added CustomAlertForm validations * precommit fixes --------- Co-authored-by: Davide Di Modica <webmaster.ddm@gmail.com>

taylor_socfortress committed Feb 28, 2024 at 06:41 UTC 4c83fc2703aefe2fddcc6eaffd80133f51a0ccdd
39 files changed +3935 -85
backend/app/connectors/grafana/dashboards/Huntress/summary.json new
+1215
@@ -0,0 +1,1215 @@
1 +{
2 + "annotations": {
3 + "list": [
4 + {
5 + "builtIn": 1,
6 + "datasource": {
7 + "type": "grafana",
8 + "uid": "-- Grafana --"
9 + },
10 + "enable": true,
11 + "hide": true,
12 + "iconColor": "rgba(0, 211, 255, 1)",
13 + "name": "Annotations & Alerts",
14 + "type": "dashboard"
15 + }
16 + ]
17 + },
18 + "editable": true,
19 + "fiscalYearStartMonth": 0,
20 + "graphTooltip": 0,
21 + "id": null,
22 + "links": [],
23 + "liveNow": false,
24 + "panels": [
25 + {
26 + "datasource": {
27 + "type": "grafana-opensearch-datasource",
28 + "uid": "replace_datasource_uid"
29 + },
30 + "fieldConfig": {
31 + "defaults": {
32 + "color": {
33 + "mode": "thresholds"
34 + },
35 + "mappings": [],
36 + "thresholds": {
37 + "mode": "absolute",
38 + "steps": [
39 + {
40 + "color": "red",
41 + "value": null
42 + }
43 + ]
44 + }
45 + },
46 + "overrides": []
47 + },
48 + "gridPos": {
49 + "h": 7,
50 + "w": 4,
51 + "x": 0,
52 + "y": 0
53 + },
54 + "id": 5,
55 + "options": {
56 + "colorMode": "value",
57 + "graphMode": "none",
58 + "justifyMode": "auto",
59 + "orientation": "auto",
60 + "reduceOptions": {
61 + "calcs": ["sum"],
62 + "fields": "",
63 + "values": false
64 + },
65 + "textMode": "auto",
66 + "wideLayout": true
67 + },
68 + "pluginVersion": "10.2.3",
69 + "targets": [
70 + {
71 + "alias": "",
72 + "bucketAggs": [
73 + {
74 + "field": "timestamp",
75 + "id": "2",
76 + "settings": {
77 + "interval": "auto"
78 + },
79 + "type": "date_histogram"
80 + }
81 + ],
82 + "datasource": {
83 + "type": "grafana-opensearch-datasource",
84 + "uid": "replace_datasource_uid"
85 + },
86 + "format": "table",
87 + "metrics": [
88 + {
89 + "id": "1",
90 + "type": "count"
91 + }
92 + ],
93 + "query": "severity:critical",
94 + "queryType": "lucene",
95 + "refId": "A",
96 + "timeField": "timestamp"
97 + }
98 + ],
99 + "title": "EVENTS - CRITICAL",
100 + "type": "stat"
101 + },
102 + {
103 + "datasource": {
104 + "type": "grafana-opensearch-datasource",
105 + "uid": "replace_datasource_uid"
106 + },
107 + "fieldConfig": {
108 + "defaults": {
109 + "color": {
110 + "mode": "thresholds"
111 + },
112 + "custom": {
113 + "align": "auto",
114 + "cellOptions": {
115 + "type": "auto"
116 + },
117 + "inspect": false
118 + },
119 + "mappings": [],
120 + "thresholds": {
121 + "mode": "absolute",
122 + "steps": [
123 + {
124 + "color": "red",
125 + "value": null
126 + }
127 + ]
128 + }
129 + },
130 + "overrides": [
131 + {
132 + "matcher": {
133 + "id": "byName",
134 + "options": "source"
135 + },
136 + "properties": [
137 + {
138 + "id": "custom.cellOptions",
139 + "value": {
140 + "type": "color-background"
141 + }
142 + }
143 + ]
144 + }
145 + ]
146 + },
147 + "gridPos": {
148 + "h": 7,
149 + "w": 6,
150 + "x": 4,
151 + "y": 0
152 + },
153 + "id": 6,
154 + "options": {
155 + "cellHeight": "sm",
156 + "footer": {
157 + "countRows": false,
158 + "fields": "",
159 + "reducer": ["sum"],
160 + "show": false
161 + },
162 + "showHeader": true
163 + },
164 + "pluginVersion": "10.2.3",
165 + "targets": [
166 + {
167 + "alias": "",
168 + "bucketAggs": [
169 + {
170 + "field": "source",
171 + "id": "3",
172 + "settings": {
173 + "min_doc_count": "1",
174 + "order": "desc",
175 + "orderBy": "_term",
176 + "size": "10"
177 + },
178 + "type": "terms"
179 + }
180 + ],
181 + "datasource": {
182 + "type": "grafana-opensearch-datasource",
183 + "uid": "replace_datasource_uid"
184 + },
185 + "format": "table",
186 + "metrics": [
187 + {
188 + "id": "1",
189 + "type": "count"
190 + }
191 + ],
192 + "query": "severity:critical",
193 + "queryType": "lucene",
194 + "refId": "A",
195 + "timeField": "timestamp"
196 + }
197 + ],
198 + "title": "EVENTS - CRITICAL (SOURCE)",
199 + "transformations": [
200 + {
201 + "id": "organize",
202 + "options": {
203 + "excludeByName": {},
204 + "includeByName": {},
205 + "indexByName": {},
206 + "renameByName": {
207 + "source": "SOURCE"
208 + }
209 + }
210 + }
211 + ],
212 + "transparent": true,
213 + "type": "table"
214 + },
215 + {
216 + "datasource": {
217 + "type": "grafana-opensearch-datasource",
218 + "uid": "replace_datasource_uid"
219 + },
220 + "fieldConfig": {
221 + "defaults": {
222 + "color": {
223 + "mode": "thresholds"
224 + },
225 + "custom": {
226 + "align": "auto",
227 + "cellOptions": {
228 + "type": "auto"
229 + },
230 + "inspect": false
231 + },
232 + "mappings": [],
233 + "thresholds": {
234 + "mode": "absolute",
235 + "steps": [
236 + {
237 + "color": "red",
238 + "value": null
239 + }
240 + ]
241 + }
242 + },
243 + "overrides": [
244 + {
245 + "matcher": {
246 + "id": "byName",
247 + "options": "subject"
248 + },
249 + "properties": [
250 + {
251 + "id": "custom.cellOptions",
252 + "value": {
253 + "type": "color-background"
254 + }
255 + }
256 + ]
257 + }
258 + ]
259 + },
260 + "gridPos": {
261 + "h": 7,
262 + "w": 14,
263 + "x": 10,
264 + "y": 0
265 + },
266 + "id": 7,
267 + "options": {
268 + "cellHeight": "sm",
269 + "footer": {
270 + "countRows": false,
271 + "fields": "",
272 + "reducer": ["sum"],
273 + "show": false
274 + },
275 + "showHeader": true
276 + },
277 + "pluginVersion": "10.2.3",
278 + "targets": [
279 + {
280 + "alias": "",
281 + "bucketAggs": [
282 + {
283 + "field": "subject",
284 + "id": "3",
285 + "settings": {
286 + "min_doc_count": "1",
287 + "order": "desc",
288 + "orderBy": "_term",
289 + "size": "10"
290 + },
291 + "type": "terms"
292 + }
293 + ],
294 + "datasource": {
295 + "type": "grafana-opensearch-datasource",
296 + "uid": "replace_datasource_uid"
297 + },
298 + "format": "table",
299 + "metrics": [
300 + {
301 + "id": "1",
302 + "type": "count"
303 + }
304 + ],
305 + "query": "severity:critical",
306 + "queryType": "lucene",
307 + "refId": "A",
308 + "timeField": "timestamp"
309 + }
310 + ],
311 + "title": "EVENTS - CRITICAL (SUBJECT)",
312 + "type": "table"
313 + },
314 + {
315 + "datasource": {
316 + "type": "grafana-opensearch-datasource",
317 + "uid": "replace_datasource_uid"
318 + },
319 + "fieldConfig": {
320 + "defaults": {
321 + "color": {
322 + "mode": "thresholds"
323 + },
324 + "mappings": [],
325 + "thresholds": {
326 + "mode": "absolute",
327 + "steps": [
328 + {
329 + "color": "orange",
330 + "value": null
331 + }
332 + ]
333 + }
334 + },
335 + "overrides": []
336 + },
337 + "gridPos": {
338 + "h": 7,
339 + "w": 4,
340 + "x": 0,
341 + "y": 7
342 + },
343 + "id": 3,
344 + "options": {
345 + "colorMode": "value",
346 + "graphMode": "none",
347 + "justifyMode": "auto",
348 + "orientation": "auto",
349 + "reduceOptions": {
350 + "calcs": ["sum"],
351 + "fields": "",
352 + "values": false
353 + },
354 + "textMode": "auto",
355 + "wideLayout": true
356 + },
357 + "pluginVersion": "10.2.3",
358 + "targets": [
359 + {
360 + "alias": "",
361 + "bucketAggs": [
362 + {
363 + "field": "timestamp",
364 + "id": "2",
365 + "settings": {
366 + "interval": "auto"
367 + },
368 + "type": "date_histogram"
369 + }
370 + ],
371 + "datasource": {
372 + "type": "grafana-opensearch-datasource",
373 + "uid": "replace_datasource_uid"
374 + },
375 + "format": "table",
376 + "metrics": [
377 + {
378 + "id": "1",
379 + "type": "count"
380 + }
381 + ],
382 + "query": "",
383 + "queryType": "lucene",
384 + "refId": "A",
385 + "timeField": "timestamp"
386 + }
387 + ],
388 + "title": "EVENTS",
389 + "type": "stat"
390 + },
391 + {
392 + "datasource": {
393 + "type": "grafana-opensearch-datasource",
394 + "uid": "replace_datasource_uid"
395 + },
396 + "fieldConfig": {
397 + "defaults": {
398 + "color": {
399 + "mode": "palette-classic"
400 + },
401 + "custom": {
402 + "axisBorderShow": false,
403 + "axisCenteredZero": false,
404 + "axisColorMode": "text",
405 + "axisLabel": "",
406 + "axisPlacement": "auto",
407 + "barAlignment": 0,
408 + "drawStyle": "bars",
409 + "fillOpacity": 0,
410 + "gradientMode": "none",
411 + "hideFrom": {
412 + "legend": false,
413 + "tooltip": false,
414 + "viz": false
415 + },
416 + "insertNulls": false,
417 + "lineInterpolation": "linear",
418 + "lineWidth": 1,
419 + "pointSize": 5,
420 + "scaleDistribution": {
421 + "type": "linear"
422 + },
423 + "showPoints": "auto",
424 + "spanNulls": false,
425 + "stacking": {
426 + "group": "A",
427 + "mode": "none"
428 + },
429 + "thresholdsStyle": {
430 + "mode": "off"
431 + }
432 + },
433 + "mappings": [],
434 + "thresholds": {
435 + "mode": "absolute",
436 + "steps": [
437 + {
438 + "color": "green",
439 + "value": null
440 + },
441 + {
442 + "color": "red",
443 + "value": 80
444 + }
445 + ]
446 + }
447 + },
448 + "overrides": []
449 + },
450 + "gridPos": {
451 + "h": 14,
452 + "w": 20,
453 + "x": 4,
454 + "y": 7
455 + },
456 + "id": 1,
457 + "options": {
458 + "legend": {
459 + "calcs": [],
460 + "displayMode": "table",
461 + "placement": "right",
462 + "showLegend": true
463 + },
464 + "tooltip": {
465 + "mode": "single",
466 + "sort": "none"
467 + }
468 + },
469 + "targets": [
470 + {
471 + "alias": "",
472 + "bucketAggs": [
473 + {
474 + "field": "source",
475 + "id": "3",
476 + "settings": {
477 + "min_doc_count": "0",
478 + "order": "desc",
479 + "orderBy": "_term",
480 + "size": "10"
481 + },
482 + "type": "terms"
483 + },
484 + {
485 + "field": "timestamp",
486 + "id": "2",
487 + "settings": {
488 + "interval": "10m"
489 + },
490 + "type": "date_histogram"
491 + }
492 + ],
493 + "datasource": {
494 + "type": "grafana-opensearch-datasource",
495 + "uid": "replace_datasource_uid"
496 + },
497 + "format": "table",
498 + "metrics": [
499 + {
500 + "id": "1",
501 + "type": "count"
502 + }
503 + ],
504 + "query": "",
505 + "queryType": "lucene",
506 + "refId": "A",
507 + "timeField": "timestamp"
508 + }
509 + ],
510 + "title": "EVENTS BY AGENT - HISTOGRAM",
511 + "transformations": [
512 + {
513 + "id": "organize",
514 + "options": {
515 + "excludeByName": {},
516 + "includeByName": {},
517 + "indexByName": {},
518 + "renameByName": {
519 + "Count": "Agent"
520 + }
521 + }
522 + }
523 + ],
524 + "type": "timeseries"
525 + },
526 + {
527 + "datasource": {
528 + "type": "grafana-opensearch-datasource",
529 + "uid": "replace_datasource_uid"
530 + },
531 + "fieldConfig": {
532 + "defaults": {
533 + "color": {
534 + "mode": "palette-classic"
535 + },
536 + "custom": {
537 + "hideFrom": {
538 + "legend": false,
539 + "tooltip": false,
540 + "viz": false
541 + }
542 + },
543 + "mappings": []
544 + },
545 + "overrides": []
546 + },
547 + "gridPos": {
548 + "h": 7,
549 + "w": 4,
550 + "x": 0,
551 + "y": 14
552 + },
553 + "id": 4,
554 + "options": {
555 + "legend": {
556 + "displayMode": "table",
557 + "placement": "right",
558 + "showLegend": true
559 + },
560 + "pieType": "donut",
561 + "reduceOptions": {
562 + "calcs": ["sum"],
563 + "fields": "",
564 + "values": false
565 + },
566 + "tooltip": {
567 + "mode": "single",
568 + "sort": "none"
569 + }
570 + },
571 + "targets": [
572 + {
573 + "alias": "",
574 + "bucketAggs": [
575 + {
576 + "field": "platform",
577 + "id": "3",
578 + "settings": {
579 + "min_doc_count": "0",
580 + "order": "desc",
581 + "orderBy": "_term",
582 + "size": "10"
583 + },
584 + "type": "terms"
585 + },
586 + {
587 + "field": "timestamp",
588 + "id": "2",
589 + "settings": {
590 + "interval": "auto"
591 + },
592 + "type": "date_histogram"
593 + }
594 + ],
595 + "datasource": {
596 + "type": "grafana-opensearch-datasource",
597 + "uid": "replace_datasource_uid"
598 + },
599 + "format": "table",
600 + "metrics": [
601 + {
602 + "id": "1",
603 + "type": "count"
604 + }
605 + ],
606 + "query": "",
607 + "queryType": "lucene",
608 + "refId": "A",
609 + "timeField": "timestamp"
610 + }
611 + ],
612 + "title": "EVENTS BY PLATRFORM",
613 + "transformations": [
614 + {
615 + "id": "organize",
616 + "options": {}
617 + }
618 + ],
619 + "type": "piechart"
620 + },
621 + {
622 + "datasource": {
623 + "type": "grafana-opensearch-datasource",
624 + "uid": "replace_datasource_uid"
625 + },
626 + "fieldConfig": {
627 + "defaults": {
628 + "color": {
629 + "mode": "palette-classic"
630 + },
631 + "custom": {
632 + "hideFrom": {
633 + "legend": false,
634 + "tooltip": false,
635 + "viz": false
636 + }
637 + },
638 + "mappings": []
639 + },
640 + "overrides": [
641 + {
642 + "matcher": {
643 + "id": "byName",
644 + "options": "high"
645 + },
646 + "properties": [
647 + {
648 + "id": "color",
649 + "value": {
650 + "fixedColor": "super-light-red",
651 + "mode": "fixed"
652 + }
653 + }
654 + ]
655 + },
656 + {
657 + "matcher": {
658 + "id": "byName",
659 + "options": "critical"
660 + },
661 + "properties": [
662 + {
663 + "id": "color",
664 + "value": {
665 + "fixedColor": "red",
666 + "mode": "fixed"
667 + }
668 + }
669 + ]
670 + }
671 + ]
672 + },
673 + "gridPos": {
674 + "h": 7,
675 + "w": 4,
676 + "x": 0,
677 + "y": 21
678 + },
679 + "id": 8,
680 + "options": {
681 + "legend": {
682 + "displayMode": "table",
683 + "placement": "right",
684 + "showLegend": true
685 + },
686 + "pieType": "donut",
687 + "reduceOptions": {
688 + "calcs": ["sum"],
689 + "fields": "",
690 + "values": false
691 + },
692 + "tooltip": {
693 + "mode": "single",
694 + "sort": "none"
695 + }
696 + },
697 + "targets": [
698 + {
699 + "alias": "",
700 + "bucketAggs": [
701 + {
702 + "field": "severity",
703 + "id": "3",
704 + "settings": {
705 + "min_doc_count": "0",
706 + "order": "desc",
707 + "orderBy": "_term",
708 + "size": "10"
709 + },
710 + "type": "terms"
711 + },
712 + {
713 + "field": "timestamp",
714 + "id": "2",
715 + "settings": {
716 + "interval": "auto"
717 + },
718 + "type": "date_histogram"
719 + }
720 + ],
721 + "datasource": {
722 + "type": "grafana-opensearch-datasource",
723 + "uid": "replace_datasource_uid"
724 + },
725 + "format": "table",
726 + "metrics": [
727 + {
728 + "id": "1",
729 + "type": "count"
730 + }
731 + ],
732 + "query": "",
733 + "queryType": "lucene",
734 + "refId": "A",
735 + "timeField": "timestamp"
736 + }
737 + ],
738 + "title": "EVENTS BY SEVERITY",
739 + "transformations": [
740 + {
741 + "id": "organize",
742 + "options": {}
743 + }
744 + ],
745 + "type": "piechart"
746 + },
747 + {
748 + "datasource": {
749 + "type": "grafana-opensearch-datasource",
750 + "uid": "replace_datasource_uid"
751 + },
752 + "fieldConfig": {
753 + "defaults": {
754 + "color": {
755 + "mode": "palette-classic"
756 + },
757 + "custom": {
758 + "axisBorderShow": false,
759 + "axisCenteredZero": false,
760 + "axisColorMode": "text",
761 + "axisLabel": "",
762 + "axisPlacement": "auto",
763 + "barAlignment": 0,
764 + "drawStyle": "bars",
765 + "fillOpacity": 0,
766 + "gradientMode": "none",
767 + "hideFrom": {
768 + "legend": false,
769 + "tooltip": false,
770 + "viz": false
771 + },
772 + "insertNulls": false,
773 + "lineInterpolation": "linear",
774 + "lineWidth": 1,
775 + "pointSize": 5,
776 + "scaleDistribution": {
777 + "type": "linear"
778 + },
779 + "showPoints": "auto",
780 + "spanNulls": false,
781 + "stacking": {
782 + "group": "A",
783 + "mode": "none"
784 + },
785 + "thresholdsStyle": {
786 + "mode": "off"
787 + }
788 + },
789 + "mappings": [],
790 + "thresholds": {
791 + "mode": "absolute",
792 + "steps": [
793 + {
794 + "color": "green",
795 + "value": null
796 + },
797 + {
798 + "color": "red",
799 + "value": 80
800 + }
801 + ]
802 + }
803 + },
804 + "overrides": [
805 + {
806 + "matcher": {
807 + "id": "byName",
808 + "options": "high"
809 + },
810 + "properties": [
811 + {
812 + "id": "color",
813 + "value": {
814 + "fixedColor": "super-light-red",
815 + "mode": "fixed"
816 + }
817 + }
818 + ]
819 + },
820 + {
821 + "matcher": {
822 + "id": "byName",
823 + "options": "critical"
824 + },
825 + "properties": [
826 + {
827 + "id": "color",
828 + "value": {
829 + "fixedColor": "red",
830 + "mode": "fixed"
831 + }
832 + }
833 + ]
834 + }
835 + ]
836 + },
837 + "gridPos": {
838 + "h": 14,
839 + "w": 20,
840 + "x": 4,
841 + "y": 21
842 + },
843 + "id": 2,
844 + "options": {
845 + "legend": {
846 + "calcs": [],
847 + "displayMode": "table",
848 + "placement": "right",
849 + "showLegend": true
850 + },
851 + "tooltip": {
852 + "mode": "single",
853 + "sort": "none"
854 + }
855 + },
856 + "targets": [
857 + {
858 + "alias": "",
859 + "bucketAggs": [
860 + {
861 + "field": "severity",
862 + "id": "3",
863 + "settings": {
864 + "min_doc_count": "0",
865 + "order": "desc",
866 + "orderBy": "_term",
867 + "size": "10"
868 + },
869 + "type": "terms"
870 + },
871 + {
872 + "field": "timestamp",
873 + "id": "2",
874 + "settings": {
875 + "interval": "10m"
876 + },
877 + "type": "date_histogram"
878 + }
879 + ],
880 + "datasource": {
881 + "type": "grafana-opensearch-datasource",
882 + "uid": "replace_datasource_uid"
883 + },
884 + "format": "table",
885 + "metrics": [
886 + {
887 + "id": "1",
888 + "type": "count"
889 + }
890 + ],
891 + "query": "",
892 + "queryType": "lucene",
893 + "refId": "A",
894 + "timeField": "timestamp"
895 + }
896 + ],
897 + "title": "EVENTS BY SEVERITY - HISTOGRAM",
898 + "transformations": [
899 + {
900 + "id": "organize",
901 + "options": {
902 + "excludeByName": {},
903 + "includeByName": {},
904 + "indexByName": {},
905 + "renameByName": {
906 + "Count": "Agent"
907 + }
908 + }
909 + }
910 + ],
911 + "type": "timeseries"
912 + },
913 + {
914 + "datasource": {
915 + "type": "grafana-opensearch-datasource",
916 + "uid": "replace_datasource_uid"
917 + },
918 + "fieldConfig": {
919 + "defaults": {
920 + "color": {
921 + "mode": "palette-classic"
922 + },
923 + "custom": {
924 + "hideFrom": {
925 + "legend": false,
926 + "tooltip": false,
927 + "viz": false
928 + }
929 + },
930 + "mappings": []
931 + },
932 + "overrides": [
933 + {
934 + "matcher": {
935 + "id": "byName",
936 + "options": "high"
937 + },
938 + "properties": [
939 + {
940 + "id": "color",
941 + "value": {
942 + "fixedColor": "super-light-red",
943 + "mode": "fixed"
944 + }
945 + }
946 + ]
947 + },
948 + {
949 + "matcher": {
950 + "id": "byName",
951 + "options": "critical"
952 + },
953 + "properties": [
954 + {
955 + "id": "color",
956 + "value": {
957 + "fixedColor": "red",
958 + "mode": "fixed"
959 + }
960 + }
961 + ]
962 + }
963 + ]
964 + },
965 + "gridPos": {
966 + "h": 7,
967 + "w": 4,
968 + "x": 0,
969 + "y": 28
970 + },
971 + "id": 9,
972 + "options": {
973 + "legend": {
974 + "displayMode": "table",
975 + "placement": "bottom",
976 + "showLegend": true
977 + },
978 + "pieType": "donut",
979 + "reduceOptions": {
980 + "calcs": ["sum"],
981 + "fields": "",
982 + "values": false
983 + },
984 + "tooltip": {
985 + "mode": "single",
986 + "sort": "none"
987 + }
988 + },
989 + "targets": [
990 + {
991 + "alias": "",
992 + "bucketAggs": [
993 + {
994 + "field": "indicator_types",
995 + "id": "3",
996 + "settings": {
997 + "min_doc_count": "0",
998 + "order": "desc",
999 + "orderBy": "_term",
1000 + "size": "10"
1001 + },
1002 + "type": "terms"
1003 + },
1004 + {
1005 + "field": "timestamp",
1006 + "id": "2",
1007 + "settings": {
1008 + "interval": "auto"
1009 + },
1010 + "type": "date_histogram"
1011 + }
1012 + ],
1013 + "datasource": {
1014 + "type": "grafana-opensearch-datasource",
1015 + "uid": "replace_datasource_uid"
1016 + },
1017 + "format": "table",
1018 + "metrics": [
1019 + {
1020 + "id": "1",
1021 + "type": "count"
1022 + }
1023 + ],
1024 + "query": "",
1025 + "queryType": "lucene",
1026 + "refId": "A",
1027 + "timeField": "timestamp"
1028 + }
1029 + ],
1030 + "title": "EVENTS BY INDICATOR TYPES",
1031 + "transformations": [
1032 + {
1033 + "id": "organize",
1034 + "options": {}
1035 + }
1036 + ],
1037 + "type": "piechart"
1038 + },
1039 + {
1040 + "datasource": {
1041 + "type": "grafana-opensearch-datasource",
1042 + "uid": "replace_datasource_uid"
1043 + },
1044 + "fieldConfig": {
1045 + "defaults": {
1046 + "color": {
1047 + "mode": "thresholds"
1048 + },
1049 + "custom": {
1050 + "align": "auto",
1051 + "cellOptions": {
1052 + "type": "auto"
1053 + },
1054 + "filterable": true,
1055 + "inspect": false
1056 + },
1057 + "mappings": [],
1058 + "thresholds": {
1059 + "mode": "absolute",
1060 + "steps": [
1061 + {
1062 + "color": "green"
1063 + },
1064 + {
1065 + "color": "red",
1066 + "value": 80
1067 + }
1068 + ]
1069 + }
1070 + },
1071 + "overrides": [
1072 + {
1073 + "matcher": {
1074 + "id": "byName",
1075 + "options": "EVENT ID"
1076 + },
1077 + "properties": [
1078 + {
1079 + "id": "links",
1080 + "value": [
1081 + {
1082 + "targetBlank": true,
1083 + "title": "EVENT DETAILS",
1084 + "url": "https://grafana.company.local/explore?left=%7B%22datasource%22:%22HUNTRESS%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22query%22:%22_id:${__value.text}%22,%22alias%22:%22%22,%22metrics%22:%5B%7B%22id%22:%221%22,%22type%22:%22logs%22,%22settings%22:%7B%22limit%22:%22500%22%7D%7D%5D,%22bucketAggs%22:%5B%5D,%22timeField%22:%22timestamp%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D"
1085 + }
1086 + ]
1087 + }
1088 + ]
1089 + }
1090 + ]
1091 + },
1092 + "gridPos": {
1093 + "h": 15,
1094 + "w": 24,
1095 + "x": 0,
1096 + "y": 35
1097 + },
1098 + "id": 10,
1099 + "options": {
1100 + "cellHeight": "sm",
1101 + "footer": {
1102 + "countRows": false,
1103 + "enablePagination": true,
1104 + "fields": "",
1105 + "reducer": ["sum"],
1106 + "show": false
1107 + },
1108 + "showHeader": true
1109 + },
1110 + "pluginVersion": "10.2.3",
1111 + "targets": [
1112 + {
1113 + "alias": "",
1114 + "bucketAggs": [],
1115 + "datasource": {
1116 + "type": "grafana-opensearch-datasource",
1117 + "uid": "replace_datasource_uid"
1118 + },
1119 + "format": "table",
1120 + "metrics": [
1121 + {
1122 + "id": "1",
1123 + "settings": {
1124 + "order": "desc",
1125 + "size": "500",
1126 + "useTimeRange": true
1127 + },
1128 + "type": "raw_data"
1129 + }
1130 + ],
1131 + "query": "",
1132 + "queryType": "lucene",
1133 + "refId": "A",
1134 + "timeField": "timestamp"
1135 + }
1136 + ],
1137 + "title": "EVENTS",
1138 + "transformations": [
1139 + {
1140 + "id": "filterFieldsByName",
1141 + "options": {
1142 + "include": {
1143 + "names": [
1144 + "timestamp",
1145 + "_id",
1146 + "body",
1147 + "footholds",
1148 + "indicator_types",
1149 + "platform",
1150 + "severity",
1151 + "source",
1152 + "status",
1153 + "subject",
1154 + "summary",
1155 + "agent_id"
1156 + ]
1157 + }
1158 + }
1159 + },
1160 + {
1161 + "id": "organize",
1162 + "options": {
1163 + "excludeByName": {},
1164 + "includeByName": {},
1165 + "indexByName": {
1166 + "_id": 1,
1167 + "agent_id": 3,
1168 + "body": 8,
1169 + "footholds": 9,
1170 + "indicator_types": 5,
1171 + "platform": 4,
1172 + "severity": 10,
1173 + "source": 2,
1174 + "status": 11,
1175 + "subject": 6,
1176 + "summary": 7,
1177 + "timestamp": 0
1178 + },
1179 + "renameByName": {
1180 + "_id": "EVENT ID",
1181 + "agent_id": "AGENT ID",
1182 + "body": "BODY",
1183 + "footholds": "FOOTHOLDS",
1184 + "indicator_types": "INDICATOR TYPES",
1185 + "platform": "PLATFORM",
1186 + "severity": "SEVERITY",
1187 + "source": "SOURCE",
1188 + "status": "STATUS",
1189 + "subject": "SUBJECT",
1190 + "summary": "SUMMARY",
1191 + "timestamp": "DATE/TIME"
1192 + }
1193 + }
1194 + }
1195 + ],
1196 + "transparent": true,
1197 + "type": "table"
1198 + }
1199 + ],
1200 + "refresh": "",
1201 + "schemaVersion": 39,
1202 + "tags": [],
1203 + "templating": {
1204 + "list": []
1205 + },
1206 + "time": {
1207 + "from": "now-24h",
1208 + "to": "now"
1209 + },
1210 + "timepicker": {},
1211 + "timezone": "",
1212 + "title": "HUNTRESS - _SUMMARY",
1213 + "version": 4,
1214 + "weekStart": ""
1215 +}
backend/app/connectors/grafana/dashboards/Mimecast/summary.json
+58 -58
@@ -51,7 +51,7 @@
51 "panels": [
52 {
53 "datasource": {
54 - "type": "elasticsearch",
54 + "type": "grafana-opensearch-datasource",
55 "uid": "replace_datasource_uid"
56 },
57 "fieldConfig": {
@@ -118,7 +118,7 @@
118 }
119 ],
120 "datasource": {
121 - "type": "elasticsearch",
121 + "type": "grafana-opensearch-datasource",
122 "uid": "replace_datasource_uid"
123 },
124 "metrics": [
@@ -139,7 +139,7 @@
139 },
140 {
141 "datasource": {
142 - "type": "elasticsearch",
142 + "type": "grafana-opensearch-datasource",
143 "uid": "replace_datasource_uid"
144 },
145 "fieldConfig": {
@@ -281,7 +281,7 @@
281 }
282 ],
283 "datasource": {
284 - "type": "elasticsearch",
284 + "type": "grafana-opensearch-datasource",
285 "uid": "replace_datasource_uid"
286 },
287 "metrics": [
@@ -310,7 +310,7 @@
310 },
311 {
312 "datasource": {
313 - "type": "elasticsearch",
313 + "type": "grafana-opensearch-datasource",
314 "uid": "replace_datasource_uid"
315 },
316 "fieldConfig": {
@@ -391,7 +391,7 @@
391 }
392 ],
393 "datasource": {
394 - "type": "elasticsearch",
394 + "type": "grafana-opensearch-datasource",
395 "uid": "replace_datasource_uid"
396 },
397 "metrics": [
@@ -413,7 +413,7 @@
413 },
414 {
415 "datasource": {
416 - "type": "elasticsearch",
416 + "type": "grafana-opensearch-datasource",
417 "uid": "replace_datasource_uid"
418 },
419 "fieldConfig": {
@@ -480,7 +480,7 @@
480 }
481 ],
482 "datasource": {
483 - "type": "elasticsearch",
483 + "type": "grafana-opensearch-datasource",
484 "uid": "replace_datasource_uid"
485 },
486 "metrics": [
@@ -514,7 +514,7 @@
514 },
515 {
516 "datasource": {
517 - "type": "elasticsearch",
517 + "type": "grafana-opensearch-datasource",
518 "uid": "replace_datasource_uid"
519 },
520 "fieldConfig": {
@@ -578,7 +578,7 @@
578 }
579 ],
580 "datasource": {
581 - "type": "elasticsearch",
581 + "type": "grafana-opensearch-datasource",
582 "uid": "replace_datasource_uid"
583 },
584 "metrics": [
@@ -612,7 +612,7 @@
612 },
613 {
614 "datasource": {
615 - "type": "elasticsearch",
615 + "type": "grafana-opensearch-datasource",
616 "uid": "replace_datasource_uid"
617 },
618 "fieldConfig": {
@@ -765,7 +765,7 @@
765 }
766 ],
767 "datasource": {
768 - "type": "elasticsearch",
768 + "type": "grafana-opensearch-datasource",
769 "uid": "replace_datasource_uid"
770 },
771 "metrics": [
@@ -797,7 +797,7 @@
797 "circleMinSize": 2,
798 "colors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"],
799 "datasource": {
800 - "type": "elasticsearch",
800 + "type": "grafana-opensearch-datasource",
801 "uid": "replace_datasource_uid"
802 },
803 "decimals": 0,
@@ -852,7 +852,7 @@
852 }
853 ],
854 "datasource": {
855 - "type": "elasticsearch",
855 + "type": "grafana-opensearch-datasource",
856 "uid": "replace_datasource_uid"
857 },
858 "metrics": [
@@ -875,7 +875,7 @@
875 },
876 {
877 "datasource": {
878 - "type": "elasticsearch",
878 + "type": "grafana-opensearch-datasource",
879 "uid": "replace_datasource_uid"
880 },
881 "fieldConfig": {
@@ -1017,7 +1017,7 @@
1017 }
1018 ],
1019 "datasource": {
1020 - "type": "elasticsearch",
1020 + "type": "grafana-opensearch-datasource",
1021 "uid": "replace_datasource_uid"
1022 },
1023 "metrics": [
@@ -1046,7 +1046,7 @@
1046 },
1047 {
1048 "datasource": {
1049 - "type": "elasticsearch",
1049 + "type": "grafana-opensearch-datasource",
1050 "uid": "replace_datasource_uid"
1051 },
1052 "fieldConfig": {
@@ -1120,7 +1120,7 @@
1120 }
1121 ],
1122 "datasource": {
1123 - "type": "elasticsearch",
1123 + "type": "grafana-opensearch-datasource",
1124 "uid": "replace_datasource_uid"
1125 },
1126 "metrics": [
@@ -1142,7 +1142,7 @@
1142 },
1143 {
1144 "datasource": {
1145 - "type": "elasticsearch",
1145 + "type": "grafana-opensearch-datasource",
1146 "uid": "replace_datasource_uid"
1147 },
1148 "fieldConfig": {
@@ -1216,7 +1216,7 @@
1216 }
1217 ],
1218 "datasource": {
1219 - "type": "elasticsearch",
1219 + "type": "grafana-opensearch-datasource",
1220 "uid": "replace_datasource_uid"
1221 },
1222 "metrics": [
@@ -1238,7 +1238,7 @@
1238 },
1239 {
1240 "datasource": {
1241 - "type": "elasticsearch",
1241 + "type": "grafana-opensearch-datasource",
1242 "uid": "replace_datasource_uid"
1243 },
1244 "fieldConfig": {
@@ -1492,7 +1492,7 @@
1492 {
1493 "bucketAggs": [],
1494 "datasource": {
1495 - "type": "elasticsearch",
1495 + "type": "grafana-opensearch-datasource",
1496 "uid": "replace_datasource_uid"
1497 },
1498 "metrics": [
@@ -1584,7 +1584,7 @@
1584 "panels": [
1585 {
1586 "datasource": {
1587 - "type": "elasticsearch",
1587 + "type": "grafana-opensearch-datasource",
1588 "uid": "replace_datasource_uid"
1589 },
1590 "fieldConfig": {
@@ -1651,7 +1651,7 @@
1651 }
1652 ],
1653 "datasource": {
1654 - "type": "elasticsearch",
1654 + "type": "grafana-opensearch-datasource",
1655 "uid": "replace_datasource_uid"
1656 },
1657 "metrics": [
@@ -1672,7 +1672,7 @@
1672 },
1673 {
1674 "datasource": {
1675 - "type": "elasticsearch",
1675 + "type": "grafana-opensearch-datasource",
1676 "uid": "replace_datasource_uid"
1677 },
1678 "fieldConfig": {
@@ -1814,7 +1814,7 @@
1814 }
1815 ],
1816 "datasource": {
1817 - "type": "elasticsearch",
1817 + "type": "grafana-opensearch-datasource",
1818 "uid": "replace_datasource_uid"
1819 },
1820 "metrics": [
@@ -1843,7 +1843,7 @@
1843 },
1844 {
1845 "datasource": {
1846 - "type": "elasticsearch",
1846 + "type": "grafana-opensearch-datasource",
1847 "uid": "replace_datasource_uid"
1848 },
1849 "fieldConfig": {
@@ -1924,7 +1924,7 @@
1924 }
1925 ],
1926 "datasource": {
1927 - "type": "elasticsearch",
1927 + "type": "grafana-opensearch-datasource",
1928 "uid": "replace_datasource_uid"
1929 },
1930 "metrics": [
@@ -1946,7 +1946,7 @@
1946 },
1947 {
1948 "datasource": {
1949 - "type": "elasticsearch",
1949 + "type": "grafana-opensearch-datasource",
1950 "uid": "replace_datasource_uid"
1951 },
1952 "fieldConfig": {
@@ -2013,7 +2013,7 @@
2013 }
2014 ],
2015 "datasource": {
2016 - "type": "elasticsearch",
2016 + "type": "grafana-opensearch-datasource",
2017 "uid": "replace_datasource_uid"
2018 },
2019 "metrics": [
@@ -2047,7 +2047,7 @@
2047 },
2048 {
2049 "datasource": {
2050 - "type": "elasticsearch",
2050 + "type": "grafana-opensearch-datasource",
2051 "uid": "replace_datasource_uid"
2052 },
2053 "fieldConfig": {
@@ -2189,7 +2189,7 @@
2189 }
2190 ],
2191 "datasource": {
2192 - "type": "elasticsearch",
2192 + "type": "grafana-opensearch-datasource",
2193 "uid": "replace_datasource_uid"
2194 },
2195 "metrics": [
@@ -2218,7 +2218,7 @@
2218 },
2219 {
2220 "datasource": {
2221 - "type": "elasticsearch",
2221 + "type": "grafana-opensearch-datasource",
2222 "uid": "replace_datasource_uid"
2223 },
2224 "fieldConfig": {
@@ -2371,7 +2371,7 @@
2371 }
2372 ],
2373 "datasource": {
2374 - "type": "elasticsearch",
2374 + "type": "grafana-opensearch-datasource",
2375 "uid": "replace_datasource_uid"
2376 },
2377 "metrics": [
@@ -2403,7 +2403,7 @@
2403 "circleMinSize": 2,
2404 "colors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"],
2405 "datasource": {
2406 - "type": "elasticsearch",
2406 + "type": "grafana-opensearch-datasource",
2407 "uid": "replace_datasource_uid"
2408 },
2409 "decimals": 0,
@@ -2458,7 +2458,7 @@
2458 }
2459 ],
2460 "datasource": {
2461 - "type": "elasticsearch",
2461 + "type": "grafana-opensearch-datasource",
2462 "uid": "replace_datasource_uid"
2463 },
2464 "metrics": [
@@ -2545,7 +2545,7 @@
2545 }
2546 ],
2547 "datasource": {
2548 - "type": "elasticsearch",
2548 + "type": "grafana-opensearch-datasource",
2549 "uid": "replace_datasource_uid"
2550 },
2551 "metrics": [
@@ -2579,7 +2579,7 @@
2579 },
2580 {
2581 "datasource": {
2582 - "type": "elasticsearch",
2582 + "type": "grafana-opensearch-datasource",
2583 "uid": "replace_datasource_uid"
2584 },
2585 "fieldConfig": {
@@ -2833,7 +2833,7 @@
2833 {
2834 "bucketAggs": [],
2835 "datasource": {
2836 - "type": "elasticsearch",
2836 + "type": "grafana-opensearch-datasource",
2837 "uid": "replace_datasource_uid"
2838 },
2839 "metrics": [
@@ -2925,7 +2925,7 @@
2925 "panels": [
2926 {
2927 "datasource": {
2928 - "type": "elasticsearch",
2928 + "type": "grafana-opensearch-datasource",
2929 "uid": "replace_datasource_uid"
2930 },
2931 "fieldConfig": {
@@ -2993,7 +2993,7 @@
2993 }
2994 ],
2995 "datasource": {
2996 - "type": "elasticsearch",
2996 + "type": "grafana-opensearch-datasource",
2997 "uid": "replace_datasource_uid"
2998 },
2999 "metrics": [
@@ -3014,7 +3014,7 @@
3014 },
3015 {
3016 "datasource": {
3017 - "type": "elasticsearch",
3017 + "type": "grafana-opensearch-datasource",
3018 "uid": "replace_datasource_uid"
3019 },
3020 "fieldConfig": {
@@ -3157,7 +3157,7 @@
3157 }
3158 ],
3159 "datasource": {
3160 - "type": "elasticsearch",
3160 + "type": "grafana-opensearch-datasource",
3161 "uid": "replace_datasource_uid"
3162 },
3163 "metrics": [
@@ -3186,7 +3186,7 @@
3186 },
3187 {
3188 "datasource": {
3189 - "type": "elasticsearch",
3189 + "type": "grafana-opensearch-datasource",
3190 "uid": "replace_datasource_uid"
3191 },
3192 "fieldConfig": {
@@ -3267,7 +3267,7 @@
3267 }
3268 ],
3269 "datasource": {
3270 - "type": "elasticsearch",
3270 + "type": "grafana-opensearch-datasource",
3271 "uid": "replace_datasource_uid"
3272 },
3273 "metrics": [
@@ -3289,7 +3289,7 @@
3289 },
3290 {
3291 "datasource": {
3292 - "type": "elasticsearch",
3292 + "type": "grafana-opensearch-datasource",
3293 "uid": "replace_datasource_uid"
3294 },
3295 "fieldConfig": {
@@ -3357,7 +3357,7 @@
3357 }
3358 ],
3359 "datasource": {
3360 - "type": "elasticsearch",
3360 + "type": "grafana-opensearch-datasource",
3361 "uid": "replace_datasource_uid"
3362 },
3363 "metrics": [
@@ -3391,7 +3391,7 @@
3391 },
3392 {
3393 "datasource": {
3394 - "type": "elasticsearch",
3394 + "type": "grafana-opensearch-datasource",
3395 "uid": "replace_datasource_uid"
3396 },
3397 "fieldConfig": {
@@ -3534,7 +3534,7 @@
3534 }
3535 ],
3536 "datasource": {
3537 - "type": "elasticsearch",
3537 + "type": "grafana-opensearch-datasource",
3538 "uid": "replace_datasource_uid"
3539 },
3540 "metrics": [
@@ -3563,7 +3563,7 @@
3563 },
3564 {
3565 "datasource": {
3566 - "type": "elasticsearch",
3566 + "type": "grafana-opensearch-datasource",
3567 "uid": "replace_datasource_uid"
3568 },
3569 "fieldConfig": {
@@ -3717,7 +3717,7 @@
3717 }
3718 ],
3719 "datasource": {
3720 - "type": "elasticsearch",
3720 + "type": "grafana-opensearch-datasource",
3721 "uid": "replace_datasource_uid"
3722 },
3723 "metrics": [
@@ -3749,7 +3749,7 @@
3749 "circleMinSize": 2,
3750 "colors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"],
3751 "datasource": {
3752 - "type": "elasticsearch",
3752 + "type": "grafana-opensearch-datasource",
3753 "uid": "replace_datasource_uid"
3754 },
3755 "decimals": 0,
@@ -3804,7 +3804,7 @@
3804 }
3805 ],
3806 "datasource": {
3807 - "type": "elasticsearch",
3807 + "type": "grafana-opensearch-datasource",
3808 "uid": "replace_datasource_uid"
3809 },
3810 "metrics": [
@@ -3827,7 +3827,7 @@
3827 },
3828 {
3829 "datasource": {
3830 - "type": "elasticsearch",
3830 + "type": "grafana-opensearch-datasource",
3831 "uid": "replace_datasource_uid"
3832 },
3833 "fieldConfig": {
@@ -3892,7 +3892,7 @@
3892 }
3893 ],
3894 "datasource": {
3895 - "type": "elasticsearch",
3895 + "type": "grafana-opensearch-datasource",
3896 "uid": "replace_datasource_uid"
3897 },
3898 "metrics": [
@@ -3926,7 +3926,7 @@
3926 },
3927 {
3928 "datasource": {
3929 - "type": "elasticsearch",
3929 + "type": "grafana-opensearch-datasource",
3930 "uid": "replace_datasource_uid"
3931 },
3932 "fieldConfig": {
@@ -4180,7 +4180,7 @@
4180 {
4181 "bucketAggs": [],
4182 "datasource": {
4183 - "type": "elasticsearch",
4183 + "type": "grafana-opensearch-datasource",
4184 "uid": "replace_datasource_uid"
4185 },
4186 "metrics": [
@@ -5552,7 +5552,7 @@
5552 "list": [
5553 {
5554 "datasource": {
5555 - "type": "elasticsearch",
5555 + "type": "grafana-opensearch-datasource",
5556 "uid": "replace_datasource_uid"
5557 },
5558 "filters": [],
backend/app/connectors/grafana/routes/reporting.py new
+208
@@ -0,0 +1,208 @@
1 +import time
2 +from datetime import datetime
3 +from datetime import timedelta
4 +from typing import List
5 +
6 +from fastapi import APIRouter
7 +from fastapi import Depends
8 +from fastapi import Security
9 +from loguru import logger
10 +from sqlalchemy.ext.asyncio import AsyncSession
11 +from sqlalchemy.future import select
12 +
13 +from app.auth.utils import AuthHandler
14 +from app.connectors.grafana.schema.reporting import GrafanaDashboardDetailsResponse
15 +from app.connectors.grafana.schema.reporting import GrafanaDashboardPanelsResponse
16 +from app.connectors.grafana.schema.reporting import GrafanaDashboardResponse
17 +from app.connectors.grafana.schema.reporting import GrafanaGenerateIframeLinksRequest
18 +from app.connectors.grafana.schema.reporting import GrafanaGenerateIframeLinksResponse
19 +from app.connectors.grafana.schema.reporting import GrafanaLinksList
20 +from app.connectors.grafana.schema.reporting import GrafanaOrganizationsResponse
21 +from app.connectors.grafana.schema.reporting import Panel
22 +from app.connectors.grafana.schema.reporting import TimeRange
23 +from app.connectors.grafana.services.reporting import get_dashboard_details
24 +from app.connectors.grafana.services.reporting import get_dashboards
25 +from app.connectors.grafana.services.reporting import get_orgs
26 +from app.connectors.models import Connectors
27 +from app.db.db_session import get_db
28 +
29 +# App specific imports
30 +
31 +
32 +grafana_reporting_router = APIRouter()
33 +
34 +
35 +async def get_grafana_url(session: AsyncSession):
36 + connector = await session.execute(select(Connectors).where(Connectors.connector_name == "Grafana"))
37 + connector = connector.scalars().first()
38 + return connector.connector_url
39 +
40 +
41 +def calculate_unix_timestamps(time_range: TimeRange):
42 + now = datetime.now()
43 + if time_range.unit == "minutes":
44 + start_time = now - timedelta(minutes=time_range.value)
45 + elif time_range.unit == "hours":
46 + start_time = now - timedelta(hours=time_range.value)
47 + elif time_range.unit == "days":
48 + start_time = now - timedelta(days=time_range.value)
49 +
50 + timestamp_from = int(time.mktime(start_time.timetuple())) * 1000
51 + timestamp_to = int(time.mktime(now.timetuple())) * 1000
52 +
53 + return timestamp_from, timestamp_to
54 +
55 +
56 +def generate_panel_urls(grafana_url: str, request: GrafanaGenerateIframeLinksRequest, timestamp_from: int, timestamp_to: int):
57 + panel_links: List[GrafanaLinksList] = []
58 + for panel_id in request.panel_ids:
59 + panel_url = (
60 + f"{grafana_url}/d-solo/{request.dashboard_uid}/{request.dashboard_title}"
61 + f"?orgId={request.org_id}&from={timestamp_from}&to={timestamp_to}"
62 + f"&panelId={panel_id}"
63 + )
64 + panel_links.append(GrafanaLinksList(panel_id=panel_id, panel_url=panel_url))
65 + return panel_links
66 +
67 +
68 +@grafana_reporting_router.get(
69 + "/orgs",
70 + response_model=GrafanaOrganizationsResponse,
71 + description="Provision Grafana dashboards",
72 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
73 +)
74 +async def get_grafana_orgs():
75 + """
76 + Endpoint to provision Grafana dashboards.
77 +
78 + Args:
79 + request (DashboardProvisionRequest): The request body containing the dashboard provisioning data.
80 +
81 + Returns:
82 + GrafanaDashboardResponse: The response containing the result of the dashboard provisioning.
83 + """
84 + logger.info("Getting Grafana orgs")
85 + orgs = await get_orgs()
86 + return GrafanaOrganizationsResponse(
87 + message="Organizations collected from Grafana",
88 + orgs=orgs,
89 + success=True,
90 + )
91 +
92 +
93 +@grafana_reporting_router.get(
94 + "/dashboards/{org_id}",
95 + response_model=GrafanaDashboardResponse,
96 + description="Get Grafana dashboards for reporting",
97 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
98 +)
99 +async def get_grafana_dashboards(org_id: int):
100 + """
101 + Endpoint to get Grafana dashboards.
102 +
103 + Args:
104 + org_id (int): The ID of the organization.
105 +
106 + Returns:
107 + GrafanaDashboardResponse: The response containing the result of the dashboard provisioning.
108 + """
109 + dashboards = await get_dashboards(org_id=org_id)
110 + return GrafanaDashboardResponse(
111 + message="Dashboards collected from Grafana",
112 + dashboards=dashboards,
113 + success=True,
114 + )
115 +
116 +
117 +@grafana_reporting_router.get(
118 + "/dashboard/{dashboard_uid}",
119 + response_model=GrafanaDashboardDetailsResponse,
120 + description="Get Grafana dashboard",
121 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
122 +)
123 +async def get_grafana_dashboard_details(dashboard_uid: str):
124 + """
125 + Endpoint to get Grafana dashboard.
126 +
127 + Args:
128 + dashboard_uid (str): The UID of the dashboard.
129 +
130 + Returns:
131 + GrafanaDashboardResponse: The response containing the result of the dashboard provisioning.
132 + """
133 + dashboard_details = await get_dashboard_details(dashboard_uid=dashboard_uid)
134 +
135 + return GrafanaDashboardDetailsResponse(
136 + message="Dashboard details collected from Grafana",
137 + dashboard_details=dashboard_details,
138 + success=True,
139 + )
140 +
141 +
142 +@grafana_reporting_router.get(
143 + "/dashboard_panels/{dashboard_uid}",
144 + response_model=GrafanaDashboardPanelsResponse,
145 + description="Get Grafana dashboard panels",
146 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
147 +)
148 +async def get_grafana_dashboard_panels(dashboard_uid: str):
149 + """
150 + Endpoint to get Grafana dashboard panels.
151 +
152 + Args:
153 + dashboard_uid (str): The UID of the dashboard.
154 +
155 + Returns:
156 + GrafanaDashboardResponse: The response containing the result of the dashboard provisioning.
157 + """
158 + dashboard_details = await get_dashboard_details(dashboard_uid=dashboard_uid)
159 + logger.info(f"Dashboard details: {dashboard_details}")
160 +
161 + # Get the panel id and panel title from the dashboard details for each panel
162 + panels = []
163 + logger.info("Fetching panels from dashboard details")
164 + for panel in dashboard_details["dashboard"]["panels"]:
165 + if panel["type"] != "row":
166 + panels.append(Panel(id=panel["id"], title=panel["title"]))
167 + logger.info(f"Panels: {panels}")
168 + return GrafanaDashboardPanelsResponse(
169 + message="Panels collected from Grafana",
170 + panels=panels,
171 + success=True,
172 + )
173 +
174 +
175 +@grafana_reporting_router.post(
176 + "/generate_iframe_links",
177 + response_model=GrafanaGenerateIframeLinksResponse,
178 + description="Generate Grafana dashboard iframe links",
179 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
180 +)
181 +async def generate_grafana_iframe_links(
182 + request: GrafanaGenerateIframeLinksRequest,
183 + session: AsyncSession = Depends(get_db),
184 +):
185 + """
186 + Endpoint to generate Grafana dashboard iframe links.
187 +
188 + Args:
189 + request (GrafanaGenerateIframeLinksRequest): The request body containing the dashboard UID and organization ID.
190 +
191 + Returns:
192 + GrafanaDashboardPanelsResponse: The response containing the result of the dashboard provisioning.
193 + """
194 + # get the Grafana URL from the database
195 + grafana_url = await get_grafana_url(session)
196 + logger.info(f"Grafana URL: {grafana_url}")
197 +
198 + # calculate the Unix timestamps based on the current time and the provided time range
199 + timestamp_from, timestamp_to = calculate_unix_timestamps(request.time_range)
200 +
201 + # build the URL string for each panel_id
202 + panel_urls = generate_panel_urls(grafana_url, request, timestamp_from, timestamp_to)
203 +
204 + return GrafanaGenerateIframeLinksResponse(
205 + message="Iframe links generated from Grafana",
206 + links=panel_urls,
207 + success=True,
208 + )
backend/app/connectors/grafana/schema/dashboards.py
+10 -1
@@ -70,6 +70,10 @@ class SapSiemDashboard(Enum):
70 USERS_AUTH = ("SapSiem", "users_auth.json")
71
72
73 +class HuntressDashboard(Enum):
74 + SUMMARY = ("Huntress", "summary.json")
75 +
76 +
77 class DashboardProvisionRequest(BaseModel):
78 dashboards: List[str] = Field(
79 ...,
@@ -88,7 +92,12 @@ class DashboardProvisionRequest(BaseModel):
92 @validator("dashboards", each_item=True)
93 def check_dashboard_exists(cls, e):
94 valid_dashboards = {
91 - item.name: item for item in list(WazuhDashboard) + list(Office365Dashboard) + list(MimecastDashboard) + list(SapSiemDashboard)
95 + item.name: item
96 + for item in list(WazuhDashboard)
97 + + list(Office365Dashboard)
98 + + list(MimecastDashboard)
99 + + list(SapSiemDashboard)
100 + + list(HuntressDashboard)
101 }
102 if e not in valid_dashboards:
103 raise ValueError(f'Dashboard identifier "{e}" is not recognized.')
backend/app/connectors/grafana/schema/reporting.py new
+191
@@ -0,0 +1,191 @@
1 +from typing import List
2 +from typing import Optional
3 +
4 +from fastapi import HTTPException
5 +from pydantic import BaseModel
6 +from pydantic import Field
7 +from pydantic import validator
8 +
9 +
10 +class GrafanaOrganizations(BaseModel):
11 + id: int = Field(..., description="The ID of the organization.")
12 + name: str = Field(..., description="The name of the organization.")
13 +
14 +
15 +class GrafanaOrganizationsResponse(BaseModel):
16 + message: str = Field(..., description="The message from the response.")
17 + orgs: List[GrafanaOrganizations] = Field(..., description="The organizations collected from Grafana.")
18 + success: bool = Field(..., description="The success of the response.")
19 +
20 +
21 +class GrafanaOrganizationDashboards(BaseModel):
22 + id: int
23 + uid: str
24 + title: str
25 + uri: str
26 + url: str
27 + slug: str
28 + type: str
29 + tags: List[str]
30 + isStarred: bool
31 + sortMeta: int
32 + folderId: Optional[int] = None
33 + folderUid: Optional[str] = None
34 + folderTitle: Optional[str] = None
35 + folderUrl: Optional[str] = None
36 +
37 +
38 +class GrafanaDashboardResponse(BaseModel):
39 + message: str = Field(..., description="The message from the response.")
40 + dashboards: List[GrafanaOrganizationDashboards] = Field(..., description="The dashboards collected from Grafana.")
41 + success: bool = Field(..., description="The success of the response.")
42 +
43 +
44 +class Annotation(BaseModel):
45 + builtIn: int
46 + datasource: dict # More specific model can be created for datasource
47 + enable: bool
48 + hide: bool
49 + iconColor: str
50 + name: str
51 + type: str
52 +
53 +
54 +class Threshold(BaseModel):
55 + color: str
56 + value: Optional[float]
57 +
58 +
59 +class FieldConfigDefaults(BaseModel):
60 + color: dict # More specific model can be created for color
61 + custom: dict # More specific model can be created for custom options
62 + mappings: List[dict] # More specific model can be created for mappings
63 + thresholds: dict # Utilize Threshold model
64 +
65 +
66 +class FieldConfig(BaseModel):
67 + defaults: FieldConfigDefaults
68 + overrides: List[dict] # More specific model can be created for overrides
69 +
70 +
71 +class GridPos(BaseModel):
72 + h: int
73 + w: int
74 + x: int
75 + y: int
76 +
77 +
78 +class PanelOptions(BaseModel):
79 + legend: dict # More specific model can be created for legend
80 + tooltip: dict # More specific model can be created for tooltip
81 +
82 +
83 +class Panel(BaseModel):
84 + fieldConfig: Optional[FieldConfig] = Field(None, description="The field configuration for the panel.")
85 + gridPos: Optional[GridPos] = Field(None, description="The grid position for the panel.")
86 + id: int
87 + options: Optional[PanelOptions] = Field(None, description="The options for the panel.")
88 + title: str
89 + type: Optional[str] = Field(None, description="The type of the panel.")
90 + collapsed: Optional[bool] = None # Optional field for row panel
91 + panels: List["Panel"] = [] # Nested list for row panel
92 +
93 +
94 +class Templating(BaseModel):
95 + list: List[dict] # More specific model can be created for template variables
96 +
97 +
98 +class DashboardDetails(BaseModel):
99 + annotations: dict # Use Annotation model for values in the list
100 + editable: bool
101 + fiscalYearStartMonth: int
102 + graphTooltip: int
103 + id: int
104 + links: List[dict] # More specific model can be created for links
105 + liveNow: bool
106 + panels: List[Panel]
107 + refresh: str
108 + schemaVersion: int
109 + tags: List[str]
110 + templating: Templating
111 + time: dict # More specific model can be created for time range
112 + timepicker: dict # More specific model can be created for timepicker options
113 + timezone: str
114 + title: str
115 + uid: str
116 + version: int
117 + weekStart: str
118 +
119 +
120 +class MetaDetails(BaseModel):
121 + type: str
122 + canSave: bool
123 + canEdit: bool
124 + canAdmin: bool
125 + canStar: bool
126 + canDelete: bool
127 + slug: str
128 + url: str
129 + expires: str
130 + created: str
131 + updated: str
132 + updatedBy: str
133 + createdBy: str
134 + version: int
135 + hasAcl: bool
136 + isFolder: bool
137 + folderId: int
138 + folderUid: str
139 + folderTitle: str
140 + folderUrl: str
141 + provisioned: bool
142 + provisionedExternalId: Optional[str] = None
143 + annotationsPermissions: dict # More specific model can be created for permissions
144 +
145 +
146 +class GrafanaDashboardDetails(BaseModel):
147 + meta: MetaDetails
148 + dashboard: DashboardDetails
149 +
150 +
151 +class GrafanaDashboardDetailsResponse(BaseModel):
152 + message: str = Field(..., description="The message from the response.")
153 + dashboard_details: GrafanaDashboardDetails = Field(..., description="The dashboard details collected from Grafana.")
154 + success: bool = Field(..., description="The success of the response.")
155 +
156 +
157 +class GrafanaDashboardPanelsResponse(BaseModel):
158 + message: str = Field(..., description="The message from the response.")
159 + panels: List[Panel] = Field(..., description="The panels collected from Grafana.")
160 + success: bool = Field(..., description="The success of the response.")
161 +
162 +
163 +class TimeRange(BaseModel):
164 + value: int
165 + unit: str
166 +
167 + @validator("unit")
168 + def validate_unit(cls, unit):
169 + valid_units = ["minutes", "hours", "days"]
170 + if unit not in valid_units:
171 + raise HTTPException(status_code=400, detail=f"Invalid time range unit: {unit}. Must be one of {valid_units}")
172 + return unit
173 +
174 +
175 +class GrafanaGenerateIframeLinksRequest(BaseModel):
176 + org_id: int = Field(..., description="The ID of the organization.")
177 + dashboard_title: str = Field(..., description="The title of the dashboard.")
178 + dashboard_uid: str = Field(..., description="The UID of the dashboard.")
179 + panel_ids: List[int] = Field(..., description="The IDs of the panels.")
180 + time_range: TimeRange = Field(..., description="Time range in minutes, hours, or days")
181 +
182 +
183 +class GrafanaLinksList(BaseModel):
184 + panel_id: int
185 + panel_url: str
186 +
187 +
188 +class GrafanaGenerateIframeLinksResponse(BaseModel):
189 + message: str = Field(..., description="The message from the response.")
190 + links: List[GrafanaLinksList] = Field(..., description="The links collected from Grafana.")
191 + success: bool = Field(..., description="The success of the response.")
backend/app/connectors/grafana/services/dashboards.py
+7 -1
@@ -7,6 +7,7 @@ from loguru import logger
7 from app.connectors.grafana.schema.dashboards import DashboardProvisionRequest
8 from app.connectors.grafana.schema.dashboards import GrafanaDashboard
9 from app.connectors.grafana.schema.dashboards import GrafanaDashboardResponse
10 +from app.connectors.grafana.schema.dashboards import HuntressDashboard
11 from app.connectors.grafana.schema.dashboards import MimecastDashboard
12 from app.connectors.grafana.schema.dashboards import Office365Dashboard
13 from app.connectors.grafana.schema.dashboards import SapSiemDashboard
@@ -146,7 +147,12 @@ async def provision_dashboards(
147 errors = []
148
149 valid_dashboards = {
149 - item.name: item for item in list(WazuhDashboard) + list(Office365Dashboard) + list(MimecastDashboard) + list(SapSiemDashboard)
150 + item.name: item
151 + for item in list(WazuhDashboard)
152 + + list(Office365Dashboard)
153 + + list(MimecastDashboard)
154 + + list(SapSiemDashboard)
155 + + list(HuntressDashboard)
156 }
157
158 for dashboard_name in dashboard_request.dashboards:
backend/app/connectors/grafana/services/reporting.py new
+73
@@ -0,0 +1,73 @@
1 +from typing import List
2 +
3 +from fastapi import HTTPException
4 +from loguru import logger
5 +
6 +from app.connectors.grafana.schema.reporting import GrafanaDashboardDetails
7 +from app.connectors.grafana.schema.reporting import GrafanaOrganizationDashboards
8 +from app.connectors.grafana.schema.reporting import GrafanaOrganizations
9 +from app.connectors.grafana.utils.universal import create_grafana_client
10 +
11 +
12 +async def get_orgs() -> List[GrafanaOrganizations]:
13 + """
14 + Update a dashboard in Grafana.
15 +
16 + Args:
17 + dashboard_json (dict): The updated dashboard JSON.
18 + organization_id (int): The ID of the organization.
19 + folder_id (int): The ID of the folder.
20 +
21 + Returns:
22 + dict: The updated dashboard response.
23 +
24 + Raises:
25 + HTTPException: If there is an error updating the dashboard.
26 + """
27 + logger.info("Getting organizations from Grafana")
28 + try:
29 + grafana_client = await create_grafana_client("Grafana")
30 + orgs = grafana_client.organizations.list_organization()
31 + return orgs
32 + except Exception as e:
33 + logger.error(f"Failed to collect organizations: {e}")
34 + raise HTTPException(status_code=500, detail=f"Failed to collect organizations: {e}")
35 +
36 +
37 +async def get_dashboards(org_id: int) -> List[GrafanaOrganizationDashboards]:
38 + """
39 + Get dashboards from Grafana.
40 +
41 + Returns:
42 + dict: The response containing the dashboards collected from Grafana.
43 + """
44 + logger.info("Getting dashboards from Grafana")
45 + try:
46 + grafana_client = await create_grafana_client("Grafana")
47 + logger.info(f"Switching to organization {org_id}")
48 + grafana_client.user.switch_actual_user_organisation(org_id)
49 + dashboards = grafana_client.search.search_dashboards()
50 + return dashboards
51 + except Exception as e:
52 + logger.error(f"Failed to collect dashboards: {e}")
53 + raise HTTPException(status_code=500, detail=f"Failed to collect dashboards: {e}")
54 +
55 +
56 +async def get_dashboard_details(dashboard_uid: str) -> GrafanaDashboardDetails:
57 + """
58 + Get dashboard details from Grafana.
59 +
60 + Args:
61 + dashboard_uid (str): The UID of the dashboard.
62 +
63 + Returns:
64 + dict: The response containing the dashboard details collected from Grafana.
65 + """
66 + logger.info("Getting dashboard details from Grafana")
67 + try:
68 + grafana_client = await create_grafana_client("Grafana")
69 + dashboard_details = grafana_client.dashboard.get_dashboard(dashboard_uid)
70 + return dashboard_details
71 + except Exception as e:
72 + logger.error(f"Failed to collect dashboard details: {e}")
73 + raise HTTPException(status_code=500, detail=f"Failed to collect dashboard details: {e}")
backend/app/db/db_populate.py
+3
@@ -255,6 +255,7 @@ def get_available_integrations_list():
255 ("Office365", "Integrate Office365 with SOCFortress."),
256 ("Mimecast", "Integrate Mimecast with SOCFortress."),
257 ("SAP SIEM", "Integrate SAP SIEM with SOCFortress."),
258 + ("Huntress", "Integrate Huntress with SOCFortress."),
259 # ... Add more available integrations as needed ...
260 ]
261
@@ -348,6 +349,8 @@ async def get_available_integrations_auth_keys_list(session: AsyncSession):
349 ("SAP SIEM", "SECRET_KEY"),
350 ("SAP SIEM", "USER_KEY"),
351 ("SAP SIEM", "API_DOMAIN"),
352 + ("Huntress", "API_KEY"),
353 + ("Huntress", "API_SECRET"),
354 # ... Add more available integrations auth keys as needed ...
355 ]
356
backend/app/integrations/huntress/routes/huntress.py new
+50
@@ -0,0 +1,50 @@
1 +from fastapi import APIRouter
2 +from fastapi import Depends
3 +from sqlalchemy.ext.asyncio import AsyncSession
4 +
5 +from app.db.db_session import get_db
6 +from app.integrations.huntress.schema.huntress import CollectHuntressRequest
7 +from app.integrations.huntress.schema.huntress import HuntressAuthKeys
8 +from app.integrations.huntress.schema.huntress import InvokeHuntressRequest
9 +from app.integrations.huntress.schema.huntress import InvokeHuntressResponse
10 +from app.integrations.huntress.services.collect import collect_huntress
11 +from app.integrations.routes import find_customer_integration
12 +from app.integrations.utils.utils import extract_auth_keys
13 +from app.integrations.utils.utils import get_customer_integration_response
14 +
15 +integration_huntress_router = APIRouter()
16 +
17 +
18 +@integration_huntress_router.post(
19 + "",
20 + response_model=InvokeHuntressResponse,
21 + description="Pull down Huntress Events.",
22 +)
23 +async def collect_huntress_route(huntress_request: InvokeHuntressRequest, session: AsyncSession = Depends(get_db)):
24 + """Pull down Huntress Events."""
25 + customer_integration_response = await get_customer_integration_response(
26 + huntress_request.customer_code,
27 + session,
28 + )
29 +
30 + customer_integration = await find_customer_integration(
31 + huntress_request.customer_code,
32 + huntress_request.integration_name,
33 + customer_integration_response,
34 + )
35 +
36 + huntress_auth_keys = extract_auth_keys(customer_integration, service_name="Huntress")
37 +
38 + auth_keys = HuntressAuthKeys(**huntress_auth_keys)
39 +
40 + await collect_huntress(
41 + request=(
42 + CollectHuntressRequest(
43 + customer_code=huntress_request.customer_code,
44 + apiKey=auth_keys.API_KEY,
45 + secretKey=auth_keys.API_SECRET,
46 + )
47 + ),
48 + )
49 +
50 + return InvokeHuntressResponse(success=True, message="Huntress Events collected successfully.")
backend/app/integrations/huntress/routes/provision.py new
+51
@@ -0,0 +1,51 @@
1 +from fastapi import APIRouter
2 +from fastapi import Depends
3 +from sqlalchemy.ext.asyncio import AsyncSession
4 +
5 +from app.db.db_session import get_db
6 +from app.integrations.huntress.schema.provision import ProvisionHuntressRequest
7 +from app.integrations.huntress.schema.provision import ProvisionHuntressResponse
8 +from app.integrations.huntress.services.provision import provision_huntress
9 +from app.integrations.utils.utils import get_customer_integration_response
10 +from app.schedulers.models.scheduler import CreateSchedulerRequest
11 +from app.schedulers.scheduler import add_scheduler_jobs
12 +
13 +integration_huntress_provision_scheduler_router = APIRouter()
14 +
15 +
16 +@integration_huntress_provision_scheduler_router.post(
17 + "/provision",
18 + response_model=ProvisionHuntressResponse,
19 + description="Provision a Huntress integration.",
20 +)
21 +async def provision_huntress_route(
22 + provision_huntress_request: ProvisionHuntressRequest,
23 + session: AsyncSession = Depends(get_db),
24 +) -> ProvisionHuntressResponse:
25 + """
26 + Provisions a huntress integration.
27 +
28 + Args:
29 + provision_huntress_request (ProvisionHuntressRequest): The request object containing the necessary data for provisioning.
30 + session (AsyncSession, optional): The database session. Defaults to Depends(get_db).
31 +
32 + Returns:
33 + ProvisionHuntressResponse: The response object indicating the success or failure of the provisioning process.
34 + """
35 + # Check if the customer integration settings are available and can be provisioned
36 + await get_customer_integration_response(
37 + provision_huntress_request.customer_code,
38 + session,
39 + )
40 + await provision_huntress(provision_huntress_request, session)
41 + await add_scheduler_jobs(
42 + CreateSchedulerRequest(
43 + function_name="invoke_huntress_integration_collection",
44 + time_interval=provision_huntress_request.time_interval,
45 + job_id="invoke_huntress_integration_collection",
46 + ),
47 + )
48 + return ProvisionHuntressResponse(
49 + success=True,
50 + message="Huntress integration provisioned successfully.",
51 + )
backend/app/integrations/huntress/schema/huntress.py new
+139
@@ -0,0 +1,139 @@
1 +from typing import Dict
2 +from typing import List
3 +from typing import Optional
4 +
5 +from pydantic import BaseModel
6 +from pydantic import Field
7 +
8 +
9 +class InvokeHuntressRequest(BaseModel):
10 + customer_code: str = Field(
11 + ...,
12 + description="The customer code.",
13 + examples=["00002"],
14 + )
15 + integration_name: str = Field(
16 + "Huntress",
17 + description="The integration name.",
18 + examples=["Huntress"],
19 + )
20 +
21 +
22 +class InvokeHuntressResponse(BaseModel):
23 + success: bool = Field(
24 + ...,
25 + description="The success status.",
26 + examples=[True],
27 + )
28 + message: str = Field(
29 + ...,
30 + description="The message.",
31 + examples=["Huntress Events collected successfully."],
32 + )
33 +
34 +
35 +class HuntressAuthKeys(BaseModel):
36 + API_KEY: str = Field(
37 + ...,
38 + description="The API key.",
39 + examples=["123456"],
40 + )
41 + API_SECRET: str = Field(
42 + ...,
43 + description="The secret key.",
44 + examples=["123456"],
45 + )
46 +
47 +
48 +class CollectHuntressRequest(BaseModel):
49 + customer_code: str = Field(
50 + ...,
51 + description="The customer code.",
52 + examples=["00002"],
53 + )
54 + apiKey: str = Field(
55 + ...,
56 + description="The API key.",
57 + examples=["123456"],
58 + )
59 + secretKey: str = Field(
60 + ...,
61 + description="The secret key.",
62 + examples=["123456"],
63 + )
64 +
65 +
66 +class Remediation(BaseModel):
67 + id: int
68 + type: str
69 + status: str
70 + details: dict
71 + completable_by_task_response: bool
72 + completable_manually: bool
73 + display_action: str
74 + approved_at: Optional[str]
75 + approved_by: Optional[dict]
76 + completed_at: Optional[str]
77 +
78 +
79 +class IndicatorCount(BaseModel):
80 + footholds: Optional[int] = Field(0, description="The number of footholds.")
81 + monitored_files: int = 0
82 + process_detections: int = 0
83 + ransomware_canaries: int = 0
84 + antivirus_detections: int = 0
85 +
86 +
87 +class ApprovedBy(BaseModel):
88 + id: int
89 + email: str
90 + first_name: str
91 + last_name: str
92 +
93 +
94 +class Foothold(BaseModel):
95 + id: Optional[int] # May not be present in all responses
96 + display_name: str
97 + service_name: str
98 + command: str
99 + file_path: str
100 + virus_total_detections: str
101 + virus_total_url: str
102 +
103 +
104 +class IncidentReport(BaseModel):
105 + id: int
106 + status: str
107 + summary: Optional[str]
108 + body: str
109 + updated_at: str
110 + agent_id: Optional[int]
111 + platform: str
112 + status_updated_at: str
113 + organization_id: Optional[int]
114 + sent_at: str
115 + account_id: int
116 + subject: str
117 + remediations: List[Remediation]
118 + footholds: Optional[str] = Field(None, description="The footholds.")
119 + severity: str
120 + closed_at: Optional[str] = Field(None, description="The date the incident was closed.")
121 + indicator_types: List[str]
122 + indicator_counts: IndicatorCount
123 +
124 + def to_dict(self) -> Dict:
125 + return self.dict()
126 +
127 +
128 +class Pagination(BaseModel):
129 + current_page: int
130 + current_page_count: int
131 + limit: int
132 + total_count: int
133 + next_page: Optional[int] = Field(None, description="The next page.")
134 + next_page_url: Optional[str] = Field(None, description="The next page URL.")
135 +
136 +
137 +class HuntressIncidentResponse(BaseModel):
138 + incident_reports: List[IncidentReport]
139 + pagination: Pagination
backend/app/integrations/huntress/schema/provision.py new
+87
@@ -0,0 +1,87 @@
1 +from typing import Any
2 +from typing import Dict
3 +from typing import List
4 +from typing import Optional
5 +
6 +from pydantic import BaseModel
7 +from pydantic import Field
8 +from pydantic import root_validator
9 +
10 +
11 +class ProvisionHuntressRequest(BaseModel):
12 + customer_code: str = Field(
13 + ...,
14 + description="The customer code.",
15 + examples=["00002"],
16 + )
17 + time_interval: int = Field(
18 + ...,
19 + description="The time interval for the scheduler.",
20 + examples=[5],
21 + )
22 + integration_name: str = Field(
23 + "Huntress",
24 + description="The integration name.",
25 + examples=["Huntress"],
26 + )
27 +
28 + # ensure the `integration_name` is always set to "Mimecast"
29 + @root_validator(pre=True)
30 + def set_integration_name(cls, values: Dict[str, Any]) -> Dict[str, Any]:
31 + values["integration_name"] = "Huntress"
32 + return values
33 +
34 +
35 +class ProvisionHuntressResponse(BaseModel):
36 + success: bool
37 + message: str
38 +
39 +
40 +# ! STREAMS ! #
41 +class StreamRule(BaseModel):
42 + field: str
43 + type: int
44 + inverted: bool
45 + value: str
46 +
47 +
48 +class HuntressEventStream(BaseModel):
49 + title: str = Field(..., description="Title of the stream")
50 + description: str = Field(..., description="Description of the stream")
51 + index_set_id: str = Field(..., description="ID of the associated index set")
52 + rules: List[StreamRule] = Field(..., description="List of rules for the stream")
53 + matching_type: str = Field(..., description="Matching type for the rules")
54 + remove_matches_from_default_stream: bool = Field(
55 + ...,
56 + description="Whether to remove matches from the default stream",
57 + )
58 + content_pack: Optional[str] = Field(
59 + None,
60 + description="Associated content pack, if any",
61 + )
62 +
63 + class Config:
64 + schema_extra = {
65 + "example": {
66 + "title": "Huntress SIEM EVENTS - Example Company",
67 + "description": "Huntress SIEM EVENTS - Example Company",
68 + "index_set_id": "12345",
69 + "rules": [
70 + {
71 + "field": "customer_code",
72 + "type": 1,
73 + "inverted": False,
74 + "value": "ExampleCode",
75 + },
76 + {
77 + "field": "integration",
78 + "type": 1,
79 + "inverted": False,
80 + "value": "huntress",
81 + },
82 + ],
83 + "matching_type": "AND",
84 + "remove_matches_from_default_stream": True,
85 + "content_pack": None,
86 + },
87 + }
backend/app/integrations/huntress/services/collect.py new
+86
@@ -0,0 +1,86 @@
1 +import base64
2 +from typing import List
3 +
4 +import httpx
5 +from loguru import logger
6 +
7 +from app.connectors.wazuh_indexer.utils.universal import create_wazuh_indexer_client
8 +from app.integrations.huntress.schema.huntress import CollectHuntressRequest
9 +from app.integrations.huntress.schema.huntress import HuntressIncidentResponse
10 +from app.integrations.huntress.schema.huntress import IncidentReport
11 +from app.integrations.utils.event_shipper import event_shipper
12 +from app.integrations.utils.schema import EventShipperPayload
13 +
14 +
15 +async def base64_encode(payload: CollectHuntressRequest) -> str:
16 + """Base64 encode the payload."""
17 + payload = f"{payload.apiKey}:{payload.secretKey}"
18 + payload_bytes = payload.encode("utf-8")
19 + base64_bytes = base64.b64encode(payload_bytes)
20 + base64_string = base64_bytes.decode("utf-8")
21 + return base64_string
22 +
23 +
24 +async def make_request(url: str, auth: str) -> HuntressIncidentResponse:
25 + headers = {"Accept": "application/json", "Authorization": f"Basic {auth}"}
26 + async with httpx.AsyncClient() as client:
27 + response = await client.get(url, headers=headers)
28 + return HuntressIncidentResponse(**response.json())
29 +
30 +
31 +async def get_next_page(response: HuntressIncidentResponse) -> str:
32 + if response.pagination and response.pagination.next_page_url:
33 + return response.pagination.next_page_url
34 + return None
35 +
36 +
37 +async def check_if_incident_exists(incident: IncidentReport) -> bool:
38 + """Check if the incident exists in the Wazuh-Indexer."""
39 + es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
40 + results = es_client.search(
41 + index="huntress*",
42 + body={
43 + "size": 1000,
44 + "query": {"bool": {"must": [{"term": {"id": incident.id}}]}},
45 + },
46 + )
47 + if results["hits"]["total"]["value"] > 0:
48 + logger.info("Event already exists in Wazuh-Indexer...Skipping")
49 + return True
50 + return False
51 +
52 +
53 +async def send_to_event_shipper(incident: IncidentReport, customer_code: str) -> None:
54 + exists = await check_if_incident_exists(incident)
55 + if not exists:
56 + message = EventShipperPayload(
57 + customer_code=customer_code,
58 + integration="huntress",
59 + version="1.0",
60 + **incident.to_dict(),
61 + )
62 + await event_shipper(message)
63 + return None
64 +
65 +
66 +async def process_incidents(incidents: List[IncidentReport], customer_code: str) -> None:
67 + """Process a list of incidents."""
68 + for incident in incidents:
69 + await send_to_event_shipper(incident, customer_code)
70 +
71 +
72 +async def process_pages(url: str, auth: str, customer_code: str) -> None:
73 + """Process all pages of incidents."""
74 + while url is not None:
75 + response = await make_request(url, auth)
76 + await process_incidents(response.incident_reports, customer_code)
77 + url = await get_next_page(response)
78 +
79 +
80 +async def collect_huntress(request: CollectHuntressRequest) -> None:
81 + """Pull down Huntress Events."""
82 + logger.info(f"Collecting Huntress Events with request: {request}")
83 + base64_string = await base64_encode(request)
84 + logger.info(f"Base64 encoded string: {base64_string}")
85 + url = "https://api.huntress.io/v1/incident_reports?page=1&limit=100"
86 + await process_pages(url, base64_string, request.customer_code)
backend/app/integrations/huntress/services/provision.py new
+394
@@ -0,0 +1,394 @@
1 +import json
2 +from datetime import datetime
3 +
4 +from loguru import logger
5 +from sqlalchemy import and_
6 +from sqlalchemy import update
7 +from sqlalchemy.ext.asyncio import AsyncSession
8 +
9 +from app.connectors.grafana.schema.dashboards import DashboardProvisionRequest
10 +from app.connectors.grafana.schema.dashboards import HuntressDashboard
11 +from app.connectors.grafana.services.dashboards import provision_dashboards
12 +from app.connectors.grafana.utils.universal import create_grafana_client
13 +from app.connectors.graylog.services.management import start_stream
14 +from app.connectors.graylog.utils.universal import send_post_request
15 +from app.customer_provisioning.schema.grafana import GrafanaDatasource
16 +from app.customer_provisioning.schema.grafana import GrafanaDataSourceCreationResponse
17 +from app.customer_provisioning.schema.graylog import GraylogIndexSetCreationResponse
18 +from app.customer_provisioning.schema.graylog import StreamCreationResponse
19 +from app.customer_provisioning.schema.graylog import TimeBasedIndexSet
20 +from app.customer_provisioning.services.grafana import create_grafana_folder
21 +from app.customer_provisioning.services.grafana import get_opensearch_version
22 +from app.customers.routes.customers import get_customer
23 +from app.customers.routes.customers import get_customer_meta
24 +from app.integrations.huntress.schema.provision import HuntressEventStream
25 +from app.integrations.huntress.schema.provision import ProvisionHuntressRequest
26 +from app.integrations.huntress.schema.provision import ProvisionHuntressResponse
27 +from app.integrations.models.customer_integration_settings import CustomerIntegrations
28 +from app.integrations.routes import create_integration_meta
29 +from app.integrations.schema import CustomerIntegrationsMetaSchema
30 +from app.utils import get_connector_attribute
31 +
32 +
33 +################## ! GRAYLOG ! ##################
34 +async def build_index_set_config(
35 + customer_code: str,
36 + session: AsyncSession,
37 +) -> TimeBasedIndexSet:
38 + """
39 + Build the configuration for a time-based index set.
40 +
41 + Args:
42 + request (ProvisionNewCustomer): The request object containing customer information.
43 +
44 + Returns:
45 + TimeBasedIndexSet: The configured time-based index set.
46 + """
47 + return TimeBasedIndexSet(
48 + title=f"HUNTRESS - {(await get_customer(customer_code, session)).customer.customer_name}",
49 + description=f"HUNTRESS - {customer_code}",
50 + index_prefix=f"huntress_{customer_code}",
51 + rotation_strategy_class="org.graylog2.indexer.rotation.strategies.TimeBasedRotationStrategy",
52 + rotation_strategy={
53 + "type": "org.graylog2.indexer.rotation.strategies.TimeBasedRotationStrategyConfig",
54 + "rotation_period": "P1D",
55 + "rotate_empty_index_set": False,
56 + "max_rotation_period": None,
57 + },
58 + retention_strategy_class="org.graylog2.indexer.retention.strategies.DeletionRetentionStrategy",
59 + retention_strategy={
60 + "type": "org.graylog2.indexer.retention.strategies.DeletionRetentionStrategyConfig",
61 + "max_number_of_indices": 30,
62 + },
63 + creation_date=datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
64 + index_analyzer="standard",
65 + shards=1,
66 + replicas=0,
67 + index_optimization_max_num_segments=1,
68 + index_optimization_disabled=False,
69 + writable=True,
70 + field_type_refresh_interval=5000,
71 + )
72 +
73 +
74 +# Function to send the POST request and handle the response
75 +async def send_index_set_creation_request(
76 + index_set: TimeBasedIndexSet,
77 +) -> GraylogIndexSetCreationResponse:
78 + """
79 + Sends a request to create an index set in Graylog.
80 +
81 + Args:
82 + index_set (TimeBasedIndexSet): The index set to be created.
83 +
84 + Returns:
85 + GraylogIndexSetCreationResponse: The response from Graylog after creating the index set.
86 + """
87 + json_index_set = json.dumps(index_set.dict())
88 + logger.info(f"json_index_set set: {json_index_set}")
89 + response_json = await send_post_request(
90 + endpoint="/api/system/indices/index_sets",
91 + data=index_set.dict(),
92 + )
93 + return GraylogIndexSetCreationResponse(**response_json)
94 +
95 +
96 +async def create_index_set(
97 + customer_code: str,
98 + session: AsyncSession,
99 +) -> GraylogIndexSetCreationResponse:
100 + """
101 + Creates an index set for a new customer.
102 +
103 + Args:
104 + request (ProvisionNewCustomer): The request object containing the customer information.
105 +
106 + Returns:
107 + GraylogIndexSetCreationResponse: The response object containing the result of the index set creation.
108 + """
109 + logger.info(f"Creating index set for customer {customer_code}")
110 + index_set_config = await build_index_set_config(customer_code, session)
111 + return await send_index_set_creation_request(index_set_config)
112 +
113 +
114 +# ! Event STREAMS ! #
115 +# Function to create event stream configuration
116 +async def build_event_stream_config(
117 + customer_code: str,
118 + index_set_id: str,
119 + session: AsyncSession,
120 +) -> HuntressEventStream:
121 + """
122 + Builds the configuration for the Huntress event stream.
123 +
124 + Args:
125 + customer_code (str): The customer code.
126 + index_set_id (str): The index set ID.
127 + session (AsyncSession): The async session.
128 +
129 + Returns:
130 + HuntressEventStream: The configured Huntress event stream.
131 + """
132 + return HuntressEventStream(
133 + title=f"HUNTRESS EVENTS - {(await get_customer(customer_code, session)).customer.customer_name}",
134 + description=f"HUNTRESS EVENTS - {(await get_customer(customer_code, session)).customer.customer_name}",
135 + index_set_id=index_set_id,
136 + rules=[
137 + {
138 + "field": "integration",
139 + "type": 1,
140 + "inverted": False,
141 + "value": "huntress",
142 + },
143 + {
144 + "field": "customer_code",
145 + "type": 1,
146 + "inverted": False,
147 + "value": f"{customer_code}",
148 + },
149 + ],
150 + matching_type="AND",
151 + remove_matches_from_default_stream=True,
152 + content_pack=None,
153 + )
154 +
155 +
156 +async def send_event_stream_creation_request(
157 + event_stream: HuntressEventStream,
158 +) -> StreamCreationResponse:
159 + """
160 + Sends a request to create an event stream.
161 +
162 + Args:
163 + event_stream (SapSiemEventStream): The event stream to be created.
164 +
165 + Returns:
166 + StreamCreationResponse: The response containing the created event stream.
167 + """
168 + json_event_stream = json.dumps(event_stream.dict())
169 + logger.info(f"json_event_stream set: {json_event_stream}")
170 + response_json = await send_post_request(
171 + endpoint="/api/streams",
172 + data=event_stream.dict(),
173 + )
174 + return StreamCreationResponse(**response_json)
175 +
176 +
177 +async def create_event_stream(
178 + customer_code: str,
179 + index_set_id: str,
180 + session: AsyncSession,
181 +) -> StreamCreationResponse:
182 + """
183 + Creates an event stream for a customer.
184 +
185 + Args:
186 + request (ProvisionNewCustomer): The request object containing customer information.
187 + index_set_id (str): The ID of the index set.
188 +
189 + Returns:
190 + The result of the event stream creation request.
191 + """
192 + event_stream_config = await build_event_stream_config(
193 + customer_code,
194 + index_set_id,
195 + session,
196 + )
197 + return await send_event_stream_creation_request(event_stream_config)
198 +
199 +
200 +#### ! GRAFANA ! ####
201 +async def create_grafana_datasource(
202 + customer_code: str,
203 + session: AsyncSession,
204 +) -> GrafanaDataSourceCreationResponse:
205 + """
206 + Creates a Grafana datasource for the specified customer.
207 +
208 + Args:
209 + customer_code (str): The customer code.
210 + session (AsyncSession): The async session.
211 +
212 + Returns:
213 + GrafanaDataSourceCreationResponse: The response containing the created datasource details.
214 + """
215 + logger.info("Creating Grafana datasource")
216 + grafana_client = await create_grafana_client("Grafana")
217 + # Switch to the newly created organization
218 + grafana_client.user.switch_actual_user_organisation(
219 + (await get_customer_meta(customer_code, session)).customer_meta.customer_meta_grafana_org_id,
220 + )
221 + datasource_payload = GrafanaDatasource(
222 + name="HUNTRESS",
223 + type="grafana-opensearch-datasource",
224 + typeName="OpenSearch",
225 + access="proxy",
226 + url=await get_connector_attribute(
227 + connector_id=1,
228 + column_name="connector_url",
229 + session=session,
230 + ),
231 + database=f"huntress_{customer_code}*",
232 + basicAuth=True,
233 + basicAuthUser=await get_connector_attribute(
234 + connector_id=1,
235 + column_name="connector_username",
236 + session=session,
237 + ),
238 + secureJsonData={
239 + "basicAuthPassword": await get_connector_attribute(
240 + connector_id=1,
241 + column_name="connector_password",
242 + session=session,
243 + ),
244 + },
245 + isDefault=False,
246 + jsonData={
247 + "database": f"huntress_{customer_code}*",
248 + "flavor": "opensearch",
249 + "includeFrozen": False,
250 + "logLevelField": "severity",
251 + "logMessageField": "summary",
252 + "maxConcurrentShardRequests": 5,
253 + "pplEnabled": True,
254 + "timeField": "timestamp",
255 + "tlsSkipVerify": True,
256 + "version": await get_opensearch_version(),
257 + },
258 + readOnly=True,
259 + )
260 + results = grafana_client.datasource.create_datasource(
261 + datasource=datasource_payload.dict(),
262 + )
263 + return GrafanaDataSourceCreationResponse(**results)
264 +
265 +
266 +async def provision_huntress(
267 + provision_huntress_request: ProvisionHuntressRequest,
268 + session: AsyncSession,
269 +) -> ProvisionHuntressResponse:
270 + logger.info(
271 + f"Provisioning Huntress integration for customer {provision_huntress_request.customer_code}.",
272 + )
273 +
274 + # Create Index Set
275 + index_set_id = (
276 + await create_index_set(
277 + customer_code=provision_huntress_request.customer_code,
278 + session=session,
279 + )
280 + ).data.id
281 + logger.info(f"Index set: {index_set_id}")
282 + # Create event stream
283 + stream_id = (
284 + await create_event_stream(
285 + provision_huntress_request.customer_code,
286 + index_set_id,
287 + session,
288 + )
289 + ).data.stream_id
290 + # Start stream
291 + await start_stream(stream_id=stream_id)
292 +
293 + # Grafana Deployment
294 + huntress_datasource_uid = (
295 + await create_grafana_datasource(
296 + customer_code=provision_huntress_request.customer_code,
297 + session=session,
298 + )
299 + ).datasource.uid
300 + grafana_huntress_folder_id = (
301 + await create_grafana_folder(
302 + organization_id=(
303 + await get_customer_meta(
304 + provision_huntress_request.customer_code,
305 + session,
306 + )
307 + ).customer_meta.customer_meta_grafana_org_id,
308 + folder_title="HUNTRESS",
309 + )
310 + ).id
311 + await provision_dashboards(
312 + DashboardProvisionRequest(
313 + dashboards=[dashboard.name for dashboard in HuntressDashboard],
314 + organizationId=(
315 + await get_customer_meta(
316 + provision_huntress_request.customer_code,
317 + session,
318 + )
319 + ).customer_meta.customer_meta_grafana_org_id,
320 + folderId=grafana_huntress_folder_id,
321 + datasourceUid=huntress_datasource_uid,
322 + ),
323 + )
324 + await create_integration_meta_entry(
325 + CustomerIntegrationsMetaSchema(
326 + customer_code=provision_huntress_request.customer_code,
327 + integration_name="Huntress",
328 + graylog_input_id=None,
329 + graylog_index_id=index_set_id,
330 + graylog_stream_id=stream_id,
331 + grafana_org_id=(
332 + await get_customer_meta(
333 + provision_huntress_request.customer_code,
334 + session,
335 + )
336 + ).customer_meta.customer_meta_grafana_org_id,
337 + grafana_dashboard_folder_id=grafana_huntress_folder_id,
338 + ),
339 + session,
340 + )
341 + await update_customer_integration_table(
342 + provision_huntress_request.customer_code,
343 + session,
344 + )
345 +
346 + return ProvisionHuntressResponse(
347 + success=True,
348 + message="Huntress integration provisioned successfully.",
349 + )
350 +
351 +
352 +############## ! WRITE TO DB ! ##############
353 +async def create_integration_meta_entry(
354 + customer_integration_meta: CustomerIntegrationsMetaSchema,
355 + session: AsyncSession,
356 +) -> None:
357 + """
358 + Creates an entry for the customer integration meta in the database.
359 +
360 + Args:
361 + customer_integration_meta (CustomerIntegrationsMetaSchema): The customer integration meta object.
362 + session (AsyncSession): The async session object for database operations.
363 + """
364 + await create_integration_meta(customer_integration_meta, session)
365 + logger.info(
366 + f"Integration meta entry created for customer {customer_integration_meta.customer_code}.",
367 + )
368 +
369 +
370 +async def update_customer_integration_table(
371 + customer_code: str,
372 + session: AsyncSession,
373 +) -> None:
374 + """
375 + Updates the `customer_integrations` table to set the `deployed` column to True where the `customer_code`
376 + matches the given customer code and the `integration_service_name` is "Huntress".
377 +
378 + Args:
379 + customer_code (str): The customer code.
380 + session (AsyncSession): The async session object for making HTTP requests.
381 + """
382 + await session.execute(
383 + update(CustomerIntegrations)
384 + .where(
385 + and_(
386 + CustomerIntegrations.customer_code == customer_code,
387 + CustomerIntegrations.integration_service_name == "Huntress",
388 + ),
389 + )
390 + .values(deployed=True),
391 + )
392 + await session.commit()
393 +
394 + return None
backend/app/integrations/markdown/huntress.md new
+23
@@ -0,0 +1,23 @@
1 +# [Huntress Integration](https://api.huntress.io/docs#introduction)
2 +
3 +Huntress is a cloud-based managed detection and response (MDR) solution that provides continuous monitoring and threat detection for endpoints. This integration allows you to ingest Huntress incidents into the SOCFortress SIEM stack.
4 +
5 +## Introduction
6 +
7 +The Huntress API follows a RESTful pattern. Requests are made via resource-oriented URLs as described in this document and API responses are formatted as JSON data.
8 +
9 +To test out the Huntress APIs quickly, use the [Swagger Interface](https://api.huntress.io/docs/preview).
10 +
11 +## API Overview
12 +
13 +### Authentication
14 +
15 +To begin, generate your API Key at `<your_account_subdomain>.huntress.io`. Once you are logged into your account on the Huntress site, check the dropdown menu at the top-right corner of the site header. You should see API Credentials among the options if your account has been granted access to the Huntress API. Click on the option to continue to the API Key generation page.
16 +
17 +Once on the API Key generation page, click on the green Setup button to begin the process to generate your API Key. You will be redirected to a page where you will be prompted to generate your API Key. Click the Generate button to generate a public and private key pair for Huntress API access. The inputs on the page will be filled in with your access credentials once you have done so.
18 +
19 +**Your API Private Key will only be visible at this stage of API Key generation. Be sure to save the value provided somewhere secure, as once you navigate away from this page, this value will no longer be accessible and you must regenerate your API credentials if your secret key value is lost.**
20 +
21 +If necessary, you can repeat the process to regenerate your API credentials with a new API Key and API Secret Key on the same API Key generation page, at `<your_account_subdomain>.huntress.io/account/api_credentials`.
22 +
23 +The Huntress API implements basic access authentication. Once you have your API Key and API Secret Key, provide these values as the result of a Base64 encoded string in every request to the Huntress API via the `Authorization` header. Your request header should look something like `Authorization: Basic [Base64Encode(<your_api_key>:<your_api_secret_key>)]`.
backend/app/integrations/monitoring_alert/routes/monitoring_alert.py
+51
@@ -26,6 +26,7 @@ from app.integrations.monitoring_alert.schema.monitoring_alert import (
26 from app.integrations.monitoring_alert.schema.monitoring_alert import (
27 MonitoringWazuhAlertsRequestModel,
28 )
29 +from app.integrations.monitoring_alert.services.custom import analyze_custom_alert
30 from app.integrations.monitoring_alert.services.office365_exchange import (
31 analyze_office365_exchange_online_alerts,
32 )
@@ -157,6 +158,56 @@ async def create_monitoring_alert(
158 )
159
160
161 +@monitoring_alerts_router.post(
162 + "/custom",
163 + response_model=GraylogPostResponse,
164 +)
165 +async def create_custom_monitoring_alert(
166 + monitoring_alert: GraylogPostRequest,
167 + session: AsyncSession = Depends(get_db),
168 +) -> GraylogPostResponse:
169 + """
170 + Create a new monitoring alert. This receives the alert from Graylog and stores it in the database.
171 +
172 + Args:
173 + monitoring_alert (MonitoringAlertsRequestModel): The monitoring alert details.
174 + session (AsyncSession, optional): The database session. Defaults to Depends(get_db).
175 +
176 + Returns:
177 + MonitoringAlertsRequestModel: The created monitoring alert.
178 + """
179 + logger.info(f"Creating monitoring alert: {monitoring_alert}")
180 + logger.info(f"Found index name {monitoring_alert.event.alert_index}")
181 +
182 + for field in monitoring_alert.event.fields:
183 + if field == "CUSTOMER_CODE":
184 + customer_meta = await session.execute(
185 + select(CustomersMeta).where(
186 + CustomersMeta.customer_code == monitoring_alert.event.fields[field],
187 + ),
188 + )
189 + customer_meta = customer_meta.scalars().first()
190 +
191 + if not customer_meta:
192 + logger.info(f"Getting customer meta for customer_meta_office365_organization_id: {monitoring_alert.event.fields[field]}")
193 + customer_meta = await session.execute(
194 + select(CustomersMeta).where(
195 + CustomersMeta.customer_meta_office365_organization_id == monitoring_alert.event.fields[field],
196 + ),
197 + )
198 + customer_meta = customer_meta.scalars().first()
199 +
200 + if not customer_meta:
201 + raise HTTPException(status_code=404, detail="Customer not found")
202 +
203 + await analyze_custom_alert(monitoring_alert, session)
204 +
205 + return GraylogPostResponse(
206 + success=True,
207 + message="Monitoring alert created successfully",
208 + )
209 +
210 +
211 @monitoring_alerts_router.post(
212 "/run_analysis/wazuh",
213 response_model=AlertAnalysisResponse,
backend/app/integrations/monitoring_alert/routes/provision.py
+67
@@ -1,19 +1,30 @@
1 +from typing import List
2 +
3 from fastapi import APIRouter
4 +from fastapi import Depends
5 from fastapi import HTTPException
6 from loguru import logger
7 +from sqlalchemy.ext.asyncio import AsyncSession
8
9 from app.connectors.graylog.routes.events import get_all_event_definitions
10 from app.connectors.graylog.schema.events import GraylogEventDefinitionsResponse
11 +from app.connectors.graylog.services.streams import get_streams
12 +from app.db.db_session import get_db
13 +from app.integrations.monitoring_alert.routes.monitoring_alert import get_customer_meta
14 from app.integrations.monitoring_alert.schema.provision import AvailableMonitoringAlerts
15 from app.integrations.monitoring_alert.schema.provision import (
16 AvailableMonitoringAlertsResponse,
17 )
18 +from app.integrations.monitoring_alert.schema.provision import (
19 + CustomMonitoringAlertProvisionModel,
20 +)
21 from app.integrations.monitoring_alert.schema.provision import (
22 ProvisionMonitoringAlertRequest,
23 )
24 from app.integrations.monitoring_alert.schema.provision import (
25 ProvisionWazuhMonitoringAlertResponse,
26 )
27 +from app.integrations.monitoring_alert.services.provision import provision_custom_alert
28 from app.integrations.monitoring_alert.services.provision import (
29 provision_office365_exchange_online_alert,
30 )
@@ -34,6 +45,23 @@ from app.schedulers.scheduler import add_scheduler_jobs
45 monitoring_alerts_provision_router = APIRouter()
46
47
48 +async def return_stream_ids(stream_names: List[str]) -> List[str]:
49 + """
50 + Return the stream IDs for the given stream names.
51 +
52 + Args:
53 + stream_names (List[str]): A list of stream names.
54 +
55 + Returns:
56 + List[str]: A list of stream IDs.
57 + """
58 + all_streams_response = await get_streams()
59 + all_streams = all_streams_response.streams
60 + stream_ids = [stream.id for stream in all_streams if stream.title in stream_names]
61 + logger.info(f"Stream IDs collected: {stream_ids}")
62 + return stream_ids
63 +
64 +
65 # Define your provision functions
66 async def invoke_provision_wazuh_monitoring_alert(
67 request: ProvisionMonitoringAlertRequest,
@@ -91,12 +119,20 @@ async def invoke_provision_office365_threat_intel_alert(
119 )
120
121
122 +async def invoke_provision_custom_monitoring_alert(
123 + request: CustomMonitoringAlertProvisionModel,
124 +):
125 + # Provision the custom monitoring alert
126 + await provision_custom_alert(request)
127 +
128 +
129 # Create a dictionary that maps alert names to provision functions
130 PROVISION_FUNCTIONS = {
131 "WAZUH_SYSLOG_LEVEL_ALERT": invoke_provision_wazuh_monitoring_alert,
132 "SURICATA_ALERT_SEVERITY_1": invoke_provision_suricata_monitoring_alert,
133 "OFFICE365_EXCHANGE_ONLINE": invoke_provision_office365_exchange_online_alert,
134 "OFFICE365_THREAT_INTEL": invoke_provision_office365_threat_intel_alert,
135 + "CUSTOM": invoke_provision_custom_monitoring_alert,
136 # Add more alert names and functions as needed
137 }
138
@@ -173,6 +209,37 @@ async def provision_monitoring_alert_route(
209 return ProvisionWazuhMonitoringAlertResponse(success=True, message=f"Monitoring alert {request.alert_name} provisioned successfully.")
210
211
212 +@monitoring_alerts_provision_router.post(
213 + "/provision/custom",
214 + response_model=ProvisionWazuhMonitoringAlertResponse,
215 + description="Provisions custom monitoring alerts.",
216 +)
217 +async def provision_custom_monitoring_alert_route(
218 + request: CustomMonitoringAlertProvisionModel,
219 + session: AsyncSession = Depends(get_db),
220 +) -> ProvisionWazuhMonitoringAlertResponse:
221 + await check_if_event_definition_exists(request.alert_name.replace("_", " "))
222 + customer_code = next((field.value for field in request.custom_fields if field.name == "CUSTOMER_CODE"), None)
223 + await get_customer_meta(customer_code=customer_code, session=session)
224 +
225 + # Look up the provision function based on request.alert_name
226 + provision_function = PROVISION_FUNCTIONS.get("CUSTOM")
227 +
228 + if provision_function is None:
229 + raise HTTPException(
230 + status_code=400,
231 + detail=f"No provision function found for alert name {request.alert_name}",
232 + )
233 +
234 + stream_ids = await return_stream_ids(request.streams)
235 + request.streams = stream_ids
236 +
237 + # Invoke the provision function
238 + await provision_function(request)
239 +
240 + return ProvisionWazuhMonitoringAlertResponse(success=True, message=f"Monitoring alert {request.alert_name} provisioned successfully.")
241 +
242 +
243 @monitoring_alerts_provision_router.post(
244 "/provision/testing",
245 response_model=ProvisionWazuhMonitoringAlertResponse,
backend/app/integrations/monitoring_alert/schema/monitoring_alert.py
+188 -1
@@ -4,9 +4,11 @@ from typing import Dict
4 from typing import List
5 from typing import Optional
6
7 +from fastapi import HTTPException
8 from pydantic import BaseModel
9 from pydantic import Extra
10 from pydantic import Field
11 +from pydantic import validator
12
13 from app.integrations.alert_creation.general.schema.alert import IrisAsset
14 from app.integrations.alert_creation.general.schema.alert import IrisIoc
@@ -110,7 +112,8 @@ class GraylogEvent(BaseModel):
112 description="Indicates if the event is an alert",
113 example=True,
114 )
113 - fields: GraylogEventFields = Field(..., description="Custom fields for the event")
115 + # fields: GraylogEventFields = Field(..., description="Custom fields for the event")
116 + fields: Dict[str, Any] = Field(..., description="Custom fields for the event")
117 group_by_fields: Dict[str, Any] = Field(
118 ...,
119 description="Fields used to group events",
@@ -121,6 +124,19 @@ class GraylogEvent(BaseModel):
124 def alert_index(self) -> str:
125 return self.origin_context.split(":")[4]
126
127 + @property
128 + def alert_id(self) -> str:
129 + return self.origin_context.split(":")[5]
130 +
131 + @validator("fields")
132 + def check_customer_code(cls, fields):
133 + if "CUSTOMER_CODE" not in fields:
134 + raise HTTPException(
135 + status_code=400,
136 + detail="CUSTOMER_CODE is required in the fields",
137 + )
138 + return fields
139 +
140
141 class GraylogPostRequest(BaseModel):
142 event_definition_id: str = Field(
@@ -346,6 +362,177 @@ class WazuhIrisAlertPayload(BaseModel):
362 return self.dict(exclude_none=True)
363
364
365 +########### ! CUSTOM ALERTS SCHEMA ! ###########
366 +class CustomSourceModel(BaseModel):
367 + timestamp: str = Field(..., description="The timestamp of the alert.")
368 + timestamp_utc: Optional[str] = Field(
369 + ...,
370 + description="The UTC timestamp of the alert.",
371 + )
372 + time_field: Optional[str] = Field(
373 + "timestamp",
374 + description="The timefield of the alert to be used when creating the IRIS alert.",
375 + )
376 + date: Optional[float] = Field(
377 + None,
378 + description="Date of the alert in Unix timestamp",
379 + )
380 + alert_metadata_tag: Optional[str] = Field(
381 + None,
382 + description="Metadata tag for the alert",
383 + )
384 + alert_gid: Optional[int] = Field(None, description="Alert group ID")
385 +
386 + class Config:
387 + allow_population_by_field_name = True
388 + extra = Extra.allow
389 +
390 + def to_dict(self):
391 + return self.dict(exclude_none=True)
392 +
393 +
394 +class CustomAlertModel(BaseModel):
395 + _index: str
396 + _id: str
397 + _version: int
398 + _source: CustomSourceModel
399 + asset_type_id: Optional[int] = Field(
400 + None,
401 + description="The asset type id of the alert which is needed for when we add the asset to IRIS.",
402 + )
403 + ioc_value: Optional[str] = Field(
404 + None,
405 + description="The IoC value of the alert which is needed for when we add the IoC to IRIS.",
406 + )
407 + ioc_type: Optional[str] = Field(
408 + None,
409 + description="The IoC type of the alert which is needed for when we add the IoC to IRIS.",
410 + )
411 +
412 + class Config:
413 + extra = Extra.allow
414 +
415 + def to_dict(self):
416 + return self.dict(exclude_none=True)
417 +
418 +
419 +########### ! Create Custom Alerts In IRIS Schemas ! ###########
420 +class CustomIrisAsset(BaseModel):
421 + asset_name: Optional[str] = Field(
422 + "Asset Does Not Apply to Custom Alerts",
423 + description="Name of the asset",
424 + example="Server01",
425 + )
426 + asset_ip: Optional[str] = Field(
427 + "Asset Does Not Apply to Custom Alerts",
428 + description="IP address of the asset",
429 + example="192.168.1.1",
430 + )
431 + asset_description: Optional[str] = Field(
432 + "Asset Does Not Apply to Custom Alerts",
433 + description="Description of the asset",
434 + example="Windows Server",
435 + )
436 + asset_type_id: Optional[int] = Field(
437 + 9,
438 + description="Type ID of the asset",
439 + example=1,
440 + )
441 +
442 + def to_dict(self):
443 + return self.dict(exclude_none=True)
444 +
445 +
446 +class CustomIrisIoc(BaseModel):
447 + ioc_value: str = Field(
448 + ...,
449 + description="Value of the IoC",
450 + example="www.google.com",
451 + )
452 + ioc_description: str = Field(
453 + ...,
454 + description="Description of the IoC",
455 + example="Google",
456 + )
457 + ioc_tlp_id: int = Field(1, description="TLP ID of the IoC", example=1)
458 + ioc_type_id: int = Field(20, description="Type ID of the IoC", example=20)
459 +
460 +
461 +class CustomIrisAlertContext(Dict[str, Any]):
462 + _source: CustomSourceModel
463 + alert_id: str = Field(..., description="ID of the alert", example="123")
464 + alert_name: str = Field(
465 + ...,
466 + description="Name of the alert",
467 + example="Intrusion Detected",
468 + )
469 + customer_iris_id: Optional[int] = Field(
470 + None,
471 + description="IRIS ID of the customer",
472 + )
473 + customer_name: Optional[str] = Field(
474 + None,
475 + description="Name of the customer",
476 + )
477 + customer_cases_index: Optional[str] = Field(
478 + None,
479 + description="IRIS case index name in the Wazuh-Indexer",
480 + )
481 + time_field: Optional[str] = Field(
482 + "timestamp_utc",
483 + description="The timefield of the alert to be used when creating the IRIS alert.",
484 + )
485 +
486 + def to_dict(self):
487 + return self.dict(exclude_none=True)
488 +
489 +
490 +class CustomIrisAlertPayload(BaseModel):
491 + alert_title: str = Field(
492 + ...,
493 + description="Title of the alert",
494 + example="Intrusion Detected",
495 + )
496 + alert_description: str = Field(
497 + ...,
498 + description="Description of the alert",
499 + example="Intrusion Detected by Firewall",
500 + )
501 + alert_source: str = Field(..., description="Source of the alert", example="Suricata")
502 + assets: List[CustomIrisAsset] = Field(..., description="List of affected assets")
503 + alert_status_id: int = Field(..., description="Status ID of the alert", example=3)
504 + alert_severity_id: int = Field(
505 + ...,
506 + description="Severity ID of the alert",
507 + example=5,
508 + )
509 + alert_customer_id: int = Field(
510 + ...,
511 + description="Customer ID related to the alert",
512 + example=1,
513 + )
514 + alert_source_content: Dict[str, Any] = Field(
515 + ...,
516 + description="Original content from the alert source",
517 + )
518 + alert_context: CustomIrisAlertContext = Field(
519 + ...,
520 + description="Contextual information about the alert",
521 + )
522 + alert_iocs: Optional[List[IrisIoc]] = Field(
523 + None,
524 + description="List of IoCs related to the alert",
525 + )
526 + alert_source_event_time: str = Field(
527 + ...,
528 + description="Timestamp of the alert",
529 + example="2021-01-01T00:00:00.000Z",
530 + )
531 +
532 + def to_dict(self):
533 + return self.dict(exclude_none=True)
534 +
535 +
536 ########### ! SURICATA ALERTS SCHEMA ! ###########
537 class SuricataSourceModel(BaseModel):
538 alert_signature: str = Field(..., description="Signature of the alert")
backend/app/integrations/monitoring_alert/schema/provision.py
+85
@@ -6,6 +6,7 @@ from typing import Optional
6 from fastapi import HTTPException
7 from pydantic import BaseModel
8 from pydantic import Field
9 +from pydantic import root_validator
10 from pydantic import validator
11
12
@@ -182,3 +183,87 @@ class GraylogAlertProvisionModel(BaseModel):
183 notification_settings: GraylogAlertProvisionNotificationSettings
184 notifications: List[GraylogAlertProvisionNotification]
185 alert: bool
186 +
187 +
188 +class AlertPriority(Enum):
189 + LOW = 1
190 + NORMAL = 2
191 + HIGH = 3
192 +
193 +
194 +class CustomFields(BaseModel):
195 + name: str
196 + value: str
197 +
198 + @validator("name")
199 + def replace_spaces_with_underscores(cls, v):
200 + return v.replace(" ", "_")
201 +
202 +
203 +class CustomMonitoringAlertProvisionModel(BaseModel):
204 + alert_name: str = Field(
205 + ...,
206 + description="The name of the alert to provision.",
207 + example="WAZUH_SYSLOG_LEVEL_ALERT",
208 + )
209 + alert_description: str = Field(
210 + ...,
211 + description=(
212 + "The description of the alert to provision. This alert monitors the "
213 + "SYSLOG_LEVEL field in the Wazuh logs. When the level is ALERT, it "
214 + "triggers an alert that is created within DFIR-IRIS. Ensure that you "
215 + "have a pipeline rule that sets the SYSLOG_LEVEL field to ALERT when "
216 + "the Wazuh rule level is greater than 11."
217 + ),
218 + example=(
219 + "This alert monitors the SYSLOG_LEVEL field in the Wazuh logs. When "
220 + "the level is ALERT, it triggers an alert that is created within "
221 + "DFIR-IRIS. Ensure that you have a pipeline rule that sets the "
222 + "SYSLOG_LEVEL field to ALERT when the Wazuh rule level is greater than 11."
223 + ),
224 + )
225 + alert_priority: AlertPriority = Field(
226 + ...,
227 + description="The priority of the alert to provision.",
228 + example=2,
229 + )
230 + search_query: str = Field(
231 + ...,
232 + description="The search query to use for the alert.",
233 + example="syslog_type:wazuh AND syslog_level:alert",
234 + )
235 + streams: Optional[List[str]] = Field(
236 + [],
237 + description="The streams to use for the alert.",
238 + example=["5f3e4c3b3f37b70001f3d7b3"],
239 + )
240 + custom_fields: List[CustomFields] = Field(
241 + ...,
242 + description="The custom fields to use for the alert.",
243 + example=[{"name": "source", "value": "Wazuh"}],
244 + )
245 + search_within_ms: int = Field(
246 + ...,
247 + description="The time in milliseconds to search within for the alert.",
248 + example=300000,
249 + )
250 + execute_every_ms: int = Field(
251 + ...,
252 + description="The time in milliseconds to execute the alert search.",
253 + example=300000,
254 + )
255 +
256 + @root_validator
257 + def check_customer_code(cls, values):
258 + custom_fields = values.get("custom_fields")
259 + if custom_fields is None:
260 + raise HTTPException(
261 + status_code=400,
262 + detail="At least one custom field with name CUSTOMER_CODE is required",
263 + )
264 + if not any(field.name == "CUSTOMER_CODE" for field in custom_fields):
265 + raise HTTPException(
266 + status_code=400,
267 + detail="At least one custom field with name CUSTOMER_CODE is required",
268 + )
269 + return values
backend/app/integrations/monitoring_alert/services/custom.py new
+215
@@ -0,0 +1,215 @@
1 +from loguru import logger
2 +from sqlalchemy.ext.asyncio import AsyncSession
3 +
4 +from app.connectors.dfir_iris.utils.universal import fetch_and_validate_data
5 +from app.connectors.dfir_iris.utils.universal import initialize_client_and_alert
6 +from app.connectors.wazuh_indexer.utils.universal import create_wazuh_indexer_client
7 +from app.integrations.alert_escalation.schema.general_alert import (
8 + CreateAlertRequest as AddAlertRequest,
9 +)
10 +from app.integrations.alert_escalation.services.general_alert import (
11 + add_alert_to_document,
12 +)
13 +from app.integrations.monitoring_alert.schema.monitoring_alert import (
14 + AlertAnalysisResponse,
15 +)
16 +from app.integrations.monitoring_alert.schema.monitoring_alert import CustomAlertModel
17 +from app.integrations.monitoring_alert.schema.monitoring_alert import (
18 + CustomIrisAlertContext,
19 +)
20 +from app.integrations.monitoring_alert.schema.monitoring_alert import (
21 + CustomIrisAlertPayload,
22 +)
23 +from app.integrations.monitoring_alert.schema.monitoring_alert import GraylogPostRequest
24 +from app.utils import get_customer_alert_settings
25 +
26 +
27 +async def fetch_wazuh_indexer_details(alert_id: str, index: str) -> CustomAlertModel:
28 + """
29 + Fetch the Custom alert details from the Wazuh-Indexer.
30 +
31 + Args:
32 + alert_id (str): The alert ID.
33 + index (str): The index.
34 +
35 + Returns:
36 + CollectAlertsResponse: The response from the Wazuh-Indexer.
37 + """
38 + logger.info(
39 + f"Fetching Custom alert details for alert_id: {alert_id} and index: {index}",
40 + )
41 +
42 + es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
43 + response = es_client.get(index=index, id=alert_id)
44 +
45 + return CustomAlertModel(**response)
46 +
47 +
48 +async def fetch_alert_details(alert: GraylogPostRequest) -> CustomAlertModel:
49 + logger.info(f"Analyzing custom alert: {alert.event.alert_id}")
50 + alert_details = await fetch_wazuh_indexer_details(alert_id=alert.event.alert_id, index=alert.event.alert_index)
51 + logger.info(f"Alert details: {alert_details}")
52 + return alert_details
53 +
54 +
55 +async def build_alert_context_payload(
56 + custom_details: dict,
57 + session: AsyncSession,
58 +) -> CustomIrisAlertContext:
59 + """
60 + Builds the payload for the alert context.
61 +
62 + Args:
63 + alert_details (CreateAlertRequest): The details of the alert.
64 + agent_data (AgentsResponse): The agent data.
65 + session (AsyncSession): The async session.
66 +
67 + Returns:
68 + CustomIrisAlertContext: The built alert context payload.
69 + """
70 + logger.info(f"Building alert context payload for alert with custom details: {custom_details.event.fields}")
71 + return CustomIrisAlertContext(
72 + customer_iris_id=(
73 + await get_customer_alert_settings(
74 + customer_code=custom_details.event.fields["CUSTOMER_CODE"],
75 + session=session,
76 + )
77 + ).iris_customer_id,
78 + customer_name=(
79 + await get_customer_alert_settings(
80 + customer_code=custom_details.event.fields["CUSTOMER_CODE"],
81 + session=session,
82 + )
83 + ).customer_name,
84 + customer_cases_index=(
85 + await get_customer_alert_settings(
86 + customer_code=custom_details.event.fields["CUSTOMER_CODE"],
87 + session=session,
88 + )
89 + ).iris_index,
90 + alert_id=custom_details.event.alert_id,
91 + alert_name=custom_details.event.message,
92 + **custom_details.event.fields,
93 + )
94 +
95 +
96 +async def build_alert_payload(
97 + alert_details: CustomIrisAlertContext,
98 + custom_details: dict,
99 + session: AsyncSession,
100 +) -> CustomIrisAlertPayload:
101 + """
102 + Builds the payload for an alert based on the provided alert details, agent data, IoC payload, and session.
103 +
104 + Args:
105 + alert_details (CustomAlertModel): The details of the alert.
106 + agent_data: The agent data associated with the alert.
107 + ioc_payload (Optional[IrisIoc]): The IoC payload associated with the alert.
108 + session (AsyncSession): The session used for database operations.
109 +
110 + Returns:
111 + CustomIrisAlertPayload: The built alert payload.
112 + """
113 + logger.info(f"Building alert payload for alert: {alert_details}")
114 +
115 + context_payload = await build_alert_context_payload(
116 + custom_details=custom_details,
117 + session=session,
118 + )
119 +
120 + logger.info(f"Alert has context: {context_payload}")
121 + return CustomIrisAlertPayload(
122 + alert_title=custom_details.event.message,
123 + alert_description=custom_details.event.message,
124 + alert_source="COPILOT Custom ANALYSIS",
125 + assets=[],
126 + alert_status_id=3,
127 + alert_severity_id=5,
128 + alert_customer_id=(
129 + await get_customer_alert_settings(
130 + customer_code=custom_details.event.fields["CUSTOMER_CODE"],
131 + session=session,
132 + )
133 + ).iris_customer_id,
134 + alert_source_content=alert_details.to_dict(),
135 + alert_context=context_payload,
136 + alert_source_event_time=custom_details.event.timestamp,
137 + )
138 +
139 +
140 +async def create_and_update_alert_in_iris(
141 + alert_details: CustomAlertModel,
142 + custom_details: dict,
143 + session: AsyncSession,
144 +) -> int:
145 + """
146 + Creates the alert, then updates the alert with the asset and IoC if available.
147 +
148 + Args:
149 + alert_details (CustomAlertModel): The details of the alert.
150 + session (AsyncSession): The async session object.
151 +
152 + Returns:
153 + int: The ID of the created alert in IRIS.
154 + """
155 + logger.info(f"Received custom fields: {custom_details}")
156 +
157 + iris_alert_payload = await build_alert_payload(
158 + alert_details=alert_details,
159 + custom_details=custom_details,
160 + session=session,
161 + )
162 +
163 + client, alert_client = await initialize_client_and_alert("DFIR-IRIS")
164 + result = await fetch_and_validate_data(
165 + client,
166 + alert_client.add_alert,
167 + iris_alert_payload.to_dict(),
168 + )
169 + alert_id = result["data"]["alert_id"]
170 + logger.info(f"Successfully created alert {alert_id} in IRIS.")
171 + return alert_id
172 +
173 +
174 +async def analyze_custom_alert(
175 + monitoring_alerts: GraylogPostRequest,
176 + session: AsyncSession,
177 +) -> AlertAnalysisResponse:
178 + """
179 + Analyze the given custom alerts. These are received straight from Graylog.
180 +
181 + 1. For each alert, extract the metadata from the Wazuh-Indexer.
182 + 2. Build the alert context payload which is based on the custom fields set within the Graylog alert.
183 +
184 + Args:
185 + monitoring_alerts (MonitoringAlerts): The monitoring alert details.
186 + session (AsyncSession): The database session.
187 +
188 + Returns:
189 + AlertAnalysisResponse: The analysis response.
190 + """
191 + logger.info(
192 + f"Analyzing custom alerts: alert_index: {monitoring_alerts.event.alert_index}, alert_id: {monitoring_alerts.event.alert_id}",
193 + )
194 + alert_details = await fetch_alert_details(monitoring_alerts)
195 +
196 + iris_alert_id = await create_and_update_alert_in_iris(
197 + alert_details,
198 + custom_details=monitoring_alerts,
199 + session=session,
200 + )
201 + es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
202 + await add_alert_to_document(
203 + es_client=es_client,
204 + alert=AddAlertRequest(
205 + alert_id=monitoring_alerts.event.alert_id,
206 + index_name=monitoring_alerts.event.alert_index,
207 + ),
208 + soc_alert_id=iris_alert_id,
209 + session=session,
210 + )
211 +
212 + return AlertAnalysisResponse(
213 + success=True,
214 + message="Custom alerts analyzed successfully",
215 + )
backend/app/integrations/monitoring_alert/services/provision.py
+103 -2
@@ -11,6 +11,9 @@ from app.connectors.graylog.schema.monitoring import GraylogEventNotificationsRe
11 from app.connectors.graylog.services.collector import get_url_whitelist_entries
12 from app.connectors.graylog.utils.universal import send_post_request
13 from app.connectors.graylog.utils.universal import send_put_request
14 +from app.integrations.monitoring_alert.schema.provision import (
15 + CustomMonitoringAlertProvisionModel,
16 +)
17 from app.integrations.monitoring_alert.schema.provision import (
18 GraylogAlertProvisionConfig,
19 )
@@ -534,7 +537,7 @@ async def provision_office365_exchange_online_alert(
537 notification_id = await get_notification_id("SEND TO COPILOT")
538 await provision_alert_definition(
539 GraylogAlertProvisionModel(
537 - title="OFFICE365 EXCHANGE ONLINE ALERT",
540 + title="OFFICE365 EXCHANGE ONLINE",
541 description="Alert on Office365 Exchange Online alerts",
542 priority=2,
543 config=GraylogAlertProvisionConfig(
@@ -653,7 +656,7 @@ async def provision_office365_threat_intel_alert(
656 notification_id = await get_notification_id("SEND TO COPILOT")
657 await provision_alert_definition(
658 GraylogAlertProvisionModel(
656 - title="OFFICE365 THREAT INTEL ALERT",
659 + title="OFFICE365 THREAT INTEL",
660 description="Alert on Office365 Threat Intel alerts",
661 priority=2,
662 config=GraylogAlertProvisionConfig(
@@ -723,3 +726,101 @@ async def provision_office365_threat_intel_alert(
726 success=True,
727 message="Office365 Threat Intel monitoring alerts provisioned successfully",
728 )
729 +
730 +
731 +async def provision_custom_alert(request: CustomMonitoringAlertProvisionModel) -> ProvisionWazuhMonitoringAlertResponse:
732 + """
733 + Provisions custom monitoring alerts.
734 +
735 + Returns:
736 + ProvisionWazuhMonitoringAlertResponse: The response indicating the success of provisioning the monitoring alerts.
737 + """
738 + #
739 + logger.info(
740 + f"Invoking provision_custom_alert with request: {request.dict()}",
741 + )
742 + notification_exists = await check_if_event_notification_exists("SEND TO COPILOT - CUSTOM")
743 + if not notification_exists:
744 + # ! Unfortunately Graylog does not support disabling SSL verification when sending webhooks
745 + # ! Therefore, we need to send to API port of Copilot over HTTP
746 + url_whitelisted = await check_if_url_whitelist_entry_exists(
747 + f"http://{os.getenv('ALERT_FORWARDING_IP')}:5000/api/monitoring_alert/custom",
748 + )
749 + if not url_whitelisted:
750 + logger.info("Provisioning URL Whitelist")
751 + whitelisted_urls = await build_url_whitelisted_entries(
752 + whitelist_url_model=GraylogUrlWhitelistEntryConfig(
753 + id=await generate_random_id(),
754 + value=f"http://{os.getenv('ALERT_FORWARDING_IP')}:5000/api/monitoring_alert/custom",
755 + title="SEND TO COPILOT - CUSTOM",
756 + type="literal",
757 + ),
758 + )
759 + await provision_webhook_url_whitelist(whitelisted_urls)
760 +
761 + logger.info("Provisioning SEND TO COPILOT - CUSTOM Webhook")
762 + notification_id = await provision_webhook(
763 + GraylogAlertWebhookNotificationModel(
764 + title="SEND TO COPILOT - CUSTOM",
765 + description="Send alert to Copilot for custom alert",
766 + config={
767 + "url": f"http://{os.getenv('ALERT_FORWARDING_IP')}:5000/api/monitoring_alert/custom",
768 + "type": "http-notification-v1",
769 + },
770 + ),
771 + )
772 + logger.info(f"SEND TO COPILOT - CUSTOM Webhook provisioned with id: {notification_id}")
773 + notification_id = await get_notification_id("SEND TO COPILOT - CUSTOM")
774 + await provision_alert_definition(
775 + GraylogAlertProvisionModel(
776 + title=request.alert_name,
777 + description=request.alert_description,
778 + priority=request.alert_priority.value,
779 + config=GraylogAlertProvisionConfig(
780 + type="aggregation-v1",
781 + query=f"{request.search_query}",
782 + query_parameters=[],
783 + streams=request.streams,
784 + group_by=[],
785 + series=[],
786 + conditions={
787 + "expression": None,
788 + },
789 + search_within_ms=await convert_seconds_to_milliseconds(
790 + request.search_within_ms,
791 + ),
792 + execute_every_ms=await convert_seconds_to_milliseconds(
793 + request.execute_every_ms,
794 + ),
795 + ),
796 + field_spec={
797 + custom_field.name: GraylogAlertProvisionFieldSpecItem(
798 + data_type="string",
799 + providers=[
800 + GraylogAlertProvisionProvider(
801 + type="template-v1",
802 + template=f"${{source.{custom_field.value}}}",
803 + require_values=True,
804 + ),
805 + ],
806 + )
807 + for custom_field in request.custom_fields
808 + },
809 + key_spec=[],
810 + notification_settings=GraylogAlertProvisionNotificationSettings(
811 + grace_period_ms=0,
812 + backlog_size=None,
813 + ),
814 + notifications=[
815 + GraylogAlertProvisionNotification(
816 + notification_id=notification_id,
817 + ),
818 + ],
819 + alert=True,
820 + ),
821 + )
822 +
823 + return ProvisionWazuhMonitoringAlertResponse(
824 + success=True,
825 + message="Custom monitoring alerts provisioned successfully",
826 + )
backend/app/routers/grafana.py
+2
@@ -1,9 +1,11 @@
1 from fastapi import APIRouter
2
3 from app.connectors.grafana.routes.dashboards import grafana_dashboards_router
4 +from app.connectors.grafana.routes.reporting import grafana_reporting_router
5
6 # Instantiate the APIRouter
7 router = APIRouter()
8
9 # Include the Shuffle related routes
10 router.include_router(grafana_dashboards_router, prefix="/grafana", tags=["Grafana"])
11 +router.include_router(grafana_reporting_router, prefix="/reporting", tags=["Grafana-Reporting"])
backend/app/routers/huntress.py new
+23
@@ -0,0 +1,23 @@
1 +from fastapi import APIRouter
2 +
3 +from app.integrations.huntress.routes.huntress import integration_huntress_router
4 +from app.integrations.huntress.routes.provision import (
5 + integration_huntress_provision_scheduler_router,
6 +)
7 +
8 +# Instantiate the APIRouter
9 +router = APIRouter()
10 +
11 +# Include the Huntress APIRouter
12 +router.include_router(
13 + integration_huntress_router,
14 + prefix="/huntress",
15 + tags=["huntress"],
16 +)
17 +
18 +# Include the Huntress Provision APIRouter
19 +router.include_router(
20 + integration_huntress_provision_scheduler_router,
21 + prefix="/huntress",
22 + tags=["huntress"],
23 +)
backend/app/schedulers/scheduler.py
+2
@@ -7,6 +7,7 @@ from app.db.db_session import sync_engine
7 from app.schedulers.models.scheduler import CreateSchedulerRequest
8 from app.schedulers.models.scheduler import JobMetadata
9 from app.schedulers.services.agent_sync import agent_sync
10 +from app.schedulers.services.invoke_huntress import invoke_huntress_integration_collect
11 from app.schedulers.services.invoke_mimecast import invoke_mimecast_integration
12 from app.schedulers.services.invoke_mimecast import invoke_mimecast_integration_ttp
13 from app.schedulers.services.invoke_sap_siem import invoke_sap_siem_integration_collect
@@ -103,6 +104,7 @@ def get_function_by_name(function_name: str):
104 "invoke_sap_siem_integration_collection": invoke_sap_siem_integration_collect,
105 "invoke_sap_siem_integration_suspicious_logins_analysis": invoke_sap_siem_integration_suspicious_logins_analysis,
106 "invoke_sap_siem_integration_multiple_logins_same_ip_analysis": invoke_sap_siem_integration_multiple_logins_same_ip_analysis,
107 + "invoke_huntress_integration_collection": invoke_huntress_integration_collect,
108 # Add other function mappings here
109 }
110 return function_map.get(
backend/app/schedulers/services/invoke_huntress.py new
+54
@@ -0,0 +1,54 @@
1 +from datetime import datetime
2 +
3 +from dotenv import load_dotenv
4 +from loguru import logger
5 +from sqlalchemy import select
6 +
7 +from app.db.db_session import get_db_session
8 +from app.db.db_session import get_sync_db_session
9 +from app.integrations.huntress.routes.huntress import collect_huntress_route
10 +from app.integrations.huntress.schema.huntress import InvokeHuntressRequest
11 +from app.integrations.huntress.schema.huntress import InvokeHuntressResponse
12 +from app.integrations.models.customer_integration_settings import CustomerIntegrations
13 +from app.schedulers.models.scheduler import JobMetadata
14 +from app.schedulers.utils.universal import get_scheduled_job_metadata
15 +
16 +load_dotenv()
17 +
18 +
19 +async def invoke_huntress_integration_collect() -> InvokeHuntressResponse:
20 + """
21 + Invokes the Huntress integration collection.
22 + """
23 + logger.info("Invoking Huntress integration collection.")
24 + customer_codes = []
25 + async with get_db_session() as session:
26 + stmt = select(CustomerIntegrations).where(
27 + CustomerIntegrations.integration_service_name == "Huntress",
28 + )
29 + result = await session.execute(stmt)
30 + customer_codes = [row.customer_code for row in result.scalars()]
31 + logger.info(f"customer_codes: {customer_codes}")
32 + for customer_code in customer_codes:
33 + await collect_huntress_route(
34 + InvokeHuntressRequest(
35 + customer_code=customer_code,
36 + integration_name="Huntress",
37 + time_range=f"{(await get_scheduled_job_metadata('invoke_huntress_integration_collection')).time_interval}m",
38 + ),
39 + session,
40 + )
41 + # Close the session
42 + await session.close()
43 + with get_sync_db_session() as session:
44 + # Synchronous ORM operations
45 + job_metadata = session.query(JobMetadata).filter_by(job_id="invoke_huntress_integration_collection").one_or_none()
46 + if job_metadata:
47 + job_metadata.last_success = datetime.utcnow()
48 + session.add(job_metadata)
49 + session.commit()
50 + else:
51 + # Handle the case where job_metadata does not exist
52 + print("JobMetadata for 'invoke_huntress_integration_collection' not found.")
53 +
54 + return InvokeHuntressResponse(success=True, message="Huntress integration invoked.")
backend/copilot.py
+2
@@ -37,6 +37,7 @@ from app.routers import dnstwist
37 from app.routers import grafana
38 from app.routers import graylog
39 from app.routers import healthcheck
40 +from app.routers import huntress
41 from app.routers import influxdb
42 from app.routers import integrations
43 from app.routers import logs
@@ -121,6 +122,7 @@ api_router.include_router(monitoring_alert.router)
122 api_router.include_router(sap_siem.router)
123 api_router.include_router(stack_provisioning.router)
124 api_router.include_router(active_response.router)
125 +api_router.include_router(huntress.router)
126
127 # Include the APIRouter in the FastAPI app
128 app.include_router(api_router)
frontend/package.json
+6 -6
@@ -39,8 +39,8 @@
39 "@fontsource/lexend": "^5.0.18",
40 "@fontsource/public-sans": "^5.0.16",
41 "@popperjs/core": "^2.11.8",
42 - "@vueuse/components": "^10.8.0",
43 - "@vueuse/core": "^10.8.0",
42 + "@vueuse/components": "^10.9.0",
43 + "@vueuse/core": "^10.9.0",
44 "apexcharts": "^3.46.0",
45 "bytes": "^3.1.2",
46 "colord": "^2.9.3",
@@ -53,13 +53,13 @@
53 "lodash": "^4.17.21",
54 "markdown-it-highlightjs": "^4.0.1",
55 "mitt": "^3.0.1",
56 - "naive-ui": "^2.38.0",
56 + "naive-ui": "^2.38.1",
57 "password-validator": "^5.3.0",
58 "pinia": "^2.1.7",
59 "pinia-plugin-persistedstate": "^3.2.1",
60 "secure-ls": "^1.2.6",
61 "validator": "^13.11.0",
62 - "vue": "^3.4.19",
62 + "vue": "^3.4.20",
63 "vue-advanced-cropper": "^2.8.8",
64 "vue-highlight-words": "^3.0.1",
65 "vue-i18n": "^9.9.1",
@@ -91,7 +91,7 @@
91 "@vue/tsconfig": "^0.5.1",
92 "autoprefixer": "^10.4.17",
93 "cypress": "^13.6.6",
94 - "eslint": "^8.56.0",
94 + "eslint": "^8.57.0",
95 "eslint-plugin-cypress": "^2.15.1",
96 "eslint-plugin-vue": "^9.22.0",
97 "fs-extra": "^11.2.0",
@@ -111,7 +111,7 @@
111 "typescript": "~5.3.3",
112 "unplugin-vue-components": "^0.26.0",
113 "vite": "^5.1.4",
114 - "vite-bundle-analyzer": "^0.8.0",
114 + "vite-bundle-analyzer": "^0.8.1",
115 "vite-bundle-visualizer": "^1.0.1",
116 "vite-svg-loader": "^5.1.0",
117 "vitest": "^1.3.1",
frontend/src/api/monitoringAlerts.ts
+23
@@ -7,6 +7,26 @@ export interface ProvisionsMonitoringAlertParams {
7 executeEvery: number
8 }
9
10 +export enum CustomProvisionPriority {
11 + "LOW" = 1,
12 + "MEDIUM" = 2,
13 + "HIGH" = 3
14 +}
15 +
16 +export interface CustomProvisionPayload {
17 + alert_name: string
18 + alert_description: string
19 + alert_priority: CustomProvisionPriority
20 + search_query: string
21 + streams: { [key: string]: any }
22 + custom_fields: {
23 + name: string
24 + value: string
25 + }[]
26 + search_within_ms: number
27 + execute_every_ms: number
28 +}
29 +
30 export default {
31 getAvailableMonitoringAlerts() {
32 return HttpClient.get<FlaskBaseResponse & { available_monitoring_alerts: AvailableMonitoringAlert[] }>(
@@ -19,5 +39,8 @@ export default {
39 execute_every: params.executeEvery,
40 alert_name: alertName
41 })
42 + },
43 + customProvision(payload: CustomProvisionPayload) {
44 + return HttpClient.post<FlaskBaseResponse>(`/monitoring_alert/provision/custom`, payload)
45 }
46 }
frontend/src/components/activeResponse/ActiveResponseInvokeForm.vue
+8 -1
@@ -119,6 +119,13 @@ function getClearForm(): InvokeForm {
119 }
120
121 function reset() {
122 + if (!loading.value) {
123 + resetForm()
124 + formRef.value?.restoreValidation()
125 + }
126 +}
127 +
128 +function resetForm() {
129 form.value = getClearForm()
130 }
131
@@ -154,7 +161,7 @@ function submit() {
161 if (res.data.success) {
162 message.success(res.data?.message || "Active Response invoked successfully")
163 emit("submitted")
157 - reset()
164 + resetForm()
165 } else {
166 message.warning(res.data?.message || "An error occurred. Please try again later.")
167 }
frontend/src/components/customers/CustomerMetaForm.vue
+8 -1
@@ -254,6 +254,13 @@ function getPayload(meta: CustomerMetaExt): CustomerMeta {
254 }
255
256 function reset() {
257 + if (!loading.value) {
258 + resetForm()
259 + formRef.value?.restoreValidation()
260 + }
261 +}
262 +
263 +function resetForm() {
264 form.value = getClearForm()
265 }
266
@@ -267,7 +274,7 @@ function submit() {
274 if (res.data.success) {
275 emit("submitted", res.data.customer_meta)
276 if (resetOnSubmit.value) {
270 - reset()
277 + resetForm()
278 }
279 } else {
280 message.warning(res.data?.message || "An error occurred. Please try again later.")
frontend/src/components/customers/provision/CustomerDefaultSettingsButton.vue
+2 -2
@@ -15,7 +15,7 @@
15 :bordered="false"
16 segmented
17 >
18 - <CustomerDefaultSettingForm @mounted="settingsFormCTX = $event" v-model:loading="loading" />
18 + <CustomerDefaultSettingsForm @mounted="settingsFormCTX = $event" v-model:loading="loading" />
19 </n-modal>
20 </template>
21
@@ -23,7 +23,7 @@
23 import { ref, watch } from "vue"
24 import { NButton, NModal } from "naive-ui"
25 import Icon from "@/components/common/Icon.vue"
26 -import CustomerDefaultSettingForm from "./CustomerDefaultSettingForm.vue"
26 +import CustomerDefaultSettingsForm from "./CustomerDefaultSettingsForm.vue"
27
28 const SettingsIcon = "carbon:settings-edit"
29 const settingsFormCTX = ref<{ load: () => void } | null>(null)
frontend/src/components/customers/provision/CustomerDefaultSettingsForm.vue renamed
+7 -1
@@ -149,10 +149,15 @@ function getClearForm(settings?: Omit<CustomerProvisioningDefaultSettings, "id">
149
150 function reset() {
151 if (!loading.value) {
152 - form.value = getClearForm()
152 + resetForm()
153 + formRef.value?.restoreValidation()
154 }
155 }
156
157 +function resetForm() {
158 + form.value = getClearForm()
159 +}
160 +
161 function submit() {
162 submittingDefaultSettings.value = true
163
@@ -169,6 +174,7 @@ function submit() {
174 .then(res => {
175 if (res.data.success) {
176 isNew.value = false
177 + message.success(res.data?.message || "Customer Provisioning Default Settings updated successfully")
178 } else {
179 message.warning(res.data?.message || "An error occurred. Please try again later.")
180 }
frontend/src/components/graylog/MonitoringAlerts/CustomAlertButton.vue new
+39
@@ -0,0 +1,39 @@
1 +<template>
2 + <n-button size="small" type="primary" secondary @click="showForm = true" :loading="loading">
3 + <div class="flex items-center gap-2">
4 + <Icon :name="DangerIcon" :size="18"></Icon>
5 + <span class="hidden xs:block">Custom Alert</span>
6 + </div>
7 + </n-button>
8 +
9 + <n-modal
10 + v-model:show="showForm"
11 + display-directive="show"
12 + preset="card"
13 + :style="{ maxWidth: 'min(600px, 90vw)', minHeight: 'min(300px, 90vh)', overflow: 'hidden' }"
14 + title="Create a Custom Alert"
15 + :bordered="false"
16 + segmented
17 + >
18 + <CustomAlertForm @mounted="formCTX = $event" v-model:loading="loading" />
19 + </n-modal>
20 +</template>
21 +
22 +<script setup lang="ts">
23 +import { ref, watch } from "vue"
24 +import { NButton, NModal } from "naive-ui"
25 +import Icon from "@/components/common/Icon.vue"
26 +import CustomAlertForm from "./CustomAlertForm.vue"
27 +
28 +const DangerIcon = "majesticons:exclamation-line"
29 +
30 +const formCTX = ref<{ reset: () => void } | null>(null)
31 +const showForm = ref(false)
32 +const loading = ref(false)
33 +
34 +watch(showForm, val => {
35 + if (val) {
36 + formCTX.value?.reset()
37 + }
38 +})
39 +</script>
frontend/src/components/graylog/MonitoringAlerts/CustomAlertForm.vue new
+437
@@ -0,0 +1,437 @@
1 +<template>
2 + <n-spin :show="loading" class="custom-alert-form">
3 + <n-form :label-width="80" :model="form" :rules="rules" ref="formRef">
4 + <div class="flex flex-col gap-2">
5 + <div class="flex gap-4">
6 + <n-form-item label="Priority" path="alert_priority" class="w-28">
7 + <n-select
8 + v-model:value="form.alert_priority"
9 + :options="alertPriorityOptions"
10 + placeholder="Select..."
11 + clearable
12 + />
13 + </n-form-item>
14 + <n-form-item label="Name" path="alert_name" class="grow">
15 + <n-input
16 + v-model:value.trim="form.alert_name"
17 + placeholder="Please insert Alert Name"
18 + clearable
19 + />
20 + </n-form-item>
21 + </div>
22 + <div class="flex flex-col gap-2">
23 + <n-form-item label="Description" path="alert_description">
24 + <n-input
25 + v-model:value.trim="form.alert_description"
26 + placeholder="Please insert Alert Description"
27 + clearable
28 + type="textarea"
29 + :autosize="{
30 + minRows: 3,
31 + maxRows: 10
32 + }"
33 + />
34 + </n-form-item>
35 + <n-form-item label="Search Query" path="search_query">
36 + <n-input
37 + v-model:value.trim="form.search_query"
38 + placeholder="Please insert Search Query"
39 + clearable
40 + />
41 + </n-form-item>
42 + </div>
43 + <div class="custom-fields-editor">
44 + <n-form-item label="Custom fields" path="custom_fields">
45 + <div class="custom-fields-list flex flex-col gap-1">
46 + <n-card size="small" v-for="(cf, index) of form.custom_fields" :key="cf.key">
47 + <div class="custom-field-box flex gap-2">
48 + <n-form-item
49 + label="Name"
50 + class="grow"
51 + size="small"
52 + :path="`custom_fields[${index}].name`"
53 + :rule="{
54 + required: true,
55 + message: `Field Name required`,
56 + trigger: ['input', 'blur']
57 + }"
58 + >
59 + <n-input
60 + v-model:value.trim="cf.name"
61 + @update:value="validate()"
62 + placeholder="Custom field Name"
63 + clearable
64 + />
65 + </n-form-item>
66 + <n-form-item
67 + label="Value"
68 + class="grow"
69 + size="small"
70 + :path="`custom_fields[${index}].value`"
71 + :rule="{
72 + required: true,
73 + message: `Field Value required`,
74 + trigger: ['input', 'blur']
75 + }"
76 + >
77 + <n-input
78 + v-model:value.trim="cf.value"
79 + @update:value="validate()"
80 + placeholder="Custom field Value"
81 + clearable
82 + />
83 + </n-form-item>
84 + <n-form-item size="small">
85 + <n-button type="error" secondary @click="removeCustomFiled(cf.key)">
86 + <template #icon>
87 + <Icon :name="RemoveIcon" :size="16"></Icon>
88 + </template>
89 + </n-button>
90 + </n-form-item>
91 + </div>
92 + </n-card>
93 + <div class="mt-3">
94 + <n-button @click="addCustomFiled()">
95 + <template #icon>
96 + <Icon :name="AddIcon"></Icon>
97 + </template>
98 + Add Custom Field
99 + </n-button>
100 + </div>
101 + </div>
102 + </n-form-item>
103 + </div>
104 + <div class="flex gap-4">
105 + <n-form-item label="Search within (seconds)" path="search_within_seconds" class="grow">
106 + <n-input-number
107 + :min="1"
108 + v-model:value="form.search_within_seconds"
109 + placeholder="Input time in seconds"
110 + clearable
111 + class="w-full"
112 + />
113 + </n-form-item>
114 + <n-form-item label="Execute every (seconds)" path="execute_every_seconds" class="grow">
115 + <n-input-number
116 + :min="1"
117 + v-model:value="form.execute_every_seconds"
118 + placeholder="Input time in seconds"
119 + clearable
120 + class="w-full"
121 + />
122 + </n-form-item>
123 + </div>
124 + <div class="flex justify-between gap-4">
125 + <div class="flex gap-4">
126 + <slot name="additionalActions"></slot>
127 + </div>
128 + <div class="flex gap-4">
129 + <n-button @click="reset()" :disabled="loading">Reset</n-button>
130 + <n-button
131 + type="primary"
132 + :disabled="!isValid"
133 + @click="validate(() => submit())"
134 + :loading="submittingCustomAlert"
135 + >
136 + Submit
137 + </n-button>
138 + </div>
139 + </div>
140 + </div>
141 + </n-form>
142 + </n-spin>
143 +</template>
144 +
145 +<script setup lang="ts">
146 +import { computed, onMounted, ref, watch } from "vue"
147 +import Api from "@/api"
148 +import {
149 + useMessage,
150 + NForm,
151 + NFormItem,
152 + NInput,
153 + NButton,
154 + NSpin,
155 + NSelect,
156 + NInputNumber,
157 + NCard,
158 + type FormValidationError,
159 + type FormInst,
160 + type FormRules,
161 + type FormItemRule,
162 + type MessageReactive
163 +} from "naive-ui"
164 +import _trim from "lodash/trim"
165 +import _get from "lodash/get"
166 +import _toSafeInteger from "lodash/toSafeInteger"
167 +import { type CustomProvisionPayload, CustomProvisionPriority } from "@/api/monitoringAlerts"
168 +import Icon from "@/components/common/Icon.vue"
169 +
170 +interface CustomProvisionForm {
171 + alert_name: string
172 + alert_description: string
173 + alert_priority: null | CustomProvisionPriority
174 + search_query: string
175 + custom_fields: {
176 + name: string
177 + value: string
178 + key: number
179 + }[]
180 + search_within_seconds: number
181 + execute_every_seconds: number
182 +}
183 +
184 +const emit = defineEmits<{
185 + (e: "update:loading", value: boolean): void
186 + (
187 + e: "mounted",
188 + value: {
189 + reset: () => void
190 + }
191 + ): void
192 +}>()
193 +
194 +const RemoveIcon = "ph:trash"
195 +const AddIcon = "carbon:add-alt"
196 +const submittingCustomAlert = ref(false)
197 +const loading = computed(() => submittingCustomAlert.value)
198 +const message = useMessage()
199 +const form = ref<CustomProvisionForm>(getClearForm())
200 +const formRef = ref<FormInst | null>(null)
201 +
202 +const alertPriorityOptions: { label: string; value: CustomProvisionPriority }[] = [
203 + { label: "Low", value: CustomProvisionPriority.LOW },
204 + { label: "Medium", value: CustomProvisionPriority.MEDIUM },
205 + { label: "High", value: CustomProvisionPriority.HIGH }
206 +]
207 +
208 +const rules: FormRules = {
209 + alert_priority: {
210 + required: true,
211 + validator: validatorNumber("Alert Priority", "Required"),
212 + trigger: ["input", "blur"]
213 + },
214 + alert_name: {
215 + required: true,
216 + message: "Please input the Alert Name",
217 + trigger: ["input", "blur"]
218 + },
219 + alert_description: {
220 + required: true,
221 + message: "Please input the Alert Description",
222 + trigger: ["input", "blur"]
223 + },
224 + search_query: {
225 + required: true,
226 + message: "Please input the Search Query",
227 + trigger: ["input", "blur"]
228 + },
229 + search_within_seconds: {
230 + required: true,
231 + // message: "Please input Search within",
232 + validator: validatorNumber("Search within"),
233 + trigger: ["input", "blur"]
234 + },
235 + execute_every_seconds: {
236 + required: true,
237 + // message: "Please input Execute every",
238 + validator: validatorNumber("Execute every"),
239 + trigger: ["input", "blur"]
240 + },
241 + custom_fields: {
242 + required: true,
243 + validator(rule: FormItemRule, value: string) {
244 + if (!areAllCustomerFieldsFilled.value) {
245 + return new Error(`Please fill all customer fields`)
246 + }
247 +
248 + if (!areAllCustomerFieldsUniques.value) {
249 + return new Error(`There are duplicated fields`)
250 + }
251 +
252 + if (!value.length || !isCustomerCodePresent.value) {
253 + return new Error(`At least one custom field with name CUSTOMER_CODE is required`)
254 + }
255 + return true
256 + },
257 + trigger: ["input", "blur"]
258 + }
259 +}
260 +
261 +const areAllCustomerFieldsFilled = computed(() => {
262 + const fieldsFilled = form.value.custom_fields.filter(o => !!o.name && !!o.value)
263 +
264 + return fieldsFilled.length === form.value.custom_fields.length
265 +})
266 +
267 +const areAllCustomerFieldsUniques = computed(() => {
268 + const fieldsFilled = form.value.custom_fields.filter(o => !!o.name).map(o => o.name)
269 +
270 + const uniques: string[] = fieldsFilled.filter((value, index, self) => self.indexOf(value) === index)
271 +
272 + return uniques.length === form.value.custom_fields.length
273 +})
274 +
275 +const isCustomerCodePresent = computed(() => {
276 + const field = form.value.custom_fields.filter(o => o.name === "CUSTOMER_CODE" && !!o.value)
277 +
278 + return !!field.length
279 +})
280 +
281 +const isValid = computed(() => {
282 + if (!areAllCustomerFieldsFilled.value) {
283 + return false
284 + }
285 +
286 + if (!isCustomerCodePresent.value) {
287 + return false
288 + }
289 +
290 + if (!areAllCustomerFieldsUniques.value) {
291 + return false
292 + }
293 +
294 + let valid = true
295 +
296 + for (const key in rules) {
297 + const rule = rules[key] as FormRules
298 +
299 + if (rule.required && !_trim(_get(form.value, key))) {
300 + valid = false
301 + }
302 + }
303 +
304 + return valid
305 +})
306 +
307 +function validatorNumber(fieldName: string, defaultMessage?: string) {
308 + return (rule: FormItemRule, value: string) => {
309 + if (!value) {
310 + return new Error(defaultMessage || `${fieldName} is required`)
311 + } else if (!/^\d*$/.test(value)) {
312 + return new Error(`${fieldName} should be an integer`)
313 + } else if (Number(value) < 1) {
314 + return new Error(`${fieldName} should be above 1`)
315 + }
316 + return true
317 + }
318 +}
319 +
320 +let validationMessage: MessageReactive | null = null
321 +
322 +function validate(cb?: () => void) {
323 + if (!formRef.value) return
324 +
325 + formRef.value.validate((errors?: Array<FormValidationError>) => {
326 + if (!errors) {
327 + validationMessage?.destroy()
328 + validationMessage = null
329 + if (cb) cb()
330 + } else {
331 + if (!validationMessage) {
332 + validationMessage = message.warning("You must fill in the required fields correctly.")
333 + }
334 + return false
335 + }
336 + })
337 +}
338 +
339 +function addCustomFiled() {
340 + form.value.custom_fields.push({
341 + name: "",
342 + value: "",
343 + key: new Date().getTime()
344 + })
345 +}
346 +
347 +function removeCustomFiled(key: number) {
348 + form.value.custom_fields = form.value.custom_fields.filter(o => o.key !== key)
349 +}
350 +
351 +function getClearForm(): CustomProvisionForm {
352 + return {
353 + alert_name: "",
354 + alert_description: "",
355 + alert_priority: null,
356 + search_query: "",
357 + custom_fields: [],
358 + search_within_seconds: 1,
359 + execute_every_seconds: 1
360 + }
361 +}
362 +
363 +function reset() {
364 + if (!loading.value) {
365 + resetForm()
366 + formRef.value?.restoreValidation()
367 + }
368 +}
369 +
370 +function resetForm() {
371 + form.value = getClearForm()
372 +}
373 +
374 +function submit() {
375 + submittingCustomAlert.value = true
376 +
377 + const payload: CustomProvisionPayload = {
378 + alert_name: _trim(form.value.alert_name),
379 + alert_description: _trim(form.value.alert_description),
380 + alert_priority: form.value.alert_priority as CustomProvisionPriority,
381 + search_query: _trim(form.value.search_query),
382 + custom_fields: form.value.custom_fields,
383 + search_within_ms: _toSafeInteger(form.value.search_within_seconds) * 1000,
384 + execute_every_ms: _toSafeInteger(form.value.execute_every_seconds) * 1000,
385 + streams: []
386 + }
387 +
388 + Api.monitoringAlerts
389 + .customProvision(payload)
390 + .then(res => {
391 + if (res.data.success) {
392 + message.success(
393 + res.data?.message || `Monitoring alert "${payload.alert_name}" provisioned successfully`
394 + )
395 + resetForm()
396 + } else {
397 + message.warning(res.data?.message || "An error occurred. Please try again later.")
398 + }
399 + })
400 + .catch(err => {
401 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
402 + })
403 + .finally(() => {
404 + submittingCustomAlert.value = false
405 + })
406 +}
407 +
408 +watch(loading, val => {
409 + emit("update:loading", val)
410 +})
411 +
412 +onMounted(() => {
413 + emit("mounted", {
414 + reset
415 + })
416 +})
417 +</script>
418 +
419 +<style lang="scss" scoped>
420 +.custom-alert-form {
421 + .custom-fields-editor {
422 + width: 100%;
423 +
424 + .custom-fields-list {
425 + width: 100%;
426 +
427 + .n-card {
428 + background-color: var(--bg-secondary-color);
429 + }
430 +
431 + .custom-field-box {
432 + width: 100%;
433 + }
434 + }
435 + }
436 +}
437 +</style>
frontend/src/components/graylog/MonitoringAlerts/List.vue
+2
@@ -23,6 +23,7 @@
23 </div>
24 </div>
25 </n-popover>
26 + <CustomAlertButton />
27 </div>
28 <n-pagination
29 v-model:page="currentPage"
@@ -68,6 +69,7 @@ import { ref, onBeforeMount, computed } from "vue"
69 import { useMessage, NSpin, NPopover, NButton, NEmpty, NPagination } from "naive-ui"
70 import Api from "@/api"
71 import MonitoringAlert from "./Item.vue"
72 +import CustomAlertButton from "./CustomAlertButton.vue"
73 import type { AvailableMonitoringAlert } from "@/types/monitoringAlerts"
74 import Icon from "@/components/common/Icon.vue"
75 import type { EventDefinition } from "@/types/graylog/event-definition"
frontend/src/components/soc/SocAlerts/SocAlertAssetsItem.vue
+3 -3
@@ -13,7 +13,7 @@
13 <div class="content">
14 <div class="title">{{ asset.asset_name }}</div>
15 <div class="description mt-2" v-if="asset.asset_description">
16 - <template v-if="isUrl(asset.asset_description)">
16 + <template v-if="isUrlLike(asset.asset_description)">
17 <a
18 :href="asset.asset_description"
19 class="asset-url"
@@ -101,7 +101,7 @@
101 </n-tab-pane>
102 <n-tab-pane name="Description" tab="Description" display-directive="show">
103 <div class="p-7 pt-4">
104 - <template v-if="isUrl(asset.asset_description)">
104 + <template v-if="isUrlLike(asset.asset_description)">
105 <a
106 :href="asset.asset_description"
107 class="asset-url"
@@ -130,7 +130,7 @@ import { computed, ref } from "vue"
130 import { NModal, NTabs, NTabPane } from "naive-ui"
131 import _omit from "lodash/omit"
132 import dayjs from "@/utils/dayjs"
133 -import { isUrl } from "@/utils"
133 +import { isUrlLike } from "@/utils"
134 import type { SocAlertAsset } from "@/types/soc/asset.d"
135 import { useRouter } from "vue-router"
136 import { useSettingsStore } from "@/stores/settings"
frontend/src/components/soc/SocCases/SocCaseAssetsItem.vue
+3 -3
@@ -13,7 +13,7 @@
13 <div class="content">
14 <div class="title" v-html="asset.asset_name"></div>
15 <div class="description mt-2" v-if="asset.asset_description">
16 - <template v-if="isUrl(asset.asset_description)">
16 + <template v-if="isUrlLike(asset.asset_description)">
17 <a
18 :href="asset.asset_description"
19 class="asset-url"
@@ -70,7 +70,7 @@
70 </n-tab-pane>
71 <n-tab-pane name="Description" tab="Description" display-directive="show">
72 <div class="p-7 pt-4">
73 - <template v-if="isUrl(asset.asset_description)">
73 + <template v-if="isUrlLike(asset.asset_description)">
74 <a
75 :href="asset.asset_description"
76 class="asset-url"
@@ -110,7 +110,7 @@ import Badge from "@/components/common/Badge.vue"
110 import SocCaseAssetLink from "./SocCaseAssetLink.vue"
111 import { computed, ref } from "vue"
112 import { NModal, NTabs, NTabPane, NEmpty } from "naive-ui"
113 -import { isUrl } from "@/utils"
113 +import { isUrlLike } from "@/utils"
114 import _omit from "lodash/omit"
115 import _split from "lodash/split"
116 import _upperFirst from "lodash/upperFirst"
frontend/src/layouts/common/Toolbar/Breadcrumb.vue
+9 -4
@@ -60,15 +60,20 @@ function checkRoute(route: RouteLocationNormalizedLoaded) {
60
61 for (const chunk of pathChunks) {
62 if (chunk) {
63 + const name = _capitalize(_upperCase(chunk))
64 + const path = chunk.toLowerCase()
65 +
66 newItems.push({
64 - name: _capitalize(_upperCase(chunk)),
65 - path: chunk.toLowerCase(),
66 - key: chunk + new Date().getTime()
67 + name,
68 + path,
69 + key: name + path
70 })
71 }
72 }
73
71 - items.value = newItems
74 + if (JSON.stringify(items.value) !== JSON.stringify(newItems)) {
75 + items.value = newItems
76 + }
77 }
78
79 onBeforeMount(() => {
frontend/src/utils/index.ts
+1 -1
@@ -28,7 +28,7 @@ export const isMobile = () => {
28 return detectMobile
29 }
30
31 -export const isUrl = (text: string) => {
31 +export const isUrlLike = (text: string) => {
32 const urlPattern = new RegExp("^(https?:\\/\\/)", "i")
33 return urlPattern.test(text)
34 }