From ddbe1585cfc5b96df9aeb57355b27719120c90d8 Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Mon, 26 Jan 2026 11:06:15 +0100 Subject: [PATCH 01/19] hot fixes : changed order in function parameters --- .../scoring/cortex_analyzers/analyzers_services/ai/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Suspicious/Suspicious/score_process/scoring/cortex_analyzers/analyzers_services/ai/service.py b/Suspicious/Suspicious/score_process/scoring/cortex_analyzers/analyzers_services/ai/service.py index 7c7130c2..e1ca1a18 100644 --- a/Suspicious/Suspicious/score_process/scoring/cortex_analyzers/analyzers_services/ai/service.py +++ b/Suspicious/Suspicious/score_process/scoring/cortex_analyzers/analyzers_services/ai/service.py @@ -205,10 +205,10 @@ def process(self): item_type = "alert" update_suspicious_collection( - suspicious_collection, phishing_campaign, alert_id, item["sourceRef"], + suspicious_collection, ) # ---------------------- From 818c1f534d8619a0029f3104ce798b7b6fe1c028 Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Tue, 27 Jan 2026 10:45:20 +0100 Subject: [PATCH 02/19] Testing new backend using the already existing minio --- Suspicious/Dockerfile | 1 + .../email_handler/email_handler.py | 48 ++++++++++++++ Suspicious/Suspicious/mail_feeder/models.py | 1 + .../utils/email_preview/eml2png_renderer.py | 61 +++++++++++++++++ Suspicious/Suspicious/storage_backends.py | 66 +++++++++++++++++++ Suspicious/Suspicious/suspicious/settings.py | 41 +++++++++--- Suspicious/requirements.txt | 1 + Suspicious/settings-sample.json | 8 ++- 8 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 Suspicious/Suspicious/mail_feeder/utils/email_preview/eml2png_renderer.py create mode 100644 Suspicious/Suspicious/storage_backends.py diff --git a/Suspicious/Dockerfile b/Suspicious/Dockerfile index 9efbda6b..198a43d3 100644 --- a/Suspicious/Dockerfile +++ b/Suspicious/Dockerfile @@ -89,6 +89,7 @@ RUN apt-get update && \ RUN apt-get update && apt-get install -y --no-install-recommends \ libldap-common \ libsasl2-2 \ + wkhtmltopdf \ libmariadb3 \ libmagic1 \ cron \ diff --git a/Suspicious/Suspicious/mail_feeder/email_handler/email_handler.py b/Suspicious/Suspicious/mail_feeder/email_handler/email_handler.py index a5d80a68..57f0c5a8 100644 --- a/Suspicious/Suspicious/mail_feeder/email_handler/email_handler.py +++ b/Suspicious/Suspicious/mail_feeder/email_handler/email_handler.py @@ -7,6 +7,9 @@ from mail_feeder.utils.process_em_header.email_header import EmailHeaderService from .models import EmailDataModel from .utils import safe_operation, increment_field +from pathlib import Path +from mail_feeder.utils.mail_preview.eml2png_renderer import Eml2PngRenderer +import os fetch_mail_logger = logging.getLogger("tasp.cron.fetch_and_process_emails") @@ -21,6 +24,7 @@ def __init__(self): self.body_service = EmailBodyService() self.header_service = EmailHeaderService() self.observables_service = EmailObservablesService() + self.preview_renderer = Eml2PngRenderer() def handle_mail(self, email_data: dict, workdir: str) -> Optional[Mail]: """ @@ -59,6 +63,9 @@ def _handle_new_mail(self, data: EmailDataModel, workdir: str) -> Optional[Mail] return None mail_instance = self._save_and_update_mail(mail_instance, data) + + self._generate_mail_preview_png(mail_instance, data, workdir) + self._process_rich_observables(mail_instance, data, workdir) self._update_times_sent(mail_instance) self._save_mail(mail_instance) @@ -79,6 +86,7 @@ def _handle_existing_mail( mail_instance, data, email_body_list, email_header_list ) fetch_mail_logger.debug(f"Updated existing mail: {mail_instance.mail_id}") + self._update_times_sent(mail_instance) self._save_mail(mail_instance) return mail_instance @@ -91,6 +99,46 @@ def _check_existing_data(self, data: EmailDataModel) -> Tuple[List[Mail], list, email_header_list = self.header_service.check_email_headers(data.headers) if data.headers else [] return email_list, email_body_list, email_header_list + def _generate_mail_preview_png(self, mail_instance: Mail, data: EmailDataModel, workdir: str) -> None: + """ + PNG preview generation. + """ + with safe_operation("generate_mail_preview_png"): + try: + email_path = self._resolve_file_path( + filename=str(data.id), + workdir=workdir, + ) + except FileNotFoundError: + fetch_mail_logger.debug( + "No email file found for preview (mail_id=%s, workdir=%s)", + mail_instance.mail_id, + workdir, + ) + return + + png_bytes = self.preview_renderer.render_eml_path_to_png_bytes( + Path(email_path) + ) + if not png_bytes: + return + + self.preview_renderer.save_preview_to_mail(mail_instance, png_bytes) + + def _resolve_file_path(self, filename: str, workdir: str) -> str: + for ext in [".eml", ".msg"]: + path = os.path.join(workdir, f"{filename}{ext}") + if os.path.exists(path): + return path + + for alt in ["user_submission.eml", "user_submission.msg"]: + path = os.path.join(workdir, alt) + if os.path.exists(path): + return path + + raise FileNotFoundError(f"No email file found for {filename} in {workdir}") + + def _save_and_update_mail(self, mail_instance: Mail, data: EmailDataModel) -> Mail: """ Saves the base Mail instance and attaches body and header. diff --git a/Suspicious/Suspicious/mail_feeder/models.py b/Suspicious/Suspicious/mail_feeder/models.py index 94ea12c8..30b4207a 100644 --- a/Suspicious/Suspicious/mail_feeder/models.py +++ b/Suspicious/Suspicious/mail_feeder/models.py @@ -11,6 +11,7 @@ class Mail(models.Model): subject = models.CharField(max_length=255, db_index=True) reportedBy = models.CharField(max_length=255, db_index=True) + preview_png = models.ImageField(upload_to="mail_previews/", null=True, blank=True) mail_header = models.ForeignKey( 'MailHeader', on_delete=models.CASCADE, related_name='mails', null=True, blank=True, db_index=True diff --git a/Suspicious/Suspicious/mail_feeder/utils/email_preview/eml2png_renderer.py b/Suspicious/Suspicious/mail_feeder/utils/email_preview/eml2png_renderer.py new file mode 100644 index 00000000..a9e5010a --- /dev/null +++ b/Suspicious/Suspicious/mail_feeder/utils/email_preview/eml2png_renderer.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import logging +import subprocess +from pathlib import Path +from typing import Optional + +from django.core.files.base import ContentFile + +logger = logging.getLogger(__name__) + + +class Eml2PngRenderer: + """ + Renders an .eml file to a PNG using the `eml2png` CLI. + Requires: eml2png + wkhtmltopdf installed in the runtime image. + """ + + def __init__(self, cli: str = "eml2png"): + self.cli = cli + + def render_eml_path_to_png_bytes(self, eml_path: Path) -> Optional[bytes]: + if not eml_path.exists(): + logger.warning("EML file not found: %s", eml_path) + return None + + # eml2png works via output file + out_path = eml_path.with_suffix(".preview.png") + + try: + subprocess.run( + [self.cli, str(eml_path), "-o", str(out_path)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + except FileNotFoundError: + logger.error("eml2png CLI not found (is it installed in the container?)") + return None + except subprocess.CalledProcessError as e: + logger.error("eml2png failed: %s", e.stderr.decode(errors="replace")) + return None + + try: + data = out_path.read_bytes() + except OSError as e: + logger.error("Cannot read rendered PNG: %s", e) + return None + finally: + # best-effort cleanup + try: + out_path.unlink(missing_ok=True) # Python 3.8+: missing_ok exists + except Exception: + pass + + return data + + def save_preview_to_mail(self, mail, png_bytes: bytes) -> None: + # Storage-aware: uses DEFAULT_FILE_STORAGE (local/MinIO/dual) + mail.preview_png.save("preview.png", ContentFile(png_bytes), save=False) + mail.save(update_fields=["preview_png"]) diff --git a/Suspicious/Suspicious/storage_backends.py b/Suspicious/Suspicious/storage_backends.py new file mode 100644 index 00000000..9951db43 --- /dev/null +++ b/Suspicious/Suspicious/storage_backends.py @@ -0,0 +1,66 @@ +# suspicious/storage_backends.py +from __future__ import annotations + +from django.conf import settings +from django.core.files.storage import FileSystemStorage, Storage + +try: + from minio_storage.storage import MinioMediaStorage +except Exception: # pragma: no cover + MinioMediaStorage = None # type: ignore + + +def _bool(val: str | bool | None, default: bool = False) -> bool: + if val is None: + return default + if isinstance(val, bool): + return val + return val.strip().lower() in {"1", "true", "yes", "on"} + + +class DualStorage(Storage): + """ + Primary = MinIO, Secondary = local filesystem. + - Save: always to primary; optionally also to secondary (dual write). + - Open/exists: prefer primary, fallback to secondary for backward compatibility. + """ + + def __init__(self, *args, **kwargs): + if MinioMediaStorage is None: + raise RuntimeError("minio_storage is required for DualStorage") + + self.primary = MinioMediaStorage() + self.secondary = FileSystemStorage(location=settings.MEDIA_ROOT, base_url=settings.MEDIA_URL) + + self.dual_write = _bool(getattr(settings, "SUSPICIOUS_STORAGE_DUAL_WRITE", False), default=False) + + def _open(self, name, mode="rb"): + if self.primary.exists(name): + return self.primary.open(name, mode) + return self.secondary.open(name, mode) + + def _save(self, name, content): + saved = self.primary.save(name, content) + if self.dual_write: + # best effort: store also locally (useful during transition) + try: + content.seek(0) + self.secondary.save(saved, content) + except Exception: + pass + return saved + + def delete(self, name): + # best effort delete both + if self.primary.exists(name): + self.primary.delete(name) + if self.secondary.exists(name): + self.secondary.delete(name) + + def exists(self, name): + return self.primary.exists(name) or self.secondary.exists(name) + + def url(self, name): + if self.primary.exists(name): + return self.primary.url(name) + return self.secondary.url(name) diff --git a/Suspicious/Suspicious/suspicious/settings.py b/Suspicious/Suspicious/suspicious/settings.py index e544ea07..195b2f27 100644 --- a/Suspicious/Suspicious/suspicious/settings.py +++ b/Suspicious/Suspicious/suspicious/settings.py @@ -22,6 +22,7 @@ ldap_config = config.get('ldap', {}) suspicious_config = config.get('suspicious', {}) +minio_config = config.get('minio', {}) cortex_config = config.get('cortex', {}) db_config = config.get('database', {}) @@ -207,8 +208,6 @@ SITE_ID = 1 -SITE_ID = 1 - ACCOUNT_EMAIL_VERIFICATION = "none" ACCOUNT_AUTHENTICATION_METHOD = "username_email" ACCOUNT_UNIQUE_EMAIL = True @@ -272,6 +271,27 @@ 'django_filters', ] +# --- Storage backend selection (default: local filesystem) --- +# Values: local | minio | dual +SUSPICIOUS_STORAGE_BACKEND = suspicious_config.get("storage_backend", "local").lower() + +# MinIO (django-minio-storage) settings (env first, then settings.json) +MINIO_STORAGE_ENDPOINT = minio_config.get("endpoint", "minio:9000") +MINIO_STORAGE_ACCESS_KEY = minio_config.get("access_key", "") +MINIO_STORAGE_SECRET_KEY = minio_config.get("secret_key", "") +MINIO_STORAGE_USE_HTTPS = str(minio_config.get("secure", "0")).lower() in {"1", "true", "yes", "on"} + +MINIO_STORAGE_MEDIA_BUCKET_NAME = suspicious_config.get("minio_media_bucket", "suspicious-media") +MINIO_STORAGE_AUTO_CREATE_MEDIA_BUCKET = bool(minio_config.get("minio_auto_create_bucket", True)) + +# Dual mode option: also write a local copy +SUSPICIOUS_STORAGE_DUAL_WRITE = str(suspicious_config.get("storage_dual_write", "0")).lower() in {"1", "true", "yes", "on"} + +if SUSPICIOUS_STORAGE_BACKEND in {"minio", "dual"}: + # Only include when enabled + if "minio_storage" not in INSTALLED_APPS: + INSTALLED_APPS.append("minio_storage") + REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ('knox.auth.TokenAuthentication',), @@ -301,7 +321,7 @@ } REST_KNOX = { - 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA3_512', + 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA3_512', 'TOKEN_TTL': timedelta(hours=10), } @@ -340,8 +360,6 @@ ASGI_APPLICATION = 'suspicious.asgi.application' -# Password validation -# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators # Password validation # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ @@ -369,7 +387,6 @@ # Internationalization # https://docs.djangoproject.com/en/4.1/topics/i18n/ - LANGUAGE_CODE = 'en-us' USE_I18N = True USE_L10N = True @@ -384,15 +401,19 @@ # Clé primaire par défaut DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -# Add default storage for file uploads -DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' + +# Default storage for file uploads (Django 4.1) +if SUSPICIOUS_STORAGE_BACKEND == "minio": + DEFAULT_FILE_STORAGE = "minio_storage.storage.MinioMediaStorage" +elif SUSPICIOUS_STORAGE_BACKEND == "dual": + DEFAULT_FILE_STORAGE = "suspicious.storage_backends.DualStorage" +else: + DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage" # Lock jobs to prevent them from running simultaneously CRONTAB_LOCK_JOBS = True # Cron jobs for various tasks -# Each job is run every minute and logs to a specific file in /tmp -# Consider adjusting the frequency of these jobs based on their cost and your needs CRONJOBS = [ ('*/1 * * * *', 'tasp.cron.fetch_emails.fetch_and_process_emails', '>> /app/log/fetched_mail.log'), ('*/1 * * * *', 'tasp.cron.sync_cortex.sync_cortex_analyzers'), diff --git a/Suspicious/requirements.txt b/Suspicious/requirements.txt index d25c2c72..6db61b6a 100644 --- a/Suspicious/requirements.txt +++ b/Suspicious/requirements.txt @@ -20,6 +20,7 @@ tldextract==5.3.1 dkimpy==1.1.8 email-validator==2.3.0 msg_parser==1.2.0 +eml2png==0.0.2 # Natural Language Processing and Similarity Checking nltk==3.9.2 diff --git a/Suspicious/settings-sample.json b/Suspicious/settings-sample.json index ce6da04f..586e5931 100644 --- a/Suspicious/settings-sample.json +++ b/Suspicious/settings-sample.json @@ -17,7 +17,10 @@ "sign": "data:image/png;base64,Base64 text of your company logo", "oidc_server_url": "oicd-server", "oidc_client_id": "oicd-client-id", - "oidc_client_secret": "oidc_client_secret" + "oidc_client_secret": "oidc_client_secret", + "storage_backend": "minio", + "minio_media_bucket": "suspicious-media", + "storage_dual_write" : "False" }, "thehive": { "enabled": false, @@ -87,7 +90,8 @@ "access_key": "the minio access key", "endpoint": "minio:9000", "secret_key": "the minio secret key", - "secure": false + "secure": false, + "minio_auto_create_bucket": true }, "mail": { "tls": true, From f1c3e07bdf56b45214a09c2cb19ec93e25c8acaa Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Tue, 27 Jan 2026 11:00:14 +0100 Subject: [PATCH 03/19] added MailAdress to denylist checks --- .../score_process/scoring/case_score_calculation.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Suspicious/Suspicious/score_process/scoring/case_score_calculation.py b/Suspicious/Suspicious/score_process/scoring/case_score_calculation.py index eccdfb09..2eab4d1e 100644 --- a/Suspicious/Suspicious/score_process/scoring/case_score_calculation.py +++ b/Suspicious/Suspicious/score_process/scoring/case_score_calculation.py @@ -157,6 +157,12 @@ def _check_mail_artifacts_for_deny_list(mail, deny_list: Set[str], logger: loggi domain_obj, url_obj, address = None, None, None source = "Unknown" + if hasattr(artifact, 'artifactIsMailAddress') and artifact.artifactIsMailAddress: + mail_address = getattr(artifact.artifactIsMailAddress, 'mail_address', None) + if mail_address and hasattr(mail_address, 'address'): + address = mail_address.address + source = "Mail Address" + if hasattr(artifact, 'artifactIsDomain') and artifact.artifactIsDomain: domain = getattr(artifact.artifactIsDomain, 'domain', None) if domain and hasattr(domain, 'value'): From c42b1f1b212f820a5a93ee6e08c22fc5906a12ec Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Tue, 27 Jan 2026 18:07:35 +0100 Subject: [PATCH 04/19] added challenge on api --- Suspicious/Suspicious/api/urls.py | 3 + Suspicious/Suspicious/api/views.py | 205 ++++++++++++++++++ Suspicious/Suspicious/case_handler/models.py | 66 ++++++ .../scoring/case_score_calculation.py | 16 +- 4 files changed, 289 insertions(+), 1 deletion(-) diff --git a/Suspicious/Suspicious/api/urls.py b/Suspicious/Suspicious/api/urls.py index e50b1ef2..7d9881ba 100644 --- a/Suspicious/Suspicious/api/urls.py +++ b/Suspicious/Suspicious/api/urls.py @@ -14,6 +14,8 @@ # Downloads DownloadCaseArchiveView, ) +from .views import challenge_case_via_token + from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView urlpatterns = [ path("schema/", SpectacularAPIView.as_view(), name="schema"), @@ -69,4 +71,5 @@ DownloadCaseArchiveView.as_view(), name="case-download", ), + path("api/cases//challenge", challenge_case_via_token, name="case-challenge"), ] diff --git a/Suspicious/Suspicious/api/views.py b/Suspicious/Suspicious/api/views.py index 35a75e3e..9589789a 100644 --- a/Suspicious/Suspicious/api/views.py +++ b/Suspicious/Suspicious/api/views.py @@ -35,6 +35,30 @@ import os import logging from minio.error import S3Error + +from django.conf import settings +from django.db import transaction +from django.shortcuts import get_object_or_404, redirect +from django.utils import timezone + +from rest_framework.permissions import AllowAny + +import hashlib +import requests + +from score_process.score_utils.thehive.challenge import ChallengeToTheHiveService +from score_process.score_utils.send_mail.service import MailNotificationService + +from django.contrib.auth import get_user_model + +from case_handler.models import Case +from api.models import CaseChallengeToken + + +User = get_user_model() + + + # --------------------------------------------------------------------- # Permissions # --------------------------------------------------------------------- @@ -42,6 +66,187 @@ ALLOWED_DOWNLOAD_GROUPS = {"Admin", "CERT"} CONFIG_PATH = os.environ.get("SUSPICIOUS_SETTINGS_PATH", "/app/settings.json") logger = logging.getLogger(__name__) +with open(CONFIG_PATH) as config_file: + config = json.load(config_file) + +thehive_config = config.get("thehive", {}) + +CHALLENGE_REDIRECT_URL = "https://suspicious-domain.com/submissions" + +# Put the real Suspicious API endpoint here (not the GitHub repo URL). +SUSPICIOUS_API_URL = getattr(settings, "SUSPICIOUS_API_URL", "https://github.com/thalesgroup-cert/suspicious") + + +class CaseChallengeOneTimeLinkView(APIView): + """ + GET /api/cases/{case_id}/challenge?token=ONE_TIME_TOKEN + + - No auth required + - Token is single-use, case-bound, expirable + - Always redirects to CHALLENGE_REDIRECT_URL (no validity oracle) + """ + permission_classes = [AllowAny] + authentication_classes = [] # ensure DRF doesn't try to authenticate + + def get(self, request, case_id: int): + raw = request.query_params.get("token") + if not raw: + return redirect(CHALLENGE_REDIRECT_URL) + + # Case existence: you can choose to not reveal this either. + # But we still redirect; no body/status differences. + case = get_object_or_404(Case.objects.select_related("reporter"), pk=case_id) + + token_hash = _hash_raw_token(raw) + now = timezone.now() + + token_obj = None + + # Consume token exactly once (race-safe) + with transaction.atomic(): + token_obj = ( + CaseChallengeToken.objects + .select_for_update() + .filter(case=case, token_hash=token_hash) + .first() + ) + if not token_obj: + return redirect(CHALLENGE_REDIRECT_URL) + + if token_obj.used_at is not None or now >= token_obj.expires_at: + return redirect(CHALLENGE_REDIRECT_URL) + + # Mark used immediately to prevent replay (even if downstream fails) + token_obj.used_at = now + # optional audit fields if present on your model: + if hasattr(token_obj, "used_ip"): + token_obj.used_ip = request.META.get("REMOTE_ADDR") + if hasattr(token_obj, "used_user_agent"): + token_obj.used_user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:2000] + + update_fields = ["used_at"] + if hasattr(token_obj, "used_ip"): + update_fields.append("used_ip") + if hasattr(token_obj, "used_user_agent"): + update_fields.append("used_user_agent") + + token_obj.save(update_fields=update_fields) + + # External call + Case updates (token already consumed => at-most-once) + self._call_suspicious_and_update_case(case=case, token_obj=token_obj) + + # Update stats + notify (best-effort, should not block redirect) + try: + if hasattr(case, "reporter") and case.reporter_id: + _update_case_challenge_stats(case.reporter) + _notify_case_challenge(case, logger) + except Exception: + logger.exception("Challenge notify/stats failed for case %s", case.id) + + return redirect(CHALLENGE_REDIRECT_URL) + + def _call_suspicious_and_update_case(self, *, case: Case, token_obj): + payload = { + "case_id": case.pk, + "action": "challenge", + } + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + + try: + resp = requests.post( + SUSPICIOUS_API_URL, + json=payload, + headers=headers, + timeout=(3.05, 10), + ) + + # optional audit fields + if hasattr(token_obj, "api_status_code"): + token_obj.api_status_code = resp.status_code + + try: + data = resp.json() + except ValueError: + data = {"raw": resp.text[:5000]} + + if hasattr(token_obj, "api_response"): + token_obj.api_response = data + + if 200 <= resp.status_code < 300: + # Required Case state updates + case.is_challenged = True + case.challenged_result = data + case.status = "Challenged" + case.save(update_fields=["is_challenged", "challenged_result", "status"]) + else: + if hasattr(token_obj, "api_error"): + token_obj.api_error = f"Non-2xx from external API: {resp.status_code}" + + except requests.RequestException as e: + if hasattr(token_obj, "api_error"): + token_obj.api_error = f"{e.__class__.__name__}: {str(e)[:2000]}" + finally: + # save token audit best-effort + try: + update_fields = [] + for f in ("api_status_code", "api_response", "api_error"): + if hasattr(token_obj, f): + update_fields.append(f) + if update_fields: + token_obj.save(update_fields=update_fields) + except Exception: + logger.exception("Failed saving challenge token audit for case %s", case.id) + +def _hash_raw_token(raw_token: str) -> str: + """ + SHA256(SECRET_KEY || raw_token) hex digest. + 'Pepper' via SECRET_KEY prevents offline brute force if DB leaks. + """ + h = hashlib.sha256() + h.update((settings.SECRET_KEY + raw_token).encode("utf-8")) + return h.hexdigest() + + +def _update_case_challenge_stats(user): + now = timezone.now() + stats, _ = UserCasesMonthlyStats.objects.get_or_create( + user=user, + month=now.strftime("%m"), + year=now.year, + defaults={"challenged_cases": 0, "total_cases": 0}, + ) + stats.challenged_cases += 1 + stats.save(update_fields=["challenged_cases"]) + + +def _notify_case_challenge(case: Case, logger): + """ + “Notify process by hand” equivalent to your CaseChallengeService.notify(). + Adjust import paths/names to your existing TheHive/email notification services. + """ + send_to_thehive = thehive_config.get("enabled", False) + reporter = getattr(case, "reporter", None) + reporter_name = getattr(reporter, "username", "unknown") + + mail_header = f"Case ID {case.id} challenged by {reporter_name}" + logger.info( + "Notifying about challenge for case ID %s. Send to TheHive: %s", + case.id, send_to_thehive + ) + + if send_to_thehive: + logger.info("Sending challenge notification to TheHive for case ID %s", case.id) + ChallengeToTheHiveService(case, None, mail_header).send_to_thehive() + logger.info("Challenge notification sent to TheHive for case ID %s", case.id) + return + + cert_users = User.objects.filter(groups__name="CERT", is_active=True).exclude(email="") + for cert_user in cert_users: + ChallengeToTheHiveService(case, cert_user, mail_header).send() + class StorageUnavailable(APIException): diff --git a/Suspicious/Suspicious/case_handler/models.py b/Suspicious/Suspicious/case_handler/models.py index 56075d9d..ddd46896 100644 --- a/Suspicious/Suspicious/case_handler/models.py +++ b/Suspicious/Suspicious/case_handler/models.py @@ -8,6 +8,10 @@ from django.utils.translation import gettext_lazy as _ from django.utils import timezone import datetime +from datetime import timedelta +from __future__ import annotations + +import hashlib class Status(models.TextChoices): """ @@ -77,6 +81,68 @@ def was_published_recently(self): now = timezone.now() return now - datetime.timedelta(days=1) <= self.creation_date <= now +class CaseChallengeToken(models.Model): + """ + One-time token for challenging a specific Case. + Store only a hash; the raw token exists only in the emailed URL. + """ + case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="challenge_tokens") + + # SHA-256 hex digest (64 chars) + token_hash = models.CharField(max_length=64, unique=True, db_index=True) + + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField(db_index=True) + + used_at = models.DateTimeField(null=True, blank=True, db_index=True) + used_ip = models.GenericIPAddressField(null=True, blank=True) + used_user_agent = models.TextField(null=True, blank=True) + + # Optional but useful for auditability/debugging: + api_status_code = models.IntegerField(null=True, blank=True) + api_response = models.JSONField(null=True, blank=True) + api_error = models.TextField(null=True, blank=True) + + class Meta: + indexes = [ + models.Index(fields=["case", "expires_at"]), + models.Index(fields=["case", "used_at"]), + ] + + @property + def is_used(self) -> bool: + return self.used_at is not None + + @property + def is_expired(self) -> bool: + return timezone.now() >= self.expires_at + + +def _token_pepper() -> str: + return settings.SECRET_KEY + + +def hash_raw_token(raw_token: str) -> str: + # SHA256(pepper || raw_token) + h = hashlib.sha256() + h.update((_token_pepper() + raw_token).encode("utf-8")) + return h.hexdigest() + + +def create_case_challenge_token(*, case: Case, ttl: timedelta = timedelta(hours=24)) -> str: + """ + Returns the raw token to embed in the email link. + The DB stores only the hash. + """ + import secrets + + raw = secrets.token_urlsafe(32) # ~256 bits of entropy + CaseChallengeToken.objects.create( + case=case, + token_hash=hash_raw_token(raw), + expires_at=timezone.now() + ttl, + ) + return raw class CaseHasFileOrMail(models.Model): """ diff --git a/Suspicious/Suspicious/score_process/scoring/case_score_calculation.py b/Suspicious/Suspicious/score_process/scoring/case_score_calculation.py index 2eab4d1e..55604873 100644 --- a/Suspicious/Suspicious/score_process/scoring/case_score_calculation.py +++ b/Suspicious/Suspicious/score_process/scoring/case_score_calculation.py @@ -3,6 +3,7 @@ from typing import List, Set, Optional, Type, Union from urllib.parse import urlparse +from email.utils import parseaddr # Models from settings.models import DenyListDomain, CampaignDomainAllowList @@ -109,6 +110,19 @@ def _extract_domain_from_url(url_string: str, logger: logging.Logger) -> Optiona logger.error(f"Failed to parse URL '{url_string}': {e}") return None +def _extract_domain_from_email_address( + address: str, + logger: logging.Logger, +) -> Optional[str]: + try: + _, email_addr = parseaddr(address) + if "@" not in email_addr: + return None + return email_addr.rsplit("@", 1)[1].lower() + except Exception as e: + logger.error(f"Failed to parse email address '{address}': {e}") + return None + def _update_ioc(obj, logger, label: str): for field in ["ioc_score", "ioc_confidence", "ioc_level"]: if not hasattr(obj, field): @@ -160,7 +174,7 @@ def _check_mail_artifacts_for_deny_list(mail, deny_list: Set[str], logger: loggi if hasattr(artifact, 'artifactIsMailAddress') and artifact.artifactIsMailAddress: mail_address = getattr(artifact.artifactIsMailAddress, 'mail_address', None) if mail_address and hasattr(mail_address, 'address'): - address = mail_address.address + address = _extract_domain_from_email_address(mail_address.address, logger) source = "Mail Address" if hasattr(artifact, 'artifactIsDomain') and artifact.artifactIsDomain: From f04dde81ed53dd7a5b1cbf7c05923b762e2aca87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Bhang?= <122457543+TheoBhang@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:03:24 +0100 Subject: [PATCH 05/19] Add one-time challenge tokens for cases --- Suspicious/Suspicious/api/urls.py | 6 ++ Suspicious/Suspicious/api/views.py | 49 ++++++++++- .../migrations/0007_casechallengetoken.py | 29 +++++++ Suspicious/Suspicious/case_handler/models.py | 60 ++++++++++++++ .../score_utils/send_mail/final_service.py | 18 +++- .../Suspicious/tasp/services/challenge.py | 83 +++++++++++++++++++ Suspicious/Suspicious/tasp/views.py | 51 +----------- 7 files changed, 242 insertions(+), 54 deletions(-) create mode 100644 Suspicious/Suspicious/case_handler/migrations/0007_casechallengetoken.py create mode 100644 Suspicious/Suspicious/tasp/services/challenge.py diff --git a/Suspicious/Suspicious/api/urls.py b/Suspicious/Suspicious/api/urls.py index e50b1ef2..6d32ec70 100644 --- a/Suspicious/Suspicious/api/urls.py +++ b/Suspicious/Suspicious/api/urls.py @@ -13,6 +13,7 @@ # Downloads DownloadCaseArchiveView, + CaseChallengeTokenView, ) from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView urlpatterns = [ @@ -69,4 +70,9 @@ DownloadCaseArchiveView.as_view(), name="case-download", ), + path( + "cases//challenge", + CaseChallengeTokenView.as_view(), + name="case-challenge", + ), ] diff --git a/Suspicious/Suspicious/api/views.py b/Suspicious/Suspicious/api/views.py index 35a75e3e..aaa4b1d5 100644 --- a/Suspicious/Suspicious/api/views.py +++ b/Suspicious/Suspicious/api/views.py @@ -1,6 +1,8 @@ +from django.db import IntegrityError, transaction from django.db.models import Sum +from django.http import HttpResponseRedirect, StreamingHttpResponse from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.exceptions import PermissionDenied, NotFound, APIException from rest_framework.response import Response from rest_framework import generics @@ -8,7 +10,7 @@ from drf_spectacular.utils import extend_schema, OpenApiParameter -from case_handler.models import Case +from case_handler.models import Case, CaseChallengeToken from mail_feeder.models import MailArchive from dashboard.models import ( MonthlyCasesSummary, @@ -28,10 +30,10 @@ from .mixins import MonthYearQueryMixin from .audit import log_cert_download from django.utils import timezone +from tasp.services.challenge import get_submissions_url, run_case_challenge import json import io import zipfile -from django.http import StreamingHttpResponse import os import logging from minio.error import S3Error @@ -151,6 +153,47 @@ def _get_archive(case: Case) -> MailArchive: return archive + +class CaseChallengeTokenView(APIView): + permission_classes = [AllowAny] + + def get(self, request, case_id: int): + token = request.query_params.get("token") + if not token: + return Response({"detail": "Token is required."}, status=400) + + token_hash = CaseChallengeToken.hash_token(token) + now = timezone.now() + try: + with transaction.atomic(): + token_record = ( + CaseChallengeToken.objects.select_for_update() + .select_related("case", "case__reporter") + .filter( + token_hash=token_hash, + case_id=case_id, + used_at__isnull=True, + expires_at__gt=now, + ) + .first() + ) + if not token_record: + return Response({"detail": "Invalid or expired token."}, status=400) + + run_case_challenge(token_record.case, logger) + token_record.mark_used() + except ValueError as exc: + return Response({"detail": str(exc)}, status=409) + except IntegrityError: + logger.exception("Database integrity error challenging case %s", case_id) + return Response({"detail": "Database error processing challenge."}, status=500) + except Exception: + logger.exception("Unexpected error challenging case %s", case_id) + return Response({"detail": "Unexpected error processing challenge."}, status=500) + + return HttpResponseRedirect(get_submissions_url()) + + class MonthlyCasesSummaryListView(generics.ListAPIView): permission_classes = [IsAuthenticated] queryset = MonthlyCasesSummary.objects.all() diff --git a/Suspicious/Suspicious/case_handler/migrations/0007_casechallengetoken.py b/Suspicious/Suspicious/case_handler/migrations/0007_casechallengetoken.py new file mode 100644 index 00000000..36a9e383 --- /dev/null +++ b/Suspicious/Suspicious/case_handler/migrations/0007_casechallengetoken.py @@ -0,0 +1,29 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("case_handler", "0006_alter_case_challenged_result_alter_case_results"), + ] + + operations = [ + migrations.CreateModel( + name="CaseChallengeToken", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("token_hash", models.CharField(db_index=True, max_length=64, unique=True)), + ("expires_at", models.DateTimeField(db_index=True)), + ("used_at", models.DateTimeField(blank=True, db_index=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("case", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="challenge_tokens", to="case_handler.case")), + ], + options={ + "indexes": [ + models.Index(fields=["case", "expires_at"]), + models.Index(fields=["case", "used_at"]), + ], + }, + ), + ] diff --git a/Suspicious/Suspicious/case_handler/models.py b/Suspicious/Suspicious/case_handler/models.py index 56075d9d..20009bbd 100644 --- a/Suspicious/Suspicious/case_handler/models.py +++ b/Suspicious/Suspicious/case_handler/models.py @@ -1,3 +1,7 @@ +import hashlib +import secrets +from urllib.parse import urlparse + from django.conf import settings from django.db import models from ip_process.models import IP @@ -78,6 +82,62 @@ def was_published_recently(self): return now - datetime.timedelta(days=1) <= self.creation_date <= now +class CaseChallengeToken(models.Model): + case = models.ForeignKey( + Case, + on_delete=models.CASCADE, + related_name="challenge_tokens", + db_index=True, + ) + token_hash = models.CharField(max_length=64, unique=True, db_index=True) + expires_at = models.DateTimeField(db_index=True) + used_at = models.DateTimeField(null=True, blank=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index(fields=["case", "expires_at"]), + models.Index(fields=["case", "used_at"]), + ] + + @staticmethod + def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + @classmethod + def issue_token(cls, case, *, lifetime=None): + if lifetime is None: + lifetime_seconds = getattr(settings, "CASE_CHALLENGE_TOKEN_TTL_SECONDS", 86400) + lifetime = datetime.timedelta(seconds=lifetime_seconds) + raw_token = secrets.token_urlsafe(32) + instance = cls.objects.create( + case=case, + token_hash=cls.hash_token(raw_token), + expires_at=timezone.now() + lifetime, + ) + return raw_token, instance + + @staticmethod + def build_challenge_url(case_id: int, token: str, api_base: str) -> str: + base = (api_base or "").rstrip("/") + if not base: + return "" + if base.endswith("/api"): + base = base[:-4] + return f"{base}/api/cases/{case_id}/challenge?token={token}" + + @staticmethod + def normalize_api_base(submissions_url: str) -> str: + parsed = urlparse(submissions_url or "") + if not parsed.scheme or not parsed.netloc: + return "" + return f"{parsed.scheme}://{parsed.netloc}" + + def mark_used(self) -> None: + self.used_at = timezone.now() + self.save(update_fields=["used_at"]) + + class CaseHasFileOrMail(models.Model): """ Association model for cases linked to either a file or an email (one-to-one per instance). diff --git a/Suspicious/Suspicious/score_process/score_utils/send_mail/final_service.py b/Suspicious/Suspicious/score_process/score_utils/send_mail/final_service.py index 87800ce2..3d7325fb 100644 --- a/Suspicious/Suspicious/score_process/score_utils/send_mail/final_service.py +++ b/Suspicious/Suspicious/score_process/score_utils/send_mail/final_service.py @@ -1,8 +1,11 @@ import json +from urllib.parse import urlparse + from .models import FinalMailServiceConfigSocial from pathlib import Path from jinja2 import Environment, FileSystemLoader, select_autoescape from .send_email_service import SendMailService +from case_handler.models import CaseChallengeToken CONFIG_PATH = "/app/settings.json" TEMPLATES_DIR = Path(__file__).parent / "templates" @@ -133,7 +136,8 @@ def _result_block(self) -> dict: "result_color": "#FF9A00", "result_text": "As a conclusion, this case has been assessed as suspicious*.", "result_description": ( - f"You may challenge the result here." + "You may challenge the result " + f"here." ), }, "Safe": { @@ -145,6 +149,17 @@ def _result_block(self) -> dict: return mapping.get(self.case.results, mapping["Suspicious"]) + def _challenge_url(self) -> str: + api_base = self.config.get("api_base") + if not api_base: + parsed = urlparse(self.config.get("submissions", "")) + if parsed.scheme and parsed.netloc: + api_base = f"{parsed.scheme}://{parsed.netloc}" + if not api_base: + return "" + token, _ = CaseChallengeToken.issue_token(self.case) + return CaseChallengeToken.build_challenge_url(self.case.id, token, api_base) + # ------------------ send ------------------ def _send_action(self, user: str, subject: str) -> None: @@ -166,4 +181,3 @@ def _send_action(self, user: str, subject: str) -> None: ) send_mail_service.close() - diff --git a/Suspicious/Suspicious/tasp/services/challenge.py b/Suspicious/Suspicious/tasp/services/challenge.py new file mode 100644 index 00000000..428e3326 --- /dev/null +++ b/Suspicious/Suspicious/tasp/services/challenge.py @@ -0,0 +1,83 @@ +import json +import os +from functools import lru_cache + +from django.contrib.auth.models import User +from django.utils import timezone + +from dashboard.models import UserCasesMonthlyStats +from score_process.score_utils.thehive.challenge import ChallengeToTheHiveService + + +CONFIG_PATH = os.environ.get("SUSPICIOUS_SETTINGS_PATH", "/app/settings.json") + + +@lru_cache(maxsize=1) +def _load_mail_config() -> dict: + with open(CONFIG_PATH) as config_file: + return json.load(config_file).get("mail", {}) + + +@lru_cache(maxsize=1) +def _load_thehive_config() -> dict: + with open(CONFIG_PATH) as config_file: + return json.load(config_file).get("thehive", {}) + + +def get_submissions_url() -> str: + return _load_mail_config().get("submissions", "/submissions/") + + +class CaseChallengeService: + def __init__(self, case, logger): + self.case = case + self.logger = logger + + def validate(self): + if self.case.is_challenged or self.case.status == "Challenged": + raise ValueError("Case already challenged") + + def mark_challenged(self): + self.case.is_challenged = True + self.case.status = "Challenged" + self.case.save(update_fields=["is_challenged", "status"]) + + def update_user_stats(self): + _update_case_challenge_stats(self.case.reporter) + + def notify(self): + send_to_thehive = _load_thehive_config().get("enabled", False) + mail_header = f"Case ID {self.case.id} challenged by {self.case.reporter.username}" + self.logger.info( + "Notifying about challenge for case ID %s. Send to TheHive: %s", + self.case.id, + send_to_thehive, + ) + if send_to_thehive: + self.logger.info("Sending challenge notification to TheHive for case ID %s", self.case.id) + ChallengeToTheHiveService(self.case, None, mail_header).send_to_thehive() + self.logger.info("Challenge notification sent to TheHive for case ID %s", self.case.id) + else: + cert_users = User.objects.filter(groups__name="CERT", is_active=True).exclude(email="") + for cert_user in cert_users: + ChallengeToTheHiveService(self.case, cert_user, mail_header).send() + + +def run_case_challenge(case, logger) -> None: + service = CaseChallengeService(case, logger) + service.validate() + service.mark_challenged() + service.update_user_stats() + service.notify() + + +def _update_case_challenge_stats(user): + now = timezone.now() + stats, _ = UserCasesMonthlyStats.objects.get_or_create( + user=user, + month=now.strftime("%m"), + year=now.year, + defaults={"challenged_cases": 0, "total_cases": 0}, + ) + stats.challenged_cases += 1 + stats.save() diff --git a/Suspicious/Suspicious/tasp/views.py b/Suspicious/Suspicious/tasp/views.py index ea033314..6f499d8a 100644 --- a/Suspicious/Suspicious/tasp/views.py +++ b/Suspicious/Suspicious/tasp/views.py @@ -43,13 +43,12 @@ handle_file, handle_ioc, ) -from score_process.score_utils.thehive.challenge import ChallengeToTheHiveService from score_process.score_utils.send_mail.service import MailNotificationService from cortex_job.models import AnalyzerReport from profiles.models import CISOProfile -from dashboard.models import UserCasesMonthlyStats +from tasp.services.challenge import run_case_challenge # --- Constants --- ERROR_CASE_NOT_FOUND = "Case does not exist." @@ -67,7 +66,6 @@ config = json.load(config_file) suspicious_config = config.get("suspicious", {}) -thehive_config = config.get("thehive", {}) # Email configuration (safer to get from Django settings or handle None) EMAIL_SENDER_DEFAULT = suspicious_config.get("email", "SUSPICIOUS") @@ -500,11 +498,7 @@ def challenge(request: HttpRequest, case_id: int) -> JsonResponse: try: case = _get_case_or_404(case_id, request.user) - service = CaseChallengeService(case, logger) - service.validate() - service.mark_challenged() - service.update_user_stats() - service.notify() + run_case_challenge(case, logger) return JsonResponse({"success": True, "message": f"Case {case_id} successfully challenged."}) @@ -520,36 +514,6 @@ def challenge(request: HttpRequest, case_id: int) -> JsonResponse: return JsonResponse({"success": False, "error": ERROR_UNEXPECTED}, status=500) -class CaseChallengeService: - def __init__(self, case, logger): - self.case = case - self.logger = logger - - def validate(self): - if self.case.is_challenged or self.case.status == "Challenged": - raise ValueError("Case already challenged") - - def mark_challenged(self): - self.case.is_challenged = True - self.case.status = "Challenged" - self.case.save(update_fields=["is_challenged", "status"]) - - def update_user_stats(self): - _update_case_challenge_stats(self.case.reporter) - - def notify(self): - send_to_thehive = thehive_config.get("enabled", False) - mail_header = f"Case ID {self.case.id} challenged by {self.case.reporter.username}" - logger.info(f"Notifying about challenge for case ID {self.case.id}. Send to TheHive: {send_to_thehive}") - if send_to_thehive: - logger.info(f"Sending challenge notification to TheHive for case ID {self.case.id}") - ChallengeToTheHiveService(self.case, None, mail_header).send_to_thehive() - logger.info(f"Challenge notification sent to TheHive for case ID {self.case.id}") - else: - cert_users = User.objects.filter(groups__name="CERT", is_active=True).exclude(email="") - for cert_user in cert_users: - ChallengeToTheHiveService(self.case, cert_user, mail_header).send() - def _get_case_or_404(case_id, user): return get_object_or_404( Case.objects.select_related("reporter"), @@ -557,17 +521,6 @@ def _get_case_or_404(case_id, user): reporter=user ) -def _update_case_challenge_stats(user): - now = timezone.now() - stats, _ = UserCasesMonthlyStats.objects.get_or_create( - user=user, - month=now.strftime("%m"), - year=now.year, - defaults={"challenged_cases": 0, "total_cases": 0}, - ) - stats.challenged_cases += 1 - stats.save() - # --- Pop-up View --- @login_required From 67b016e7563441f58a39f55fe60466d4908b9b9a Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Wed, 28 Jan 2026 17:20:13 +0100 Subject: [PATCH 06/19] fixed mixed code --- Suspicious/Suspicious/api/urls.py | 2 - Suspicious/Suspicious/api/views.py | 205 ------------------- Suspicious/Suspicious/case_handler/models.py | 2 +- 3 files changed, 1 insertion(+), 208 deletions(-) diff --git a/Suspicious/Suspicious/api/urls.py b/Suspicious/Suspicious/api/urls.py index 81673d52..6d32ec70 100644 --- a/Suspicious/Suspicious/api/urls.py +++ b/Suspicious/Suspicious/api/urls.py @@ -15,8 +15,6 @@ DownloadCaseArchiveView, CaseChallengeTokenView, ) -from .views import challenge_case_via_token - from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView urlpatterns = [ path("schema/", SpectacularAPIView.as_view(), name="schema"), diff --git a/Suspicious/Suspicious/api/views.py b/Suspicious/Suspicious/api/views.py index 75b218e9..aaa4b1d5 100644 --- a/Suspicious/Suspicious/api/views.py +++ b/Suspicious/Suspicious/api/views.py @@ -37,30 +37,6 @@ import os import logging from minio.error import S3Error - -from django.conf import settings -from django.db import transaction -from django.shortcuts import get_object_or_404, redirect -from django.utils import timezone - -from rest_framework.permissions import AllowAny - -import hashlib -import requests - -from score_process.score_utils.thehive.challenge import ChallengeToTheHiveService -from score_process.score_utils.send_mail.service import MailNotificationService - -from django.contrib.auth import get_user_model - -from case_handler.models import Case -from api.models import CaseChallengeToken - - -User = get_user_model() - - - # --------------------------------------------------------------------- # Permissions # --------------------------------------------------------------------- @@ -68,187 +44,6 @@ ALLOWED_DOWNLOAD_GROUPS = {"Admin", "CERT"} CONFIG_PATH = os.environ.get("SUSPICIOUS_SETTINGS_PATH", "/app/settings.json") logger = logging.getLogger(__name__) -with open(CONFIG_PATH) as config_file: - config = json.load(config_file) - -thehive_config = config.get("thehive", {}) - -CHALLENGE_REDIRECT_URL = "https://suspicious-domain.com/submissions" - -# Put the real Suspicious API endpoint here (not the GitHub repo URL). -SUSPICIOUS_API_URL = getattr(settings, "SUSPICIOUS_API_URL", "https://github.com/thalesgroup-cert/suspicious") - - -class CaseChallengeOneTimeLinkView(APIView): - """ - GET /api/cases/{case_id}/challenge?token=ONE_TIME_TOKEN - - - No auth required - - Token is single-use, case-bound, expirable - - Always redirects to CHALLENGE_REDIRECT_URL (no validity oracle) - """ - permission_classes = [AllowAny] - authentication_classes = [] # ensure DRF doesn't try to authenticate - - def get(self, request, case_id: int): - raw = request.query_params.get("token") - if not raw: - return redirect(CHALLENGE_REDIRECT_URL) - - # Case existence: you can choose to not reveal this either. - # But we still redirect; no body/status differences. - case = get_object_or_404(Case.objects.select_related("reporter"), pk=case_id) - - token_hash = _hash_raw_token(raw) - now = timezone.now() - - token_obj = None - - # Consume token exactly once (race-safe) - with transaction.atomic(): - token_obj = ( - CaseChallengeToken.objects - .select_for_update() - .filter(case=case, token_hash=token_hash) - .first() - ) - if not token_obj: - return redirect(CHALLENGE_REDIRECT_URL) - - if token_obj.used_at is not None or now >= token_obj.expires_at: - return redirect(CHALLENGE_REDIRECT_URL) - - # Mark used immediately to prevent replay (even if downstream fails) - token_obj.used_at = now - # optional audit fields if present on your model: - if hasattr(token_obj, "used_ip"): - token_obj.used_ip = request.META.get("REMOTE_ADDR") - if hasattr(token_obj, "used_user_agent"): - token_obj.used_user_agent = (request.META.get("HTTP_USER_AGENT") or "")[:2000] - - update_fields = ["used_at"] - if hasattr(token_obj, "used_ip"): - update_fields.append("used_ip") - if hasattr(token_obj, "used_user_agent"): - update_fields.append("used_user_agent") - - token_obj.save(update_fields=update_fields) - - # External call + Case updates (token already consumed => at-most-once) - self._call_suspicious_and_update_case(case=case, token_obj=token_obj) - - # Update stats + notify (best-effort, should not block redirect) - try: - if hasattr(case, "reporter") and case.reporter_id: - _update_case_challenge_stats(case.reporter) - _notify_case_challenge(case, logger) - except Exception: - logger.exception("Challenge notify/stats failed for case %s", case.id) - - return redirect(CHALLENGE_REDIRECT_URL) - - def _call_suspicious_and_update_case(self, *, case: Case, token_obj): - payload = { - "case_id": case.pk, - "action": "challenge", - } - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - } - - try: - resp = requests.post( - SUSPICIOUS_API_URL, - json=payload, - headers=headers, - timeout=(3.05, 10), - ) - - # optional audit fields - if hasattr(token_obj, "api_status_code"): - token_obj.api_status_code = resp.status_code - - try: - data = resp.json() - except ValueError: - data = {"raw": resp.text[:5000]} - - if hasattr(token_obj, "api_response"): - token_obj.api_response = data - - if 200 <= resp.status_code < 300: - # Required Case state updates - case.is_challenged = True - case.challenged_result = data - case.status = "Challenged" - case.save(update_fields=["is_challenged", "challenged_result", "status"]) - else: - if hasattr(token_obj, "api_error"): - token_obj.api_error = f"Non-2xx from external API: {resp.status_code}" - - except requests.RequestException as e: - if hasattr(token_obj, "api_error"): - token_obj.api_error = f"{e.__class__.__name__}: {str(e)[:2000]}" - finally: - # save token audit best-effort - try: - update_fields = [] - for f in ("api_status_code", "api_response", "api_error"): - if hasattr(token_obj, f): - update_fields.append(f) - if update_fields: - token_obj.save(update_fields=update_fields) - except Exception: - logger.exception("Failed saving challenge token audit for case %s", case.id) - -def _hash_raw_token(raw_token: str) -> str: - """ - SHA256(SECRET_KEY || raw_token) hex digest. - 'Pepper' via SECRET_KEY prevents offline brute force if DB leaks. - """ - h = hashlib.sha256() - h.update((settings.SECRET_KEY + raw_token).encode("utf-8")) - return h.hexdigest() - - -def _update_case_challenge_stats(user): - now = timezone.now() - stats, _ = UserCasesMonthlyStats.objects.get_or_create( - user=user, - month=now.strftime("%m"), - year=now.year, - defaults={"challenged_cases": 0, "total_cases": 0}, - ) - stats.challenged_cases += 1 - stats.save(update_fields=["challenged_cases"]) - - -def _notify_case_challenge(case: Case, logger): - """ - “Notify process by hand” equivalent to your CaseChallengeService.notify(). - Adjust import paths/names to your existing TheHive/email notification services. - """ - send_to_thehive = thehive_config.get("enabled", False) - reporter = getattr(case, "reporter", None) - reporter_name = getattr(reporter, "username", "unknown") - - mail_header = f"Case ID {case.id} challenged by {reporter_name}" - logger.info( - "Notifying about challenge for case ID %s. Send to TheHive: %s", - case.id, send_to_thehive - ) - - if send_to_thehive: - logger.info("Sending challenge notification to TheHive for case ID %s", case.id) - ChallengeToTheHiveService(case, None, mail_header).send_to_thehive() - logger.info("Challenge notification sent to TheHive for case ID %s", case.id) - return - - cert_users = User.objects.filter(groups__name="CERT", is_active=True).exclude(email="") - for cert_user in cert_users: - ChallengeToTheHiveService(case, cert_user, mail_header).send() - class StorageUnavailable(APIException): diff --git a/Suspicious/Suspicious/case_handler/models.py b/Suspicious/Suspicious/case_handler/models.py index 2939ac40..c141802a 100644 --- a/Suspicious/Suspicious/case_handler/models.py +++ b/Suspicious/Suspicious/case_handler/models.py @@ -1,3 +1,4 @@ +from __future__ import annotations import hashlib import secrets from urllib.parse import urlparse @@ -13,7 +14,6 @@ from django.utils import timezone import datetime from datetime import timedelta -from __future__ import annotations import hashlib From 4e26094a050e3b466269b08a01e5de958ac54f6b Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Wed, 28 Jan 2026 17:45:22 +0100 Subject: [PATCH 07/19] removed duplicates --- Suspicious/Suspicious/case_handler/models.py | 62 -------------------- 1 file changed, 62 deletions(-) diff --git a/Suspicious/Suspicious/case_handler/models.py b/Suspicious/Suspicious/case_handler/models.py index c141802a..c86e6530 100644 --- a/Suspicious/Suspicious/case_handler/models.py +++ b/Suspicious/Suspicious/case_handler/models.py @@ -85,68 +85,6 @@ def was_published_recently(self): now = timezone.now() return now - datetime.timedelta(days=1) <= self.creation_date <= now -class CaseChallengeToken(models.Model): - """ - One-time token for challenging a specific Case. - Store only a hash; the raw token exists only in the emailed URL. - """ - case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="challenge_tokens") - - # SHA-256 hex digest (64 chars) - token_hash = models.CharField(max_length=64, unique=True, db_index=True) - - created_at = models.DateTimeField(auto_now_add=True) - expires_at = models.DateTimeField(db_index=True) - - used_at = models.DateTimeField(null=True, blank=True, db_index=True) - used_ip = models.GenericIPAddressField(null=True, blank=True) - used_user_agent = models.TextField(null=True, blank=True) - - # Optional but useful for auditability/debugging: - api_status_code = models.IntegerField(null=True, blank=True) - api_response = models.JSONField(null=True, blank=True) - api_error = models.TextField(null=True, blank=True) - - class Meta: - indexes = [ - models.Index(fields=["case", "expires_at"]), - models.Index(fields=["case", "used_at"]), - ] - - @property - def is_used(self) -> bool: - return self.used_at is not None - - @property - def is_expired(self) -> bool: - return timezone.now() >= self.expires_at - - -def _token_pepper() -> str: - return settings.SECRET_KEY - - -def hash_raw_token(raw_token: str) -> str: - # SHA256(pepper || raw_token) - h = hashlib.sha256() - h.update((_token_pepper() + raw_token).encode("utf-8")) - return h.hexdigest() - - -def create_case_challenge_token(*, case: Case, ttl: timedelta = timedelta(hours=24)) -> str: - """ - Returns the raw token to embed in the email link. - The DB stores only the hash. - """ - import secrets - - raw = secrets.token_urlsafe(32) # ~256 bits of entropy - CaseChallengeToken.objects.create( - case=case, - token_hash=hash_raw_token(raw), - expires_at=timezone.now() + ttl, - ) - return raw class CaseChallengeToken(models.Model): case = models.ForeignKey( From d5f0562d03724337b438ba1a10578e483ad2eaa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Bhang?= <122457543+TheoBhang@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:51:05 +0100 Subject: [PATCH 08/19] Feature add minio backend + challenge token in final email (#26) * hot fixes : changed order in function parameters * Testing new backend using the already existing minio * added MailAdress to denylist checks * added challenge on api * Add one-time challenge tokens for cases * fixed mixed code * removed duplicates --- Suspicious/Dockerfile | 1 + Suspicious/Suspicious/api/urls.py | 6 ++ Suspicious/Suspicious/api/views.py | 49 ++++++++++- .../migrations/0007_casechallengetoken.py | 29 +++++++ Suspicious/Suspicious/case_handler/models.py | 64 ++++++++++++++ .../email_handler/email_handler.py | 48 +++++++++++ Suspicious/Suspicious/mail_feeder/models.py | 1 + .../utils/email_preview/eml2png_renderer.py | 61 ++++++++++++++ .../score_utils/send_mail/final_service.py | 18 +++- .../scoring/case_score_calculation.py | 20 +++++ Suspicious/Suspicious/storage_backends.py | 66 +++++++++++++++ Suspicious/Suspicious/suspicious/settings.py | 41 ++++++--- .../Suspicious/tasp/services/challenge.py | 83 +++++++++++++++++++ Suspicious/Suspicious/tasp/views.py | 51 +----------- Suspicious/requirements.txt | 1 + Suspicious/settings-sample.json | 8 +- 16 files changed, 481 insertions(+), 66 deletions(-) create mode 100644 Suspicious/Suspicious/case_handler/migrations/0007_casechallengetoken.py create mode 100644 Suspicious/Suspicious/mail_feeder/utils/email_preview/eml2png_renderer.py create mode 100644 Suspicious/Suspicious/storage_backends.py create mode 100644 Suspicious/Suspicious/tasp/services/challenge.py diff --git a/Suspicious/Dockerfile b/Suspicious/Dockerfile index 9efbda6b..198a43d3 100644 --- a/Suspicious/Dockerfile +++ b/Suspicious/Dockerfile @@ -89,6 +89,7 @@ RUN apt-get update && \ RUN apt-get update && apt-get install -y --no-install-recommends \ libldap-common \ libsasl2-2 \ + wkhtmltopdf \ libmariadb3 \ libmagic1 \ cron \ diff --git a/Suspicious/Suspicious/api/urls.py b/Suspicious/Suspicious/api/urls.py index e50b1ef2..6d32ec70 100644 --- a/Suspicious/Suspicious/api/urls.py +++ b/Suspicious/Suspicious/api/urls.py @@ -13,6 +13,7 @@ # Downloads DownloadCaseArchiveView, + CaseChallengeTokenView, ) from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView urlpatterns = [ @@ -69,4 +70,9 @@ DownloadCaseArchiveView.as_view(), name="case-download", ), + path( + "cases//challenge", + CaseChallengeTokenView.as_view(), + name="case-challenge", + ), ] diff --git a/Suspicious/Suspicious/api/views.py b/Suspicious/Suspicious/api/views.py index 35a75e3e..aaa4b1d5 100644 --- a/Suspicious/Suspicious/api/views.py +++ b/Suspicious/Suspicious/api/views.py @@ -1,6 +1,8 @@ +from django.db import IntegrityError, transaction from django.db.models import Sum +from django.http import HttpResponseRedirect, StreamingHttpResponse from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.exceptions import PermissionDenied, NotFound, APIException from rest_framework.response import Response from rest_framework import generics @@ -8,7 +10,7 @@ from drf_spectacular.utils import extend_schema, OpenApiParameter -from case_handler.models import Case +from case_handler.models import Case, CaseChallengeToken from mail_feeder.models import MailArchive from dashboard.models import ( MonthlyCasesSummary, @@ -28,10 +30,10 @@ from .mixins import MonthYearQueryMixin from .audit import log_cert_download from django.utils import timezone +from tasp.services.challenge import get_submissions_url, run_case_challenge import json import io import zipfile -from django.http import StreamingHttpResponse import os import logging from minio.error import S3Error @@ -151,6 +153,47 @@ def _get_archive(case: Case) -> MailArchive: return archive + +class CaseChallengeTokenView(APIView): + permission_classes = [AllowAny] + + def get(self, request, case_id: int): + token = request.query_params.get("token") + if not token: + return Response({"detail": "Token is required."}, status=400) + + token_hash = CaseChallengeToken.hash_token(token) + now = timezone.now() + try: + with transaction.atomic(): + token_record = ( + CaseChallengeToken.objects.select_for_update() + .select_related("case", "case__reporter") + .filter( + token_hash=token_hash, + case_id=case_id, + used_at__isnull=True, + expires_at__gt=now, + ) + .first() + ) + if not token_record: + return Response({"detail": "Invalid or expired token."}, status=400) + + run_case_challenge(token_record.case, logger) + token_record.mark_used() + except ValueError as exc: + return Response({"detail": str(exc)}, status=409) + except IntegrityError: + logger.exception("Database integrity error challenging case %s", case_id) + return Response({"detail": "Database error processing challenge."}, status=500) + except Exception: + logger.exception("Unexpected error challenging case %s", case_id) + return Response({"detail": "Unexpected error processing challenge."}, status=500) + + return HttpResponseRedirect(get_submissions_url()) + + class MonthlyCasesSummaryListView(generics.ListAPIView): permission_classes = [IsAuthenticated] queryset = MonthlyCasesSummary.objects.all() diff --git a/Suspicious/Suspicious/case_handler/migrations/0007_casechallengetoken.py b/Suspicious/Suspicious/case_handler/migrations/0007_casechallengetoken.py new file mode 100644 index 00000000..36a9e383 --- /dev/null +++ b/Suspicious/Suspicious/case_handler/migrations/0007_casechallengetoken.py @@ -0,0 +1,29 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("case_handler", "0006_alter_case_challenged_result_alter_case_results"), + ] + + operations = [ + migrations.CreateModel( + name="CaseChallengeToken", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("token_hash", models.CharField(db_index=True, max_length=64, unique=True)), + ("expires_at", models.DateTimeField(db_index=True)), + ("used_at", models.DateTimeField(blank=True, db_index=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("case", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="challenge_tokens", to="case_handler.case")), + ], + options={ + "indexes": [ + models.Index(fields=["case", "expires_at"]), + models.Index(fields=["case", "used_at"]), + ], + }, + ), + ] diff --git a/Suspicious/Suspicious/case_handler/models.py b/Suspicious/Suspicious/case_handler/models.py index 56075d9d..c86e6530 100644 --- a/Suspicious/Suspicious/case_handler/models.py +++ b/Suspicious/Suspicious/case_handler/models.py @@ -1,3 +1,8 @@ +from __future__ import annotations +import hashlib +import secrets +from urllib.parse import urlparse + from django.conf import settings from django.db import models from ip_process.models import IP @@ -8,6 +13,9 @@ from django.utils.translation import gettext_lazy as _ from django.utils import timezone import datetime +from datetime import timedelta + +import hashlib class Status(models.TextChoices): """ @@ -78,6 +86,62 @@ def was_published_recently(self): return now - datetime.timedelta(days=1) <= self.creation_date <= now +class CaseChallengeToken(models.Model): + case = models.ForeignKey( + Case, + on_delete=models.CASCADE, + related_name="challenge_tokens", + db_index=True, + ) + token_hash = models.CharField(max_length=64, unique=True, db_index=True) + expires_at = models.DateTimeField(db_index=True) + used_at = models.DateTimeField(null=True, blank=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index(fields=["case", "expires_at"]), + models.Index(fields=["case", "used_at"]), + ] + + @staticmethod + def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + @classmethod + def issue_token(cls, case, *, lifetime=None): + if lifetime is None: + lifetime_seconds = getattr(settings, "CASE_CHALLENGE_TOKEN_TTL_SECONDS", 86400) + lifetime = datetime.timedelta(seconds=lifetime_seconds) + raw_token = secrets.token_urlsafe(32) + instance = cls.objects.create( + case=case, + token_hash=cls.hash_token(raw_token), + expires_at=timezone.now() + lifetime, + ) + return raw_token, instance + + @staticmethod + def build_challenge_url(case_id: int, token: str, api_base: str) -> str: + base = (api_base or "").rstrip("/") + if not base: + return "" + if base.endswith("/api"): + base = base[:-4] + return f"{base}/api/cases/{case_id}/challenge?token={token}" + + @staticmethod + def normalize_api_base(submissions_url: str) -> str: + parsed = urlparse(submissions_url or "") + if not parsed.scheme or not parsed.netloc: + return "" + return f"{parsed.scheme}://{parsed.netloc}" + + def mark_used(self) -> None: + self.used_at = timezone.now() + self.save(update_fields=["used_at"]) + + class CaseHasFileOrMail(models.Model): """ Association model for cases linked to either a file or an email (one-to-one per instance). diff --git a/Suspicious/Suspicious/mail_feeder/email_handler/email_handler.py b/Suspicious/Suspicious/mail_feeder/email_handler/email_handler.py index a5d80a68..57f0c5a8 100644 --- a/Suspicious/Suspicious/mail_feeder/email_handler/email_handler.py +++ b/Suspicious/Suspicious/mail_feeder/email_handler/email_handler.py @@ -7,6 +7,9 @@ from mail_feeder.utils.process_em_header.email_header import EmailHeaderService from .models import EmailDataModel from .utils import safe_operation, increment_field +from pathlib import Path +from mail_feeder.utils.mail_preview.eml2png_renderer import Eml2PngRenderer +import os fetch_mail_logger = logging.getLogger("tasp.cron.fetch_and_process_emails") @@ -21,6 +24,7 @@ def __init__(self): self.body_service = EmailBodyService() self.header_service = EmailHeaderService() self.observables_service = EmailObservablesService() + self.preview_renderer = Eml2PngRenderer() def handle_mail(self, email_data: dict, workdir: str) -> Optional[Mail]: """ @@ -59,6 +63,9 @@ def _handle_new_mail(self, data: EmailDataModel, workdir: str) -> Optional[Mail] return None mail_instance = self._save_and_update_mail(mail_instance, data) + + self._generate_mail_preview_png(mail_instance, data, workdir) + self._process_rich_observables(mail_instance, data, workdir) self._update_times_sent(mail_instance) self._save_mail(mail_instance) @@ -79,6 +86,7 @@ def _handle_existing_mail( mail_instance, data, email_body_list, email_header_list ) fetch_mail_logger.debug(f"Updated existing mail: {mail_instance.mail_id}") + self._update_times_sent(mail_instance) self._save_mail(mail_instance) return mail_instance @@ -91,6 +99,46 @@ def _check_existing_data(self, data: EmailDataModel) -> Tuple[List[Mail], list, email_header_list = self.header_service.check_email_headers(data.headers) if data.headers else [] return email_list, email_body_list, email_header_list + def _generate_mail_preview_png(self, mail_instance: Mail, data: EmailDataModel, workdir: str) -> None: + """ + PNG preview generation. + """ + with safe_operation("generate_mail_preview_png"): + try: + email_path = self._resolve_file_path( + filename=str(data.id), + workdir=workdir, + ) + except FileNotFoundError: + fetch_mail_logger.debug( + "No email file found for preview (mail_id=%s, workdir=%s)", + mail_instance.mail_id, + workdir, + ) + return + + png_bytes = self.preview_renderer.render_eml_path_to_png_bytes( + Path(email_path) + ) + if not png_bytes: + return + + self.preview_renderer.save_preview_to_mail(mail_instance, png_bytes) + + def _resolve_file_path(self, filename: str, workdir: str) -> str: + for ext in [".eml", ".msg"]: + path = os.path.join(workdir, f"{filename}{ext}") + if os.path.exists(path): + return path + + for alt in ["user_submission.eml", "user_submission.msg"]: + path = os.path.join(workdir, alt) + if os.path.exists(path): + return path + + raise FileNotFoundError(f"No email file found for {filename} in {workdir}") + + def _save_and_update_mail(self, mail_instance: Mail, data: EmailDataModel) -> Mail: """ Saves the base Mail instance and attaches body and header. diff --git a/Suspicious/Suspicious/mail_feeder/models.py b/Suspicious/Suspicious/mail_feeder/models.py index 94ea12c8..30b4207a 100644 --- a/Suspicious/Suspicious/mail_feeder/models.py +++ b/Suspicious/Suspicious/mail_feeder/models.py @@ -11,6 +11,7 @@ class Mail(models.Model): subject = models.CharField(max_length=255, db_index=True) reportedBy = models.CharField(max_length=255, db_index=True) + preview_png = models.ImageField(upload_to="mail_previews/", null=True, blank=True) mail_header = models.ForeignKey( 'MailHeader', on_delete=models.CASCADE, related_name='mails', null=True, blank=True, db_index=True diff --git a/Suspicious/Suspicious/mail_feeder/utils/email_preview/eml2png_renderer.py b/Suspicious/Suspicious/mail_feeder/utils/email_preview/eml2png_renderer.py new file mode 100644 index 00000000..a9e5010a --- /dev/null +++ b/Suspicious/Suspicious/mail_feeder/utils/email_preview/eml2png_renderer.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import logging +import subprocess +from pathlib import Path +from typing import Optional + +from django.core.files.base import ContentFile + +logger = logging.getLogger(__name__) + + +class Eml2PngRenderer: + """ + Renders an .eml file to a PNG using the `eml2png` CLI. + Requires: eml2png + wkhtmltopdf installed in the runtime image. + """ + + def __init__(self, cli: str = "eml2png"): + self.cli = cli + + def render_eml_path_to_png_bytes(self, eml_path: Path) -> Optional[bytes]: + if not eml_path.exists(): + logger.warning("EML file not found: %s", eml_path) + return None + + # eml2png works via output file + out_path = eml_path.with_suffix(".preview.png") + + try: + subprocess.run( + [self.cli, str(eml_path), "-o", str(out_path)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + except FileNotFoundError: + logger.error("eml2png CLI not found (is it installed in the container?)") + return None + except subprocess.CalledProcessError as e: + logger.error("eml2png failed: %s", e.stderr.decode(errors="replace")) + return None + + try: + data = out_path.read_bytes() + except OSError as e: + logger.error("Cannot read rendered PNG: %s", e) + return None + finally: + # best-effort cleanup + try: + out_path.unlink(missing_ok=True) # Python 3.8+: missing_ok exists + except Exception: + pass + + return data + + def save_preview_to_mail(self, mail, png_bytes: bytes) -> None: + # Storage-aware: uses DEFAULT_FILE_STORAGE (local/MinIO/dual) + mail.preview_png.save("preview.png", ContentFile(png_bytes), save=False) + mail.save(update_fields=["preview_png"]) diff --git a/Suspicious/Suspicious/score_process/score_utils/send_mail/final_service.py b/Suspicious/Suspicious/score_process/score_utils/send_mail/final_service.py index 87800ce2..3d7325fb 100644 --- a/Suspicious/Suspicious/score_process/score_utils/send_mail/final_service.py +++ b/Suspicious/Suspicious/score_process/score_utils/send_mail/final_service.py @@ -1,8 +1,11 @@ import json +from urllib.parse import urlparse + from .models import FinalMailServiceConfigSocial from pathlib import Path from jinja2 import Environment, FileSystemLoader, select_autoescape from .send_email_service import SendMailService +from case_handler.models import CaseChallengeToken CONFIG_PATH = "/app/settings.json" TEMPLATES_DIR = Path(__file__).parent / "templates" @@ -133,7 +136,8 @@ def _result_block(self) -> dict: "result_color": "#FF9A00", "result_text": "As a conclusion, this case has been assessed as suspicious*.", "result_description": ( - f"You may challenge the result here." + "You may challenge the result " + f"here." ), }, "Safe": { @@ -145,6 +149,17 @@ def _result_block(self) -> dict: return mapping.get(self.case.results, mapping["Suspicious"]) + def _challenge_url(self) -> str: + api_base = self.config.get("api_base") + if not api_base: + parsed = urlparse(self.config.get("submissions", "")) + if parsed.scheme and parsed.netloc: + api_base = f"{parsed.scheme}://{parsed.netloc}" + if not api_base: + return "" + token, _ = CaseChallengeToken.issue_token(self.case) + return CaseChallengeToken.build_challenge_url(self.case.id, token, api_base) + # ------------------ send ------------------ def _send_action(self, user: str, subject: str) -> None: @@ -166,4 +181,3 @@ def _send_action(self, user: str, subject: str) -> None: ) send_mail_service.close() - diff --git a/Suspicious/Suspicious/score_process/scoring/case_score_calculation.py b/Suspicious/Suspicious/score_process/scoring/case_score_calculation.py index eccdfb09..55604873 100644 --- a/Suspicious/Suspicious/score_process/scoring/case_score_calculation.py +++ b/Suspicious/Suspicious/score_process/scoring/case_score_calculation.py @@ -3,6 +3,7 @@ from typing import List, Set, Optional, Type, Union from urllib.parse import urlparse +from email.utils import parseaddr # Models from settings.models import DenyListDomain, CampaignDomainAllowList @@ -109,6 +110,19 @@ def _extract_domain_from_url(url_string: str, logger: logging.Logger) -> Optiona logger.error(f"Failed to parse URL '{url_string}': {e}") return None +def _extract_domain_from_email_address( + address: str, + logger: logging.Logger, +) -> Optional[str]: + try: + _, email_addr = parseaddr(address) + if "@" not in email_addr: + return None + return email_addr.rsplit("@", 1)[1].lower() + except Exception as e: + logger.error(f"Failed to parse email address '{address}': {e}") + return None + def _update_ioc(obj, logger, label: str): for field in ["ioc_score", "ioc_confidence", "ioc_level"]: if not hasattr(obj, field): @@ -157,6 +171,12 @@ def _check_mail_artifacts_for_deny_list(mail, deny_list: Set[str], logger: loggi domain_obj, url_obj, address = None, None, None source = "Unknown" + if hasattr(artifact, 'artifactIsMailAddress') and artifact.artifactIsMailAddress: + mail_address = getattr(artifact.artifactIsMailAddress, 'mail_address', None) + if mail_address and hasattr(mail_address, 'address'): + address = _extract_domain_from_email_address(mail_address.address, logger) + source = "Mail Address" + if hasattr(artifact, 'artifactIsDomain') and artifact.artifactIsDomain: domain = getattr(artifact.artifactIsDomain, 'domain', None) if domain and hasattr(domain, 'value'): diff --git a/Suspicious/Suspicious/storage_backends.py b/Suspicious/Suspicious/storage_backends.py new file mode 100644 index 00000000..9951db43 --- /dev/null +++ b/Suspicious/Suspicious/storage_backends.py @@ -0,0 +1,66 @@ +# suspicious/storage_backends.py +from __future__ import annotations + +from django.conf import settings +from django.core.files.storage import FileSystemStorage, Storage + +try: + from minio_storage.storage import MinioMediaStorage +except Exception: # pragma: no cover + MinioMediaStorage = None # type: ignore + + +def _bool(val: str | bool | None, default: bool = False) -> bool: + if val is None: + return default + if isinstance(val, bool): + return val + return val.strip().lower() in {"1", "true", "yes", "on"} + + +class DualStorage(Storage): + """ + Primary = MinIO, Secondary = local filesystem. + - Save: always to primary; optionally also to secondary (dual write). + - Open/exists: prefer primary, fallback to secondary for backward compatibility. + """ + + def __init__(self, *args, **kwargs): + if MinioMediaStorage is None: + raise RuntimeError("minio_storage is required for DualStorage") + + self.primary = MinioMediaStorage() + self.secondary = FileSystemStorage(location=settings.MEDIA_ROOT, base_url=settings.MEDIA_URL) + + self.dual_write = _bool(getattr(settings, "SUSPICIOUS_STORAGE_DUAL_WRITE", False), default=False) + + def _open(self, name, mode="rb"): + if self.primary.exists(name): + return self.primary.open(name, mode) + return self.secondary.open(name, mode) + + def _save(self, name, content): + saved = self.primary.save(name, content) + if self.dual_write: + # best effort: store also locally (useful during transition) + try: + content.seek(0) + self.secondary.save(saved, content) + except Exception: + pass + return saved + + def delete(self, name): + # best effort delete both + if self.primary.exists(name): + self.primary.delete(name) + if self.secondary.exists(name): + self.secondary.delete(name) + + def exists(self, name): + return self.primary.exists(name) or self.secondary.exists(name) + + def url(self, name): + if self.primary.exists(name): + return self.primary.url(name) + return self.secondary.url(name) diff --git a/Suspicious/Suspicious/suspicious/settings.py b/Suspicious/Suspicious/suspicious/settings.py index e544ea07..195b2f27 100644 --- a/Suspicious/Suspicious/suspicious/settings.py +++ b/Suspicious/Suspicious/suspicious/settings.py @@ -22,6 +22,7 @@ ldap_config = config.get('ldap', {}) suspicious_config = config.get('suspicious', {}) +minio_config = config.get('minio', {}) cortex_config = config.get('cortex', {}) db_config = config.get('database', {}) @@ -207,8 +208,6 @@ SITE_ID = 1 -SITE_ID = 1 - ACCOUNT_EMAIL_VERIFICATION = "none" ACCOUNT_AUTHENTICATION_METHOD = "username_email" ACCOUNT_UNIQUE_EMAIL = True @@ -272,6 +271,27 @@ 'django_filters', ] +# --- Storage backend selection (default: local filesystem) --- +# Values: local | minio | dual +SUSPICIOUS_STORAGE_BACKEND = suspicious_config.get("storage_backend", "local").lower() + +# MinIO (django-minio-storage) settings (env first, then settings.json) +MINIO_STORAGE_ENDPOINT = minio_config.get("endpoint", "minio:9000") +MINIO_STORAGE_ACCESS_KEY = minio_config.get("access_key", "") +MINIO_STORAGE_SECRET_KEY = minio_config.get("secret_key", "") +MINIO_STORAGE_USE_HTTPS = str(minio_config.get("secure", "0")).lower() in {"1", "true", "yes", "on"} + +MINIO_STORAGE_MEDIA_BUCKET_NAME = suspicious_config.get("minio_media_bucket", "suspicious-media") +MINIO_STORAGE_AUTO_CREATE_MEDIA_BUCKET = bool(minio_config.get("minio_auto_create_bucket", True)) + +# Dual mode option: also write a local copy +SUSPICIOUS_STORAGE_DUAL_WRITE = str(suspicious_config.get("storage_dual_write", "0")).lower() in {"1", "true", "yes", "on"} + +if SUSPICIOUS_STORAGE_BACKEND in {"minio", "dual"}: + # Only include when enabled + if "minio_storage" not in INSTALLED_APPS: + INSTALLED_APPS.append("minio_storage") + REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ('knox.auth.TokenAuthentication',), @@ -301,7 +321,7 @@ } REST_KNOX = { - 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA3_512', + 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA3_512', 'TOKEN_TTL': timedelta(hours=10), } @@ -340,8 +360,6 @@ ASGI_APPLICATION = 'suspicious.asgi.application' -# Password validation -# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators # Password validation # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ @@ -369,7 +387,6 @@ # Internationalization # https://docs.djangoproject.com/en/4.1/topics/i18n/ - LANGUAGE_CODE = 'en-us' USE_I18N = True USE_L10N = True @@ -384,15 +401,19 @@ # Clé primaire par défaut DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -# Add default storage for file uploads -DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' + +# Default storage for file uploads (Django 4.1) +if SUSPICIOUS_STORAGE_BACKEND == "minio": + DEFAULT_FILE_STORAGE = "minio_storage.storage.MinioMediaStorage" +elif SUSPICIOUS_STORAGE_BACKEND == "dual": + DEFAULT_FILE_STORAGE = "suspicious.storage_backends.DualStorage" +else: + DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage" # Lock jobs to prevent them from running simultaneously CRONTAB_LOCK_JOBS = True # Cron jobs for various tasks -# Each job is run every minute and logs to a specific file in /tmp -# Consider adjusting the frequency of these jobs based on their cost and your needs CRONJOBS = [ ('*/1 * * * *', 'tasp.cron.fetch_emails.fetch_and_process_emails', '>> /app/log/fetched_mail.log'), ('*/1 * * * *', 'tasp.cron.sync_cortex.sync_cortex_analyzers'), diff --git a/Suspicious/Suspicious/tasp/services/challenge.py b/Suspicious/Suspicious/tasp/services/challenge.py new file mode 100644 index 00000000..428e3326 --- /dev/null +++ b/Suspicious/Suspicious/tasp/services/challenge.py @@ -0,0 +1,83 @@ +import json +import os +from functools import lru_cache + +from django.contrib.auth.models import User +from django.utils import timezone + +from dashboard.models import UserCasesMonthlyStats +from score_process.score_utils.thehive.challenge import ChallengeToTheHiveService + + +CONFIG_PATH = os.environ.get("SUSPICIOUS_SETTINGS_PATH", "/app/settings.json") + + +@lru_cache(maxsize=1) +def _load_mail_config() -> dict: + with open(CONFIG_PATH) as config_file: + return json.load(config_file).get("mail", {}) + + +@lru_cache(maxsize=1) +def _load_thehive_config() -> dict: + with open(CONFIG_PATH) as config_file: + return json.load(config_file).get("thehive", {}) + + +def get_submissions_url() -> str: + return _load_mail_config().get("submissions", "/submissions/") + + +class CaseChallengeService: + def __init__(self, case, logger): + self.case = case + self.logger = logger + + def validate(self): + if self.case.is_challenged or self.case.status == "Challenged": + raise ValueError("Case already challenged") + + def mark_challenged(self): + self.case.is_challenged = True + self.case.status = "Challenged" + self.case.save(update_fields=["is_challenged", "status"]) + + def update_user_stats(self): + _update_case_challenge_stats(self.case.reporter) + + def notify(self): + send_to_thehive = _load_thehive_config().get("enabled", False) + mail_header = f"Case ID {self.case.id} challenged by {self.case.reporter.username}" + self.logger.info( + "Notifying about challenge for case ID %s. Send to TheHive: %s", + self.case.id, + send_to_thehive, + ) + if send_to_thehive: + self.logger.info("Sending challenge notification to TheHive for case ID %s", self.case.id) + ChallengeToTheHiveService(self.case, None, mail_header).send_to_thehive() + self.logger.info("Challenge notification sent to TheHive for case ID %s", self.case.id) + else: + cert_users = User.objects.filter(groups__name="CERT", is_active=True).exclude(email="") + for cert_user in cert_users: + ChallengeToTheHiveService(self.case, cert_user, mail_header).send() + + +def run_case_challenge(case, logger) -> None: + service = CaseChallengeService(case, logger) + service.validate() + service.mark_challenged() + service.update_user_stats() + service.notify() + + +def _update_case_challenge_stats(user): + now = timezone.now() + stats, _ = UserCasesMonthlyStats.objects.get_or_create( + user=user, + month=now.strftime("%m"), + year=now.year, + defaults={"challenged_cases": 0, "total_cases": 0}, + ) + stats.challenged_cases += 1 + stats.save() diff --git a/Suspicious/Suspicious/tasp/views.py b/Suspicious/Suspicious/tasp/views.py index ea033314..6f499d8a 100644 --- a/Suspicious/Suspicious/tasp/views.py +++ b/Suspicious/Suspicious/tasp/views.py @@ -43,13 +43,12 @@ handle_file, handle_ioc, ) -from score_process.score_utils.thehive.challenge import ChallengeToTheHiveService from score_process.score_utils.send_mail.service import MailNotificationService from cortex_job.models import AnalyzerReport from profiles.models import CISOProfile -from dashboard.models import UserCasesMonthlyStats +from tasp.services.challenge import run_case_challenge # --- Constants --- ERROR_CASE_NOT_FOUND = "Case does not exist." @@ -67,7 +66,6 @@ config = json.load(config_file) suspicious_config = config.get("suspicious", {}) -thehive_config = config.get("thehive", {}) # Email configuration (safer to get from Django settings or handle None) EMAIL_SENDER_DEFAULT = suspicious_config.get("email", "SUSPICIOUS") @@ -500,11 +498,7 @@ def challenge(request: HttpRequest, case_id: int) -> JsonResponse: try: case = _get_case_or_404(case_id, request.user) - service = CaseChallengeService(case, logger) - service.validate() - service.mark_challenged() - service.update_user_stats() - service.notify() + run_case_challenge(case, logger) return JsonResponse({"success": True, "message": f"Case {case_id} successfully challenged."}) @@ -520,36 +514,6 @@ def challenge(request: HttpRequest, case_id: int) -> JsonResponse: return JsonResponse({"success": False, "error": ERROR_UNEXPECTED}, status=500) -class CaseChallengeService: - def __init__(self, case, logger): - self.case = case - self.logger = logger - - def validate(self): - if self.case.is_challenged or self.case.status == "Challenged": - raise ValueError("Case already challenged") - - def mark_challenged(self): - self.case.is_challenged = True - self.case.status = "Challenged" - self.case.save(update_fields=["is_challenged", "status"]) - - def update_user_stats(self): - _update_case_challenge_stats(self.case.reporter) - - def notify(self): - send_to_thehive = thehive_config.get("enabled", False) - mail_header = f"Case ID {self.case.id} challenged by {self.case.reporter.username}" - logger.info(f"Notifying about challenge for case ID {self.case.id}. Send to TheHive: {send_to_thehive}") - if send_to_thehive: - logger.info(f"Sending challenge notification to TheHive for case ID {self.case.id}") - ChallengeToTheHiveService(self.case, None, mail_header).send_to_thehive() - logger.info(f"Challenge notification sent to TheHive for case ID {self.case.id}") - else: - cert_users = User.objects.filter(groups__name="CERT", is_active=True).exclude(email="") - for cert_user in cert_users: - ChallengeToTheHiveService(self.case, cert_user, mail_header).send() - def _get_case_or_404(case_id, user): return get_object_or_404( Case.objects.select_related("reporter"), @@ -557,17 +521,6 @@ def _get_case_or_404(case_id, user): reporter=user ) -def _update_case_challenge_stats(user): - now = timezone.now() - stats, _ = UserCasesMonthlyStats.objects.get_or_create( - user=user, - month=now.strftime("%m"), - year=now.year, - defaults={"challenged_cases": 0, "total_cases": 0}, - ) - stats.challenged_cases += 1 - stats.save() - # --- Pop-up View --- @login_required diff --git a/Suspicious/requirements.txt b/Suspicious/requirements.txt index d25c2c72..6db61b6a 100644 --- a/Suspicious/requirements.txt +++ b/Suspicious/requirements.txt @@ -20,6 +20,7 @@ tldextract==5.3.1 dkimpy==1.1.8 email-validator==2.3.0 msg_parser==1.2.0 +eml2png==0.0.2 # Natural Language Processing and Similarity Checking nltk==3.9.2 diff --git a/Suspicious/settings-sample.json b/Suspicious/settings-sample.json index ce6da04f..586e5931 100644 --- a/Suspicious/settings-sample.json +++ b/Suspicious/settings-sample.json @@ -17,7 +17,10 @@ "sign": "data:image/png;base64,Base64 text of your company logo", "oidc_server_url": "oicd-server", "oidc_client_id": "oicd-client-id", - "oidc_client_secret": "oidc_client_secret" + "oidc_client_secret": "oidc_client_secret", + "storage_backend": "minio", + "minio_media_bucket": "suspicious-media", + "storage_dual_write" : "False" }, "thehive": { "enabled": false, @@ -87,7 +90,8 @@ "access_key": "the minio access key", "endpoint": "minio:9000", "secret_key": "the minio secret key", - "secure": false + "secure": false, + "minio_auto_create_bucket": true }, "mail": { "tls": true, From 8b1c2fa1cffb62941ae91d0a1913f7cc5d171ad8 Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Mon, 2 Feb 2026 11:24:44 +0100 Subject: [PATCH 09/19] updated dashboard --- Suspicious/Suspicious/templates/tasp/dashboard.html | 1 + 1 file changed, 1 insertion(+) diff --git a/Suspicious/Suspicious/templates/tasp/dashboard.html b/Suspicious/Suspicious/templates/tasp/dashboard.html index 35d523b5..03a28ce0 100644 --- a/Suspicious/Suspicious/templates/tasp/dashboard.html +++ b/Suspicious/Suspicious/templates/tasp/dashboard.html @@ -56,6 +56,7 @@

Dashboard for : {{ month | + From a2e238c113064109feb846c14aedb52813ce3755 Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Mon, 2 Feb 2026 11:30:38 +0100 Subject: [PATCH 10/19] fixed typo --- .../Suspicious/mail_feeder/email_handler/email_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Suspicious/Suspicious/mail_feeder/email_handler/email_handler.py b/Suspicious/Suspicious/mail_feeder/email_handler/email_handler.py index 57f0c5a8..28a40629 100644 --- a/Suspicious/Suspicious/mail_feeder/email_handler/email_handler.py +++ b/Suspicious/Suspicious/mail_feeder/email_handler/email_handler.py @@ -8,7 +8,7 @@ from .models import EmailDataModel from .utils import safe_operation, increment_field from pathlib import Path -from mail_feeder.utils.mail_preview.eml2png_renderer import Eml2PngRenderer +from mail_feeder.utils.email_preview.eml2png_renderer import Eml2PngRenderer import os fetch_mail_logger = logging.getLogger("tasp.cron.fetch_and_process_emails") From 483ae735df4ea41879dcda66e9ee19e026546e26 Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Mon, 2 Feb 2026 13:11:32 +0100 Subject: [PATCH 11/19] updated models and migration --- .../case_handler/migrations/0007_casechallengetoken.py | 4 ++-- Suspicious/Suspicious/case_handler/models.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Suspicious/Suspicious/case_handler/migrations/0007_casechallengetoken.py b/Suspicious/Suspicious/case_handler/migrations/0007_casechallengetoken.py index 36a9e383..895db567 100644 --- a/Suspicious/Suspicious/case_handler/migrations/0007_casechallengetoken.py +++ b/Suspicious/Suspicious/case_handler/migrations/0007_casechallengetoken.py @@ -21,8 +21,8 @@ class Migration(migrations.Migration): ], options={ "indexes": [ - models.Index(fields=["case", "expires_at"]), - models.Index(fields=["case", "used_at"]), + models.Index(fields=["case", "expires_at"], name="cct_case_expires_idx"), + models.Index(fields=["case", "used_at"], name="cct_case_used_idx"), ], }, ), diff --git a/Suspicious/Suspicious/case_handler/models.py b/Suspicious/Suspicious/case_handler/models.py index c86e6530..a50d9068 100644 --- a/Suspicious/Suspicious/case_handler/models.py +++ b/Suspicious/Suspicious/case_handler/models.py @@ -100,8 +100,8 @@ class CaseChallengeToken(models.Model): class Meta: indexes = [ - models.Index(fields=["case", "expires_at"]), - models.Index(fields=["case", "used_at"]), + models.Index(fields=["case", "expires_at"], name="cct_case_expires_idx"), + models.Index(fields=["case", "used_at"], name="cct_case_used_idx"), ] @staticmethod From fd0e48730291e7dba7147ab54bd03e811262872e Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Mon, 2 Feb 2026 14:22:22 +0100 Subject: [PATCH 12/19] Added preview email and improved email final --- .../migrations/0012_mail_preview_png.py | 18 ++++++++++++++++++ .../score_utils/send_mail/final_service.py | 9 ++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 Suspicious/Suspicious/mail_feeder/migrations/0012_mail_preview_png.py diff --git a/Suspicious/Suspicious/mail_feeder/migrations/0012_mail_preview_png.py b/Suspicious/Suspicious/mail_feeder/migrations/0012_mail_preview_png.py new file mode 100644 index 00000000..7e7e44d4 --- /dev/null +++ b/Suspicious/Suspicious/mail_feeder/migrations/0012_mail_preview_png.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.10 on 2026-02-02 12:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mail_feeder', '0011_mailarchive_bucket_name'), + ] + + operations = [ + migrations.AddField( + model_name='mail', + name='preview_png', + field=models.ImageField(blank=True, null=True, upload_to='mail_previews/'), + ), + ] diff --git a/Suspicious/Suspicious/score_process/score_utils/send_mail/final_service.py b/Suspicious/Suspicious/score_process/score_utils/send_mail/final_service.py index 3d7325fb..edf2c722 100644 --- a/Suspicious/Suspicious/score_process/score_utils/send_mail/final_service.py +++ b/Suspicious/Suspicious/score_process/score_utils/send_mail/final_service.py @@ -130,12 +130,15 @@ def _result_block(self) -> dict: "result_text": "As a conclusion, this case has been assessed as dangerous*.", "result_description": ( "If applicable, do not open files or click links." + "You may challenge the result " + f"here." ), }, "Suspicious": { "result_color": "#FF9A00", "result_text": "As a conclusion, this case has been assessed as suspicious*.", "result_description": ( + "Exercise caution when interacting with files or links." "You may challenge the result " f"here." ), @@ -143,7 +146,11 @@ def _result_block(self) -> dict: "Safe": { "result_color": "#5EC27F", "result_text": "As a conclusion, this case has been assessed as safe*.", - "result_description": "You may proceed safely, while remaining vigilant.", + "result_description": ( + "You may proceed safely, while remaining vigilant." + "You may challenge the result " + f"here." + ), }, } From 327a2d77290e885ddb47235561e330479e512a17 Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Mon, 2 Feb 2026 16:31:26 +0100 Subject: [PATCH 13/19] test on challenge --- Suspicious/Suspicious/case_handler/models.py | 1 + Suspicious/Suspicious/suspicious/settings.py | 1 + Suspicious/Suspicious/tasp/cron/suspicious.py | 10 +++++++ .../tasp/static/js/caseScriptUser.js | 28 +++++++++++++++++++ .../Suspicious/templates/tasp/index.html | 12 ++++++++ 5 files changed, 52 insertions(+) diff --git a/Suspicious/Suspicious/case_handler/models.py b/Suspicious/Suspicious/case_handler/models.py index a50d9068..068a6c83 100644 --- a/Suspicious/Suspicious/case_handler/models.py +++ b/Suspicious/Suspicious/case_handler/models.py @@ -60,6 +60,7 @@ class Case(models.Model): fileOrMail = models.ForeignKey('CaseHasFileOrMail', on_delete=models.CASCADE, related_name='cases', null=True, blank=True, db_index=True) nonFileIocs = models.ForeignKey('CaseHasNonFileIocs', on_delete=models.CASCADE, related_name='cases', null=True, blank=True, db_index=True) is_challenged = models.BooleanField(default=False) + is_challengeable = models.BooleanField(default=True) challenged_result = models.CharField(max_length=20, choices=Result.choices, default=Result.UNCHALLENGED, verbose_name='Challenged Result', db_index=True) creation_date = models.DateTimeField(auto_now_add=True, db_index=True) last_update = models.DateTimeField(auto_now=True) diff --git a/Suspicious/Suspicious/suspicious/settings.py b/Suspicious/Suspicious/suspicious/settings.py index 195b2f27..5fbb1a7d 100644 --- a/Suspicious/Suspicious/suspicious/settings.py +++ b/Suspicious/Suspicious/suspicious/settings.py @@ -418,6 +418,7 @@ ('*/1 * * * *', 'tasp.cron.fetch_emails.fetch_and_process_emails', '>> /app/log/fetched_mail.log'), ('*/1 * * * *', 'tasp.cron.sync_cortex.sync_cortex_analyzers'), ('*/1 * * * *', 'tasp.cron.user_and_cases.update_ongoing_case_jobs', '>> /app/log/case_updating.log'), + ('0 0 * * *', 'tasp.cron.suspicious.check_challengeable', '>> /app/log/case_challengeable.log'), ('*/5 * * * *', 'tasp.cron.kpi.sync_monthly_kpi'), ('*/10 * * * *', 'tasp.cron.user_and_cases.sync_user_profiles'), ('0 0 1 * *', 'tasp.cron.cleanup.delete_old_analyzer_reports', '>> /app/log/cleanup_phishing.log'), diff --git a/Suspicious/Suspicious/tasp/cron/suspicious.py b/Suspicious/Suspicious/tasp/cron/suspicious.py index 207fb03c..7668e82f 100644 --- a/Suspicious/Suspicious/tasp/cron/suspicious.py +++ b/Suspicious/Suspicious/tasp/cron/suspicious.py @@ -4,11 +4,21 @@ from chromadb.config import Settings from .utils import load_config from .models import CronConfig +from case_handler.models import Case logger = logging.getLogger("cron.suspicious") cleanup_logger = logging.getLogger("tasp.cron.cleanup_phishing") CONFIG_PATH = "/app/settings.json" +def check_challengeable(): + """ + Check if cases are challengeable and update their status accordingly. + """ + cases = Case.objects.filter(is_challengeable=True) + for case in cases: + if not case.was_published_recently(): + case.is_challengeable = False + case.save(update_fields=["is_challengeable"]) def remove_old_suspicious_emails(config_path: str = CONFIG_PATH, threshold_days: int = 15) -> None: cfg: CronConfig = load_config(config_path) diff --git a/Suspicious/Suspicious/tasp/static/js/caseScriptUser.js b/Suspicious/Suspicious/tasp/static/js/caseScriptUser.js index 100c44d5..65660faf 100644 --- a/Suspicious/Suspicious/tasp/static/js/caseScriptUser.js +++ b/Suspicious/Suspicious/tasp/static/js/caseScriptUser.js @@ -245,7 +245,35 @@ $(document).ready(function () { const caseIdList = document.querySelectorAll(".caseid"); const testList = document.querySelectorAll(".tests"); const resultList = document.querySelectorAll(".result"); + const challengeButtons = document.querySelectorAll(".challengebtn"); + challengeButtons.forEach((challengeButton) => { + challengeButton.addEventListener("click", async () => { + const hidden = challengeButton.value; + const caseId = hidden; + + // hide the button + challengeButton.style.display = "none"; + + const challenge = confirm("Are you sure you want to challenge the results?"); + if (challenge) { + try { + const response = await fetch(`../challenge/${caseId}`); + const result = await response.json(); + if (result.success) { + alert("Challenge sent"); + // replace the button with a message + const parentDiv = challengeButton.parentElement; + parentDiv.innerHTML = ` +

Not Challengeable

+ `; + } + } catch (error) { + console.error(`Error sending challenge: ${error}`); + } + } + }); + }); diff --git a/Suspicious/Suspicious/templates/tasp/index.html b/Suspicious/Suspicious/templates/tasp/index.html index 5289a81e..eebad93b 100644 --- a/Suspicious/Suspicious/templates/tasp/index.html +++ b/Suspicious/Suspicious/templates/tasp/index.html @@ -57,6 +57,10 @@

Your latest submissions:

+ + + + @@ -110,6 +114,14 @@

Your latest submissions:

Default {% endif %} {{case.results}} + +
+ {% if case.is_challengeable %} + + {% else %} +

Not Challengeable

+ {% endif %} + {% endfor %} From d1214a2dd8ddc178ce89edacbbf06eae030f9840 Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Mon, 2 Feb 2026 16:32:56 +0100 Subject: [PATCH 14/19] update index html --- Suspicious/Suspicious/templates/tasp/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Suspicious/Suspicious/templates/tasp/index.html b/Suspicious/Suspicious/templates/tasp/index.html index eebad93b..4a9e8640 100644 --- a/Suspicious/Suspicious/templates/tasp/index.html +++ b/Suspicious/Suspicious/templates/tasp/index.html @@ -116,7 +116,7 @@

Your latest submissions:

{{case.results}}
- {% if case.is_challengeable %} + {% if case.is_challengeable or case.is_challenged %} {% else %}

Not Challengeable

From 852e12cf96d3d183c35f333a577fe4d1580aa312 Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Mon, 2 Feb 2026 16:33:11 +0100 Subject: [PATCH 15/19] index updated again --- Suspicious/Suspicious/templates/tasp/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Suspicious/Suspicious/templates/tasp/index.html b/Suspicious/Suspicious/templates/tasp/index.html index 4a9e8640..265d3946 100644 --- a/Suspicious/Suspicious/templates/tasp/index.html +++ b/Suspicious/Suspicious/templates/tasp/index.html @@ -116,7 +116,7 @@

Your latest submissions:

{{case.results}}
- {% if case.is_challengeable or case.is_challenged %} + {% if case.is_challengeable and not case.is_challenged %} {% else %}

Not Challengeable

From 0ef1bc4ac28d312fcccee0d700434d742862db44 Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Tue, 3 Feb 2026 12:11:47 +0100 Subject: [PATCH 16/19] case challengeable state --- .../migrations/0008_case_is_challengeable.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 Suspicious/Suspicious/case_handler/migrations/0008_case_is_challengeable.py diff --git a/Suspicious/Suspicious/case_handler/migrations/0008_case_is_challengeable.py b/Suspicious/Suspicious/case_handler/migrations/0008_case_is_challengeable.py new file mode 100644 index 00000000..b32a567f --- /dev/null +++ b/Suspicious/Suspicious/case_handler/migrations/0008_case_is_challengeable.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.10 on 2026-02-02 16:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('case_handler', '0007_casechallengetoken'), + ] + + operations = [ + migrations.AddField( + model_name='case', + name='is_challengeable', + field=models.BooleanField(default=True), + ), + ] From 225eaabe0ccb12de0ddc9f2af82936596d1c2f11 Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Thu, 19 Feb 2026 15:12:06 +0100 Subject: [PATCH 17/19] hot fix -- Campaign dashboard --- .../analyzers_services/ai/service.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Suspicious/Suspicious/score_process/scoring/cortex_analyzers/analyzers_services/ai/service.py b/Suspicious/Suspicious/score_process/scoring/cortex_analyzers/analyzers_services/ai/service.py index e1ca1a18..9f02eae6 100644 --- a/Suspicious/Suspicious/score_process/scoring/cortex_analyzers/analyzers_services/ai/service.py +++ b/Suspicious/Suspicious/score_process/scoring/cortex_analyzers/analyzers_services/ai/service.py @@ -140,6 +140,18 @@ def process(self): if malscore_val <= 6.5: logger.info("Mail not considered dangerous") + try: + logger.info("Adding mail to suspicious collection...") + add_to_suspicious_collection( + self.full, + "", + "", + self.suspicious_case_id, + suspicious_collection, + ) + logger.info("Mail added to suspicious collection!") + except Exception as e: + logger.error(f"Error adding to suspicious collection: {e}") return response logger.info("Mail considered dangerous") From 29a2e8291be8370afb3fd30576af84d1c0acd733 Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Thu, 19 Feb 2026 15:47:07 +0100 Subject: [PATCH 18/19] temporary removal --- Suspicious/Suspicious/templates/tasp/index.html | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Suspicious/Suspicious/templates/tasp/index.html b/Suspicious/Suspicious/templates/tasp/index.html index 265d3946..5289a81e 100644 --- a/Suspicious/Suspicious/templates/tasp/index.html +++ b/Suspicious/Suspicious/templates/tasp/index.html @@ -57,10 +57,6 @@

Your latest submissions:

- - - - @@ -114,14 +110,6 @@

Your latest submissions:

Default {% endif %} {{case.results}} - -
- {% if case.is_challengeable and not case.is_challenged %} - - {% else %} -

Not Challengeable

- {% endif %} - {% endfor %} From 4c7fcf1bb5ae64f888a75aca4e5fd000f8d245f8 Mon Sep 17 00:00:00 2001 From: TheoBhang Date: Fri, 20 Feb 2026 09:39:56 +0100 Subject: [PATCH 19/19] added missing requirement --- Suspicious/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/Suspicious/requirements.txt b/Suspicious/requirements.txt index 6db61b6a..8b5ebc4a 100644 --- a/Suspicious/requirements.txt +++ b/Suspicious/requirements.txt @@ -61,3 +61,4 @@ docutils==0.21.2 Sphinx==8.2.3 sphinx-rtd-theme==3.0.2 myst-parser==4.0.1 +pillow==12.1.1