This commit is contained in:
@@ -36,6 +36,10 @@ SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_USE_TLS=1
|
||||
M365_TENANT_ID=
|
||||
M365_CLIENT_ID=
|
||||
M365_CLIENT_SECRET=
|
||||
M365_MAILBOX_USER=
|
||||
|
||||
# Roteerbare Fernet-sleutels voor app-wachtwoorden; actieve sleutel eerst.
|
||||
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
|
||||
@@ -74,6 +74,10 @@ SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_USE_TLS=1
|
||||
M365_TENANT_ID=
|
||||
M365_CLIENT_ID=
|
||||
M365_CLIENT_SECRET=
|
||||
M365_MAILBOX_USER=
|
||||
|
||||
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
MAILBOX_CREDENTIAL_KEYS=
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
verify-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify release
|
||||
run: ./scripts/codex_verify.sh
|
||||
- name: Build immutable image
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
registry="gitea.itworx.tech"
|
||||
image="${registry}/jens/vacatureradar:${version}"
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin
|
||||
docker build --file Dockerfile.unraid \
|
||||
--build-arg "VCS_REF=${GITHUB_SHA}" \
|
||||
--build-arg "APP_VERSION=${version}" \
|
||||
--tag "$image" .
|
||||
docker push "$image"
|
||||
docker image inspect "$image" --format '{{index .RepoDigests 0}}' > image-digest.txt
|
||||
docker sbom "$image" --format spdx-json > sbom.spdx.json
|
||||
cat > RELEASE_NOTES.md <<EOF
|
||||
# VacatureRadar ${version}
|
||||
|
||||
Immutable release image built from commit ${GITHUB_SHA}.
|
||||
The accompanying digest, SPDX SBOM and checksums are release evidence.
|
||||
EOF
|
||||
sha256sum image-digest.txt sbom.spdx.json RELEASE_NOTES.md > SHA256SUMS
|
||||
- name: Publish release evidence
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-evidence-${{ github.ref_name }}
|
||||
path: |
|
||||
image-digest.txt
|
||||
sbom.spdx.json
|
||||
SHA256SUMS
|
||||
RELEASE_NOTES.md
|
||||
@@ -1,6 +1,12 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM python:3.13-slim-trixie
|
||||
|
||||
ARG VCS_REF=unknown
|
||||
ARG APP_VERSION=dev
|
||||
LABEL org.opencontainers.image.source="https://gitea.itworx.tech/Jens/VacatureRadar" \
|
||||
org.opencontainers.image.revision=$VCS_REF \
|
||||
org.opencontainers.image.version=$APP_VERSION
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Microsoft365OAuthError(RuntimeError):
|
||||
"""Safe OAuth boundary error that never includes tokens or client secrets."""
|
||||
|
||||
|
||||
def acquire_exchange_access_token(*, client_factory=None) -> str:
|
||||
tenant_id = settings.M365_TENANT_ID.strip()
|
||||
client_id = settings.M365_CLIENT_ID.strip()
|
||||
client_secret = settings.M365_CLIENT_SECRET.strip()
|
||||
if not all((tenant_id, client_id, client_secret)):
|
||||
raise Microsoft365OAuthError("Microsoft 365 OAuth is niet volledig geconfigureerd.")
|
||||
|
||||
if client_factory is None:
|
||||
import msal
|
||||
|
||||
client_factory = msal.ConfidentialClientApplication
|
||||
factory = client_factory
|
||||
client = factory(
|
||||
client_id,
|
||||
authority=f"https://login.microsoftonline.com/{tenant_id}",
|
||||
client_credential=client_secret,
|
||||
)
|
||||
result = client.acquire_token_for_client(scopes=[settings.M365_EXCHANGE_SCOPE])
|
||||
token = result.get("access_token") if isinstance(result, dict) else None
|
||||
if not isinstance(token, str) or not token:
|
||||
category = "unknown"
|
||||
if isinstance(result, dict):
|
||||
category = str(result.get("error") or "unknown")[:80]
|
||||
raise Microsoft365OAuthError(
|
||||
f"Microsoft 365 OAuth-token kon niet worden verkregen ({category})."
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def xoauth2_bytes(username: str, access_token: str) -> bytes:
|
||||
if not username or "\x00" in username or "\r" in username or "\n" in username:
|
||||
raise Microsoft365OAuthError("Ongeldig Microsoft 365-mailboxadres.")
|
||||
return f"user={username}\x01auth=Bearer {access_token}\x01\x01".encode()
|
||||
|
||||
|
||||
def xoauth2_b64(username: str, access_token: str) -> str:
|
||||
return base64.b64encode(xoauth2_bytes(username, access_token)).decode("ascii")
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
from email.utils import parseaddr
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.base import BaseEmailBackend
|
||||
|
||||
from apps.core.services.microsoft365 import (
|
||||
Microsoft365OAuthError,
|
||||
acquire_exchange_access_token,
|
||||
xoauth2_b64,
|
||||
)
|
||||
from apps.sources.services.tls import trusted_tls_context
|
||||
|
||||
|
||||
class Microsoft365OAuthEmailBackend(BaseEmailBackend):
|
||||
"""Send mail through Exchange Online with app-only SASL XOAUTH2."""
|
||||
|
||||
def send_messages(self, email_messages) -> int:
|
||||
if not email_messages:
|
||||
return 0
|
||||
sent = 0
|
||||
try:
|
||||
token = acquire_exchange_access_token()
|
||||
username = settings.M365_MAILBOX_USER.strip()
|
||||
if not username:
|
||||
raise Microsoft365OAuthError(
|
||||
"M365_MAILBOX_USER is verplicht voor Microsoft 365-mailuitvoer."
|
||||
)
|
||||
with smtplib.SMTP(
|
||||
settings.EMAIL_HOST or "smtp.office365.com",
|
||||
settings.EMAIL_PORT,
|
||||
timeout=settings.M365_SMTP_TIMEOUT_SECONDS,
|
||||
) as client:
|
||||
client.ehlo()
|
||||
client.starttls(context=trusted_tls_context())
|
||||
client.ehlo()
|
||||
code, _ = client.docmd("AUTH", f"XOAUTH2 {xoauth2_b64(username, token)}")
|
||||
if code != 235:
|
||||
raise Microsoft365OAuthError("Microsoft 365 SMTP OAuth geweigerd.")
|
||||
for message in email_messages:
|
||||
recipients = message.recipients()
|
||||
if not recipients:
|
||||
continue
|
||||
sender = parseaddr(message.from_email or settings.DEFAULT_FROM_EMAIL)[1]
|
||||
client.sendmail(
|
||||
sender or username,
|
||||
recipients,
|
||||
message.message().as_bytes(linesep="\r\n"),
|
||||
)
|
||||
sent += 1
|
||||
except Exception:
|
||||
if self.fail_silently:
|
||||
return sent
|
||||
raise
|
||||
return sent
|
||||
@@ -9,7 +9,10 @@ class MailboxConnectionForm(forms.ModelForm):
|
||||
password = forms.CharField(
|
||||
required=False,
|
||||
label="App-wachtwoord",
|
||||
help_text="Laat leeg bij wijzigen om het bestaande app-wachtwoord te behouden.",
|
||||
help_text=(
|
||||
"Alleen voor Gmail of een aangepaste provider. Microsoft 365 gebruikt "
|
||||
"de centrale OAuth-configuratie."
|
||||
),
|
||||
widget=forms.PasswordInput(
|
||||
attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"},
|
||||
render_value=False,
|
||||
@@ -50,7 +53,8 @@ class MailboxConnectionForm(forms.ModelForm):
|
||||
data = super().clean()
|
||||
if data.get("provider") != MailboxConnection.Provider.CUSTOM:
|
||||
data["custom_host"] = ""
|
||||
if not self.instance.pk and not data.get("password"):
|
||||
uses_oauth = data.get("provider") == MailboxConnection.Provider.OUTLOOK
|
||||
if not self.instance.pk and not uses_oauth and not data.get("password"):
|
||||
self.add_error("password", "Een app-wachtwoord is verplicht voor een nieuwe koppeling.")
|
||||
return data
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("sources", "0007_alter_mailboxconnection_platform")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="mailboxconnection",
|
||||
name="encrypted_password",
|
||||
field=models.TextField(blank=True),
|
||||
)
|
||||
]
|
||||
@@ -357,7 +357,7 @@ class MailboxConnection(TimeStampedModel):
|
||||
custom_host = models.CharField(max_length=255, blank=True)
|
||||
port = models.PositiveIntegerField(default=993)
|
||||
username = models.CharField(max_length=320)
|
||||
encrypted_password = models.TextField()
|
||||
encrypted_password = models.TextField(blank=True)
|
||||
mailbox = models.CharField(max_length=255, default="INBOX")
|
||||
enabled = models.BooleanField(default=True)
|
||||
poll_interval_minutes = models.PositiveIntegerField(
|
||||
|
||||
@@ -74,14 +74,19 @@ def _validate_connected_peer(
|
||||
*,
|
||||
before: ValidatedUrl,
|
||||
after: ValidatedUrl,
|
||||
require_peer: bool,
|
||||
) -> None:
|
||||
peer_ip = _connected_peer_ip(response)
|
||||
if peer_ip is not None:
|
||||
if not peer_ip.is_global:
|
||||
raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}")
|
||||
return
|
||||
if require_peer:
|
||||
raise FetchError(
|
||||
"Verbonden IP-adres kon niet veilig worden vastgesteld; aanvraag geblokkeerd."
|
||||
)
|
||||
# Mock/custom transports do not always expose the peer socket. Keep the conservative
|
||||
# DNS-overlap fallback for those transports.
|
||||
# DNS-overlap fallback only for explicitly injected test/custom clients.
|
||||
if not set(before.addresses).intersection(after.addresses):
|
||||
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
|
||||
|
||||
@@ -175,6 +180,7 @@ def fetch_url(
|
||||
response,
|
||||
before=validation,
|
||||
after=post_validation,
|
||||
require_peer=own_client,
|
||||
)
|
||||
if response.status_code in {301, 302, 303, 307, 308}:
|
||||
location = response.headers.get("location")
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from apps.core.services.microsoft365 import acquire_exchange_access_token, xoauth2_bytes
|
||||
from apps.sources.models import EmailMessageRecord, MailboxConnection
|
||||
from apps.sources.services.email_import import ingest_email, message_identity
|
||||
from apps.sources.services.mailbox_connections import decrypt_mailbox_password
|
||||
@@ -30,7 +31,11 @@ def poll_imap_mailbox(
|
||||
if client_factory is None:
|
||||
client_factory = imaplib.IMAP4_SSL
|
||||
host_validator(f"https://{connection.imap_host}")
|
||||
password = decrypt_mailbox_password(connection)
|
||||
password = (
|
||||
""
|
||||
if connection.provider == MailboxConnection.Provider.OUTLOOK
|
||||
else decrypt_mailbox_password(connection)
|
||||
)
|
||||
client_options = {"timeout": settings.IMAP_CONNECT_TIMEOUT_SECONDS}
|
||||
if client_factory is imaplib.IMAP4_SSL:
|
||||
client_options["ssl_context"] = trusted_tls_context()
|
||||
@@ -42,7 +47,13 @@ def poll_imap_mailbox(
|
||||
"failed": 0,
|
||||
}
|
||||
try:
|
||||
client.login(connection.username, password)
|
||||
if connection.provider == MailboxConnection.Provider.OUTLOOK:
|
||||
access_token = acquire_exchange_access_token()
|
||||
client.authenticate(
|
||||
"XOAUTH2", lambda _challenge: xoauth2_bytes(connection.username, access_token)
|
||||
)
|
||||
else:
|
||||
client.login(connection.username, password)
|
||||
status, _ = client.select(connection.mailbox, readonly=True)
|
||||
if status != "OK":
|
||||
raise RuntimeError("IMAP-mailbox kon niet worden geopend")
|
||||
|
||||
@@ -48,7 +48,9 @@ def decrypt_mailbox_password(connection: MailboxConnection) -> str:
|
||||
def rotate_mailbox_credentials() -> int:
|
||||
"""Re-encrypt every mailbox password with the first configured key."""
|
||||
rotated = 0
|
||||
for connection in MailboxConnection.objects.select_for_update().all():
|
||||
for connection in MailboxConnection.objects.select_for_update().exclude(
|
||||
provider=MailboxConnection.Provider.OUTLOOK
|
||||
):
|
||||
password = decrypt_mailbox_password(connection)
|
||||
connection.encrypted_password = encrypt_mailbox_password(password)
|
||||
connection.save(update_fields=["encrypted_password", "updated_at"])
|
||||
@@ -79,7 +81,10 @@ def save_mailbox_connection(
|
||||
"poll_interval_minutes",
|
||||
):
|
||||
setattr(connection, field, cleaned_data[field])
|
||||
if password:
|
||||
uses_oauth = connection.provider == MailboxConnection.Provider.OUTLOOK
|
||||
if uses_oauth:
|
||||
connection.encrypted_password = ""
|
||||
elif password:
|
||||
connection.encrypted_password = encrypt_mailbox_password(password)
|
||||
elif not connection.encrypted_password:
|
||||
raise MailboxCredentialError("Een app-wachtwoord is verplicht.")
|
||||
|
||||
@@ -298,6 +298,12 @@ EMAIL_PORT = int(os.getenv("SMTP_PORT", "587"))
|
||||
EMAIL_HOST_USER = os.getenv("SMTP_USER", "")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True)
|
||||
M365_TENANT_ID = os.getenv("M365_TENANT_ID", "")
|
||||
M365_CLIENT_ID = os.getenv("M365_CLIENT_ID", "")
|
||||
M365_CLIENT_SECRET = os.getenv("M365_CLIENT_SECRET", "")
|
||||
M365_MAILBOX_USER = os.getenv("M365_MAILBOX_USER", EMAIL_HOST_USER)
|
||||
M365_EXCHANGE_SCOPE = "https://outlook.office365.com/.default"
|
||||
M365_SMTP_TIMEOUT_SECONDS = env_int_range("M365_SMTP_TIMEOUT_SECONDS", 20, minimum=3, maximum=120)
|
||||
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.unraid
|
||||
image: vacatureradar:unraid
|
||||
image: ${VACATURERADAR_IMAGE:-vacatureradar:unraid}
|
||||
container_name: VacatureRadar
|
||||
env_file:
|
||||
- ${VACATURERADAR_ENV_FILE:-.env}
|
||||
|
||||
+13
-6
@@ -1356,22 +1356,25 @@ tasks:
|
||||
summary: Configureer de gewenste afzonderlijke platformmailboxen via de beveiligde UI, de Fernet-secretkey en
|
||||
digestrecipient; test read-only import en mailverzending zonder credentials in Git/logs.
|
||||
acceptance_criteria:
|
||||
- Iedere platformmailbox gebruikt TLS, een dedicated account/app-wachtwoord en read-only mailboxselectie.
|
||||
- Iedere platformmailbox gebruikt TLS, een dedicated alias en read-only mailboxselectie; Microsoft 365 gebruikt
|
||||
app-only OAuth/XOAUTH2 en geen Basic Auth of app-wachtwoord.
|
||||
- Dezelfde live testmail importeert per mailbox idempotent en alle geconfigureerde intervallen worden door de
|
||||
scheduler gerespecteerd.
|
||||
- SMTP verstuurt één testdigest en outboxstatus klopt.
|
||||
- De encryptiesleutel staat alleen in de productie-secretstore/.env; app-wachtwoorden zijn als ciphertext in de
|
||||
database aanwezig en sleutelrotatie is live getest.
|
||||
- OAuth-clientcredentials en de encryptiesleutel staan alleen in de productie-secretstore/.env; eventuele
|
||||
Gmail/custom-app-wachtwoorden zijn als ciphertext opgeslagen en sleutelrotatie is live getest.
|
||||
verification:
|
||||
- Volg Unraid mailboxchecklist
|
||||
- Inspecteer logs op afwezigheid van secret/payload
|
||||
primary_paths:
|
||||
- /mnt/user/appdata/vacatureradar/config/.env
|
||||
- Bronnen -> Platformmailboxen
|
||||
external_blocker: Vereist mailbox-/SMTP-account en app-password van de gebruiker.
|
||||
external_blocker: De dedicated productie-alias, Entra-appregistratie, admin consent en Exchange-mailboxscope zijn
|
||||
op 2026-07-30 live ingericht; resterend zijn de productie-uitrol en echte platformalertmails voor de
|
||||
import-/digestproef.
|
||||
- id: VR-203
|
||||
title: Productiedomein, TLS en reverse proxy activeren
|
||||
status: blocked-external
|
||||
status: done
|
||||
priority: P0
|
||||
requirement_ids:
|
||||
- NFR-001
|
||||
@@ -1393,7 +1396,11 @@ tasks:
|
||||
primary_paths:
|
||||
- Unraid reverse proxy
|
||||
- /mnt/user/appdata/vacatureradar/config/.env
|
||||
external_blocker: Vereist gekozen domein/DNS en toegang tot de Unraid reverse proxy.
|
||||
result:
|
||||
completed_at: '2026-07-29'
|
||||
note: Publiek domein, HTTP-naar-HTTPS, TLS/securityheaders, DEBUG=False, secure cookies, CSRF-gastlogin, kernroutes
|
||||
en readiness live bewezen in docs/audit/FINAL_ACCEPTANCE.md op 2026-07-29; interne diensten zijn uitsluitend
|
||||
containerintern bereikbaar.
|
||||
- id: VR-204
|
||||
title: Immutable containerimage publiceren
|
||||
status: blocked-external
|
||||
|
||||
@@ -30,7 +30,37 @@ Productieoplevering 0.3.14 op 2026-07-29:
|
||||
- de pre-releasecustomdump, media en composeconfig staan checksum-geldig onder `/mnt/user/appdata/VacatureRadar/backups/release-0.3.14-20260729T164500Z`; rollbackimage `vacatureradar:rollback-0.3.13` wijst naar `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`;
|
||||
- productie is healthy zonder restarts, versie 0.3.14 is publiek zichtbaar, alle gastkernroutes antwoorden 200 en een echte importsmoke creëerde één job binnen een transactie waarna rollback expliciet bevestigd werd.
|
||||
|
||||
Volgende uitvoerbare taak: geen na afronding van VR-228. Alleen de bestaande optionele externe identity- en mailboxacties blijven open.
|
||||
Volgende uitvoerbare taak: geen na afronding van VR-228. Alleen de optionele
|
||||
mailbox-/identityconfiguratie (`VR-202`) en publicatie naar een externe private
|
||||
registry (`VR-204`) blijven extern geblokkeerd.
|
||||
|
||||
## Laatste risicohardening
|
||||
|
||||
Live mailboxvoorbereiding op 2026-07-30:
|
||||
|
||||
- Exchange Online bevestigde dat de dedicated VacatureRadar-alias aan de productie-mailbox is toegevoegd;
|
||||
- de alias is niet primair gemaakt en bestaande adressen zijn ongewijzigd gebleven;
|
||||
- de Entra-appregistratie, admin consent en Exchange-mailboxscope zijn live ingericht
|
||||
met app-only OAuth; tijdelijke bootstraprechten en de bootstrap-app zijn na afloop verwijderd;
|
||||
- `VR-202` resteert alleen nog op de productie-uitrol en echte platformalert-/digestproeven;
|
||||
Basic Auth wordt niet geactiveerd;
|
||||
- de volledige lokale gate is groen met 324 tests, 0 failures, 0 skips en 84,05%
|
||||
branch-aware dekking.
|
||||
|
||||
Risicohardening op 2026-07-29:
|
||||
|
||||
- de productiefetcher blokkeert nu fail-closed wanneer de HTTP-transportlaag het
|
||||
werkelijk verbonden peer-IP niet beschikbaar stelt; ieder niet-publiek peer-IP
|
||||
bleef al geblokkeerd;
|
||||
- alleen expliciet geïnjecteerde test/custom clients mogen nog de begrensde
|
||||
DNS-overlapfallback gebruiken; een negatieve regressietest dekt de
|
||||
productiegrens;
|
||||
- `VR-203` is administratief afgerond op basis van het bestaande live bewijs in
|
||||
`docs/audit/FINAL_ACCEPTANCE.md`: publieke HTTP→HTTPS, TLS/securityheaders,
|
||||
veilige cookies, CSRF-gastlogin, kernroutes en readiness zijn aantoonbaar
|
||||
geslaagd;
|
||||
- verificatie: Ruff groen, 8 gerichte fetchertests geslaagd en de volledige gate
|
||||
groen met 319 tests, 0 failures, 0 skips en 84,21% branch-aware dekking.
|
||||
|
||||
Productieoplevering 0.3.13 op 2026-07-29:
|
||||
|
||||
@@ -554,13 +584,18 @@ Deze blokkeren onafhankelijke code niet:
|
||||
|
||||
- verdere persoonlijke werkgevers-/bronnenkeuze en actuele menselijke review buiten de actieve VITO-/Deliverect-startercatalogus;
|
||||
- live IMAP/SMTP-account en app-password;
|
||||
- Unraid-domein, DNS, TLS en reverse proxy;
|
||||
- containerregistry en credentials;
|
||||
- actieve/voor deze repository geregistreerde Gitea Actions-runner;
|
||||
|
||||
## Bekende verificatiebeperking van de aangeleverde basis
|
||||
|
||||
De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn geverifieerd. De optionele pytest-Playwrightvarianten vallen lokaal terug op de HTML/a11y-probe; VR-119 is aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op het begeleide profiel en de live vacaturefeed en VR-124 op de acht platformmailboxen en alertkaarten. Een publieke HTTPS-smoke blijft afhankelijk van domein-, DNS- en reverse-proxyconfiguratie.
|
||||
De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn
|
||||
geverifieerd. De verplichte Playwright-gate draait lokaal met Chromium; VR-119 is
|
||||
aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op
|
||||
het begeleide profiel en de live vacaturefeed en VR-124 op de acht
|
||||
platformmailboxen en alertkaarten. Publieke HTTPS, proxyheaders en de gastflow
|
||||
zijn op 2026-07-29 live geaccepteerd en vastgelegd in
|
||||
`docs/audit/FINAL_ACCEPTANCE.md`.
|
||||
|
||||
## Hervatten
|
||||
|
||||
|
||||
@@ -77,6 +77,11 @@ celery -A config inspect reserved
|
||||
## Digest ontbreekt of dubbel
|
||||
|
||||
- Controleer actief profiel, digesttijd/zone en `DIGEST_RECIPIENT`.
|
||||
- Microsoft 365 gebruikt uitsluitend app-only OAuth. Configureer `M365_TENANT_ID`,
|
||||
`M365_CLIENT_ID`, `M365_CLIENT_SECRET`, `M365_MAILBOX_USER` en
|
||||
`EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`.
|
||||
Verleen alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp`, admin consent en mailboxscope;
|
||||
Basic Auth en Microsoft 365-app-wachtwoorden worden niet ondersteund.
|
||||
- Controleer `DigestOutbox` op datum en status.
|
||||
- Bij SMTP-fout: corrigeer configuratie en retry dezelfde outbox gecontroleerd.
|
||||
- Maak geen tweede outboxrecord handmatig; uniciteit per profiel/datum is de duplicaatbarrière.
|
||||
|
||||
@@ -164,7 +164,9 @@ IMAP_MAX_MESSAGES_PER_POLL=200
|
||||
IMAP_MAX_MESSAGE_BYTES=1000000
|
||||
```
|
||||
|
||||
Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Kies Gmail, Outlook / Microsoft 365 of een aangepaste publieke IMAP-host, gebruik een app-wachtwoord en laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is.
|
||||
Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Gmail en aangepaste publieke IMAP-hosts gebruiken een app-wachtwoord. Outlook / Microsoft 365 gebruikt centraal app-only OAuth via de vier `M365_*`-waarden en `EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`; geef de Entra-app alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp` en beperk de Exchange-serviceprincipal tot de bedoelde mailbox. Laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is.
|
||||
|
||||
Voor een immutable registryrelease zet je `VACATURERADAR_IMAGE=gitea.itworx.tech/jens/vacatureradar:<versie>@sha256:<digest>` en start je met `docker compose -f docker-compose.unraid.yml up -d --no-build`.
|
||||
|
||||
Voor sleutelrotatie zet je `nieuwe-sleutel,oude-sleutel` in `MAILBOX_CREDENTIAL_KEYS`, herstart je de processen en draai je:
|
||||
|
||||
|
||||
@@ -115,7 +115,10 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
|
||||
|
||||
## Resterende risico's
|
||||
|
||||
- De fetcher controleert het werkelijk verbonden peer-IP wanneer httpcore dat exposeert. Een custom transport dat geen peerinformatie aanbiedt valt terug op DNS-set-overlap; volledige IP-pinning blijft een aanvullend egress-hardeningniveau.
|
||||
- De productiefetcher blokkeert fail-closed wanneer httpcore het werkelijk verbonden
|
||||
peer-IP niet exposeert en weigert ieder niet-publiek peer-IP. Alleen een expliciet
|
||||
geïnjecteerde test/custom client mag terugvallen op DNS-set-overlap; die clientgrens
|
||||
mag daarom niet voor productie-fetches worden gebruikt.
|
||||
- Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn in productie begrensd via gedeelde Redis-rate-limiting en blokkades op login/manual-import. Correcte individuele client-IPherkenning vereist het exacte proxy-CIDR.
|
||||
- Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen.
|
||||
- Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet.
|
||||
@@ -150,3 +153,8 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
|
||||
- Impact: herhaalde herstarts van dezelfde bron veroorzaken extra belasting
|
||||
- Mitigatie: cooldown plus metadataflag voor eenmalige canary, en trialstatus met beperkte runtrigger
|
||||
- Verificatie: `tests/integration/test_source_health.py`
|
||||
# Microsoft 365 OAuth
|
||||
|
||||
- Exchange Online gebruikt app-only XOAUTH2; Basic Auth en app-wachtwoorden zijn uitgesloten.
|
||||
- Client secrets leven alleen in de productie-secretstore en tokenfouten bevatten geen token of secret.
|
||||
- De serviceprincipal krijgt uitsluitend IMAP/SMTP-toegang tot de bedoelde mailbox; IMAP-selectie blijft read-only.
|
||||
|
||||
@@ -61,6 +61,7 @@ def test_user_can_link_edit_pause_and_sync_separate_platform_mailboxes(client, u
|
||||
)
|
||||
assert response.status_code == 302
|
||||
indeed = MailboxConnection.objects.get(user=user, platform="indeed")
|
||||
assert indeed.encrypted_password == ""
|
||||
|
||||
edit_response = client.get(reverse("sources:mailbox_edit", args=[vdab.pk]))
|
||||
assert edit_response.status_code == 200
|
||||
|
||||
@@ -8,6 +8,7 @@ from apps.sources.services.fetcher import (
|
||||
FetchTimeoutError,
|
||||
PolicyBlockedError,
|
||||
RateLimitedError,
|
||||
_validate_connected_peer,
|
||||
fetch_url,
|
||||
)
|
||||
from apps.sources.services.url_security import ValidatedUrl
|
||||
@@ -100,6 +101,24 @@ def test_fetcher_rejects_dns_rebinding(monkeypatch):
|
||||
client.close()
|
||||
|
||||
|
||||
def test_production_transport_fails_closed_without_connected_peer():
|
||||
validated = ValidatedUrl(
|
||||
url="https://example.org/jobs",
|
||||
hostname="example.org",
|
||||
port=443,
|
||||
addresses=("93.184.216.34",),
|
||||
)
|
||||
response = httpx.Response(200)
|
||||
|
||||
with pytest.raises(FetchError, match="Verbonden IP-adres"):
|
||||
_validate_connected_peer(
|
||||
response,
|
||||
before=validated,
|
||||
after=validated,
|
||||
require_peer=True,
|
||||
)
|
||||
|
||||
|
||||
class _PeerStream:
|
||||
def __init__(self, address: str) -> None:
|
||||
self.address = address
|
||||
|
||||
@@ -89,6 +89,19 @@ def test_rotation_reencrypts_existing_credentials_with_primary_key(user):
|
||||
assert decrypt_mailbox_password(connection) == "rotate-me"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rotation_skips_outlook_oauth_connections(user):
|
||||
MailboxConnection.objects.create(
|
||||
user=user,
|
||||
platform=MailboxConnection.Platform.INDEED,
|
||||
provider=MailboxConnection.Provider.OUTLOOK,
|
||||
username="vacatureradar@example.invalid",
|
||||
encrypted_password="",
|
||||
)
|
||||
|
||||
assert rotate_mailbox_credentials() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_private_imap_host_is_blocked_before_connection(user):
|
||||
key = Fernet.generate_key().decode("ascii")
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
|
||||
from apps.core.services.microsoft365 import (
|
||||
Microsoft365OAuthError,
|
||||
acquire_exchange_access_token,
|
||||
xoauth2_b64,
|
||||
)
|
||||
from apps.notifications.backends import Microsoft365OAuthEmailBackend
|
||||
|
||||
|
||||
@override_settings(
|
||||
M365_TENANT_ID="tenant-id",
|
||||
M365_CLIENT_ID="client-id",
|
||||
M365_CLIENT_SECRET="client-secret",
|
||||
M365_EXCHANGE_SCOPE="https://outlook.office365.com/.default",
|
||||
)
|
||||
def test_app_only_token_uses_tenant_authority_and_exchange_scope():
|
||||
calls = {}
|
||||
|
||||
class Client:
|
||||
def acquire_token_for_client(self, *, scopes):
|
||||
calls["scopes"] = scopes
|
||||
return {"access_token": "access-token"}
|
||||
|
||||
def factory(client_id, *, authority, client_credential):
|
||||
calls.update(
|
||||
client_id=client_id,
|
||||
authority=authority,
|
||||
client_credential=client_credential,
|
||||
)
|
||||
return Client()
|
||||
|
||||
assert acquire_exchange_access_token(client_factory=factory) == "access-token"
|
||||
assert calls == {
|
||||
"client_id": "client-id",
|
||||
"authority": "https://login.microsoftonline.com/tenant-id",
|
||||
"client_credential": "client-secret",
|
||||
"scopes": ["https://outlook.office365.com/.default"],
|
||||
}
|
||||
|
||||
|
||||
@override_settings(M365_TENANT_ID="", M365_CLIENT_ID="", M365_CLIENT_SECRET="")
|
||||
def test_missing_oauth_configuration_fails_without_secret_material():
|
||||
with pytest.raises(Microsoft365OAuthError, match="niet volledig") as error:
|
||||
acquire_exchange_access_token()
|
||||
assert "secret" not in str(error.value).lower()
|
||||
|
||||
|
||||
def test_xoauth2_payload_is_encoded_exactly():
|
||||
encoded = xoauth2_b64("mailbox@example.invalid", "token-value")
|
||||
assert base64.b64decode(encoded) == (
|
||||
b"user=mailbox@example.invalid\x01auth=Bearer token-value\x01\x01"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
M365_MAILBOX_USER="vacatureradar@example.invalid",
|
||||
EMAIL_HOST="smtp.office365.com",
|
||||
EMAIL_PORT=587,
|
||||
DEFAULT_FROM_EMAIL="VacatureRadar <vacatureradar@example.invalid>",
|
||||
M365_SMTP_TIMEOUT_SECONDS=10,
|
||||
)
|
||||
def test_oauth_smtp_backend_authenticates_and_sends(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class SMTP:
|
||||
def __init__(self, host, port, *, timeout):
|
||||
calls.append(("connect", host, port, timeout))
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return None
|
||||
|
||||
def ehlo(self):
|
||||
calls.append(("ehlo",))
|
||||
|
||||
def starttls(self, *, context):
|
||||
calls.append(("starttls", bool(context)))
|
||||
|
||||
def docmd(self, command, argument):
|
||||
calls.append((command, argument))
|
||||
return 235, b"ok"
|
||||
|
||||
def sendmail(self, sender, recipients, message):
|
||||
calls.append(("sendmail", sender, recipients, message))
|
||||
|
||||
monkeypatch.setattr("apps.notifications.backends.smtplib.SMTP", SMTP)
|
||||
monkeypatch.setattr(
|
||||
"apps.notifications.backends.acquire_exchange_access_token",
|
||||
lambda: "token-value",
|
||||
)
|
||||
from django.core.mail import EmailMessage as DjangoEmailMessage
|
||||
|
||||
django_message = DjangoEmailMessage(
|
||||
subject="Test",
|
||||
body="test",
|
||||
from_email="vacatureradar@example.invalid",
|
||||
to=["recipient@example.invalid"],
|
||||
)
|
||||
assert Microsoft365OAuthEmailBackend().send_messages([django_message]) == 1
|
||||
assert calls[0] == ("connect", "smtp.office365.com", 587, 10)
|
||||
assert any(call[0] == "AUTH" and call[1].startswith("XOAUTH2 ") for call in calls)
|
||||
assert any(call[0] == "sendmail" for call in calls)
|
||||
Reference in New Issue
Block a user