Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ddbe158
hot fixes : changed order in function parameters
TheoBhang Jan 26, 2026
818c1f5
Testing new backend using the already existing minio
TheoBhang Jan 27, 2026
f1c3e07
added MailAdress to denylist checks
TheoBhang Jan 27, 2026
c42b1f1
added challenge on api
TheoBhang Jan 27, 2026
27ce45d
Merge branch 'thalesgroup-cert:main' into feature/minioBackend
TheoBhang Jan 28, 2026
f04dde8
Add one-time challenge tokens for cases
TheoBhang Jan 28, 2026
b18e548
Merge branch 'feature/minioBackend' into codex/implement-secure-one-t…
TheoBhang Jan 28, 2026
a8c5bba
Merge pull request #2 from TheoBhang/codex/implement-secure-one-time-…
TheoBhang Jan 28, 2026
67b016e
fixed mixed code
TheoBhang Jan 28, 2026
3908950
Merge pull request #1 from TheoBhang/feature/minioBackend
TheoBhang Jan 28, 2026
4e26094
removed duplicates
TheoBhang Jan 28, 2026
d5f0562
Feature add minio backend + challenge token in final email (#26)
TheoBhang Jan 28, 2026
8b1c2fa
updated dashboard
TheoBhang Feb 2, 2026
a2e238c
fixed typo
TheoBhang Feb 2, 2026
76f2a62
Merge branch 'test' into main
TheoBhang Feb 2, 2026
483ae73
updated models and migration
TheoBhang Feb 2, 2026
fd0e487
Added preview email and improved email final
TheoBhang Feb 2, 2026
327a2d7
test on challenge
TheoBhang Feb 2, 2026
d1214a2
update index html
TheoBhang Feb 2, 2026
852e12c
index updated again
TheoBhang Feb 2, 2026
0ef1bc4
case challengeable state
TheoBhang Feb 3, 2026
225eaab
hot fix -- Campaign dashboard
TheoBhang Feb 19, 2026
29a2e82
temporary removal
TheoBhang Feb 19, 2026
fcfcb60
Merge pull request #31 from TheoBhang/main
TheoBhang Feb 19, 2026
4c7fcf1
added missing requirement
TheoBhang Feb 20, 2026
f993b28
Merge pull request #32 from TheoBhang/main
TheoBhang Feb 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Suspicious/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
6 changes: 6 additions & 0 deletions Suspicious/Suspicious/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

# Downloads
DownloadCaseArchiveView,
CaseChallengeTokenView,
)
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
urlpatterns = [
Expand Down Expand Up @@ -69,4 +70,9 @@
DownloadCaseArchiveView.as_view(),
name="case-download",
),
path(
"cases/<int:case_id>/challenge",
CaseChallengeTokenView.as_view(),
name="case-challenge",
),
]
49 changes: 46 additions & 3 deletions Suspicious/Suspicious/api/views.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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
from django_filters.rest_framework import DjangoFilterBackend

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,
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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"], name="cct_case_expires_idx"),
models.Index(fields=["case", "used_at"], name="cct_case_used_idx"),
],
},
),
]
Original file line number Diff line number Diff line change
@@ -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),
),
]
65 changes: 65 additions & 0 deletions Suspicious/Suspicious/case_handler/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
"""
Expand Down Expand Up @@ -52,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)
Expand All @@ -78,6 +87,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"], name="cct_case_expires_idx"),
models.Index(fields=["case", "used_at"], name="cct_case_used_idx"),
]

@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).
Expand Down
48 changes: 48 additions & 0 deletions Suspicious/Suspicious/mail_feeder/email_handler/email_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.email_preview.eml2png_renderer import Eml2PngRenderer
import os

fetch_mail_logger = logging.getLogger("tasp.cron.fetch_and_process_emails")

Expand All @@ -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]:
"""
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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/'),
),
]
1 change: 1 addition & 0 deletions Suspicious/Suspicious/mail_feeder/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading