Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alert System #307

Merged
merged 15 commits into from
Apr 8, 2024
Merged
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- HackTricks integration to link findings to interesing wiki resources (https://github.com/pablosnt/rekono/issues/271)
- Creation of reports in JSON, XML and PDF formats (https://github.com/pablosnt/rekono/issues/273)
- Customization of HTTP headers sent by tools (https://github.com/pablosnt/rekono/issues/297)
- Multi Factor Authentication (MFA) (https://github.com/pablosnt/rekono/issues/303)
- Customization of alerts to be notified when a finding under certain circumstances has been found (https://github.com/pablosnt/rekono/issues/301)
- CVE Crowd integration to identify trending CVEs on social networks (https://github.com/pablosnt/rekono/issues/301)

### Security

Expand Down
3 changes: 3 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ tools:
directory: /opt/spring4shell-scan
reports:
pdf-template: null
monitor:
cvecrowd:
hour: 0
Empty file.
6 changes: 6 additions & 0 deletions src/backend/alerts/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from alerts.models import Alert
from django.contrib import admin

# Register your models here.

admin.site.register(Alert)
16 changes: 16 additions & 0 deletions src/backend/alerts/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from pathlib import Path
from typing import Any, List

from django.apps import AppConfig
from framework.apps import BaseApp


class AlertsConfig(BaseApp, AppConfig):
name = "alerts"
fixtures_path = Path(__file__).resolve().parent / "fixtures"
skip_if_model_exists = True

def _get_models(self) -> List[Any]:
from alerts.models import MonitorSettings

return [MonitorSettings]
18 changes: 18 additions & 0 deletions src/backend/alerts/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.db.models import TextChoices


class AlertItem(TextChoices):
OSINT = "OSINT"
HOST = "Host"
OPEN_PORT = "Open Port"
SERVICE = "Service"
TECHNOLOGY = "Technology"
CREDENTIAL = "Credential"
VULNERABILITY = "Vulnerability"
CVE = "CVE"


class AlertMode(TextChoices):
NEW = "New"
FILTER = "Filter"
MONITOR = "Monitor"
16 changes: 16 additions & 0 deletions src/backend/alerts/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from alerts.models import Alert
from django_filters.rest_framework import FilterSet


class AlertFilter(FilterSet):
class Meta:
model = Alert
fields = {
"project": ["exact"],
"item": ["exact"],
"mode": ["exact"],
"value": ["exact", "icontains"],
"enabled": ["exact"],
"owner": ["exact"],
"suscribers": ["exact"],
}
11 changes: 11 additions & 0 deletions src/backend/alerts/fixtures/1_default.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[
{
"model": "alerts.monitorsettings",
"pk": 1,
"fields": {
"rq_job_id": null,
"last_monitor": null,
"hour_span": 24
}
}
]
1 change: 1 addition & 0 deletions src/backend/alerts/management/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Management commands."""
1 change: 1 addition & 0 deletions src/backend/alerts/management/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Management commands."""
11 changes: 11 additions & 0 deletions src/backend/alerts/management/commands/monitor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from typing import Any

from alerts.queues import MonitorQueue
from django.core.management.base import BaseCommand


class Command(BaseCommand):
help = "Trigger monitor system"

def handle(self, *args: Any, **options: Any) -> None:
MonitorQueue().enqueue()
141 changes: 141 additions & 0 deletions src/backend/alerts/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
from alerts.enums import AlertItem, AlertMode
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from executions.models import Execution
from findings.enums import PortStatus, TriageStatus
from findings.models import (
OSINT,
Credential,
Finding,
Host,
Port,
Technology,
Vulnerability,
)
from framework.models import BaseModel
from projects.models import Project
from rekono.settings import AUTH_USER_MODEL
from security.validators.input_validator import Regex, Validator

# Create your models here.


class Alert(BaseModel):
project = models.ForeignKey(
Project, related_name="alerts", on_delete=models.CASCADE
)
item = models.TextField(max_length=15, choices=AlertItem.choices)
mode = models.TextField(
max_length=7, choices=AlertMode.choices, default=AlertMode.NEW
)
value = models.TextField(
max_length=100,
validators=[Validator(Regex.NAME.value, code="filter_value")],
blank=True,
null=True,
)
enabled = models.BooleanField(default=True)
suscribe_all_members = models.BooleanField(default=False)
owner = models.ForeignKey(
AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True
)
suscribers = models.ManyToManyField(
AUTH_USER_MODEL, related_name="alerts", blank=True
)

class Meta:
constraints = [
models.UniqueConstraint(
fields=["project", "item", "mode", "value"],
name="unique_alerts_1",
condition=models.Q(value__isnull=False),
),
models.UniqueConstraint(
fields=["project", "item", "mode"],
name="unique_alerts_2",
condition=models.Q(value__isnull=True),
),
]

mapping = {
AlertItem.OSINT.value: {"model": OSINT, AlertMode.NEW.value: True},
AlertItem.HOST.value: {
"model": Host,
AlertMode.NEW.value: True,
AlertMode.FILTER.value: "address",
},
AlertItem.OPEN_PORT.value: {
"model": Port,
"filter": lambda f: f.status == PortStatus.OPEN,
AlertMode.NEW.value: True,
},
AlertItem.SERVICE.value: {
"model": Port,
"filter": lambda f: f.service is not None,
AlertMode.NEW.value: True,
AlertMode.FILTER.value: "service",
},
AlertItem.TECHNOLOGY.value: {
"model": Technology,
AlertMode.NEW.value: True,
AlertMode.FILTER.value: "name",
},
AlertItem.CREDENTIAL.value: {"model": Credential, AlertMode.NEW.value: True},
AlertItem.VULNERABILITY.value: {
"model": Vulnerability,
AlertMode.NEW.value: True,
},
AlertItem.CVE.value: {
"model": Vulnerability,
"filter": lambda f: f.cve is not None,
AlertMode.NEW.value: True,
AlertMode.FILTER.value: "cve",
AlertMode.MONITOR.value: "trending",
},
}

def __str__(self) -> str:
values = [self.project.__str__(), self.mode, self.item]
if self.value:
values.append(self.value)
return " - ".join(values)

@classmethod
def get_project_field(cls) -> str:
return "project"

def must_be_triggered(self, execution: Execution, finding: Finding) -> bool:
data = self.mapping[self.item]
if (
not isinstance(finding, data["model"])
or finding.is_fixed
or (
hasattr(finding, "triage_status")
and finding.triage_status == TriageStatus.FALSE_POSITIVE
)
or not data.get(self.mode)
or (data.get("filter") and not data["filter"](finding))
):
return False
if self.mode == AlertMode.NEW.value:
return not finding.executions.exclude(id=execution.id).exists()
elif self.mode == AlertMode.FILTER.value:
return (
getattr(finding, data.get(AlertMode.FILTER.value, "").lower())
== self.value.lower()
)
else:
return (
getattr(finding, data.get(AlertMode.MONITOR.value, "").lower()) is True
)


class MonitorSettings(BaseModel):
rq_job_id = models.TextField(max_length=50, blank=True, null=True)
last_monitor = models.DateTimeField(blank=True, null=True)
hour_span = models.IntegerField(
default=7, validators=[MinValueValidator(24), MaxValueValidator(168)]
)

def __str__(self) -> str:
return f"Last monitor was at {self.last_monitor}. Next one in {self.hour_span} hours"
49 changes: 49 additions & 0 deletions src/backend/alerts/queues.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import logging
from datetime import timedelta
from typing import Any

from alerts.models import MonitorSettings
from django.utils import timezone
from django_rq import job
from framework.queues import BaseQueue
from platforms.cvecrowd.integrations import CVECrowd
from rq.job import Job

logger = logging.getLogger()


class MonitorQueue(BaseQueue):
name = "monitor"

def enqueue(self, **kwargs: Any) -> Job:
settings = MonitorSettings.objects.first()
job = self._get_queue().enqueue(
self.consume, on_success=self._scheduled_callback
)
settings.rq_job_id = job.id
settings.save(update_fields=["rq_job_id"])
return job

@staticmethod
@job("monitor")
def consume() -> None:
logger.info("[Monitor] Monitor job has started")
settings = MonitorSettings.objects.first()
settings.last_monitor = timezone.now()
settings.save(update_fields=["last_monitor"])
for platform in [CVECrowd()]:
platform.monitor()

@staticmethod
def _scheduled_callback(
job: Any, connection: Any, *args: Any, **kwargs: Any
) -> None:
settings = MonitorSettings.objects.first()
instance = MonitorQueue()
job = instance._get_queue().enqueue_at(
settings.last_monitor + timedelta(hours=settings.hour_span),
instance.consume,
on_success=instance._scheduled_callback,
)
settings.rq_job_id = job.id
settings.save(update_fields=["rq_job_id"])
86 changes: 86 additions & 0 deletions src/backend/alerts/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from typing import Any, Dict

from alerts.enums import AlertMode
from alerts.models import Alert, MonitorSettings
from django.core.exceptions import ValidationError
from django.db import transaction
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from users.serializers import SimpleUserSerializer


class AlertSerializer(ModelSerializer):
suscribed = SerializerMethodField(read_only=True)
owner = SimpleUserSerializer(many=False, read_only=True)

class Meta:
model = Alert
fields = (
"id",
"project",
"item",
"mode",
"value",
"enabled",
"owner",
"suscribed",
"suscribers",
"suscribe_all_members",
)
read_only_fields = ("id", "suscribed", "enabled", "owner", "suscribers")
extra_kwargs = {"suscribe_all_members": {"write_only": True}}

def get_suscribed(self, instance: Any) -> bool:
return instance.suscribers.filter(
pk=self.context.get("request").user.id
).exists()

def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
attrs = super().validate(attrs)
if attrs.get("mode") == AlertMode.FILTER and not attrs.get("value"):
raise ValidationError(
"Value is required when the alert mode is 'filter'", code="value"
)
attrs["enabled"] = True
return attrs

@transaction.atomic()
def create(self, validated_data: Dict[str, Any]) -> Alert:
alert = super().create(validated_data)
if alert.suscribe_all_members:
alert.suscribers.set(alert.project.members.all())
else:
alert.suscribers.add(alert.owner)
return alert


class EditAlertSerializer(AlertSerializer):
class Meta:
model = Alert
fields = (
"id",
"project",
"item",
"mode",
"value",
"enabled",
"owner",
"suscribed",
"suscribers",
)
read_only_fields = (
"id",
"project",
"item",
"mode",
"enabled",
"owner",
"suscribed",
"suscribers",
)


class MonitorSettingsSerializer(ModelSerializer):
class Meta:
model = MonitorSettings
fields = ("id", "last_monitor", "hour_span")
read_only_fields = ("id", "last_monitor")
8 changes: 8 additions & 0 deletions src/backend/alerts/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from alerts.views import AlertViewSet, MonitorSettingsViewSet
from rest_framework.routers import SimpleRouter

router = SimpleRouter()
router.register("alerts", AlertViewSet)
router.register("monitor", MonitorSettingsViewSet)

urlpatterns = router.urls
Loading
Loading