fix(M2): configuration, secrets et rédaction — correction des écarts FIXME_M2

Sécurité :
- ical_url : str → SecretStr | None (masquage dans str/repr/model_dump_json)
- En-têtes Authorization/Proxy-Authorization : masquage complet de la valeur
- _URL_PATTERN : insensible à la casse (HTTPS:// reconnu)
- redact_url() : masquage du nom d'utilisateur (userinfo complet)

Configuration :
- Sous-configs : Field(default_factory=...) pour rechargement à chaque appel
- Suppression du singleton settings (injection de dépendances)
- .env.example : ajout AI_PROVIDER et CALDAV_CALENDAR_PATH
- Guide : BLOG_RSS_ENABLED → BLOG_ENABLED, AI_MODEL=None par défaut
- .secrets.baseline : ligne décalée 5112 → 5117 (faux positif audité)
- pre-commit : ajout de pytest aux additional_dependencies du hook mypy

Tests : 15 tests (config + redaction) couvrant rechargement, masquage
SecretStr, en-têtes Authorization, URL auth intégrée et casse variable.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
2026-09-06 12:35:55 +02:00
parent a0ac75cec6
commit 2f6184df0b
8 changed files with 298 additions and 30 deletions

View File

@@ -13,6 +13,7 @@ PRONOTE_MESSAGES_SOURCE=pronotepy
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
CALDAV_USERNAME=user@example.com CALDAV_USERNAME=user@example.com
CALDAV_PASSWORD=your_caldav_password CALDAV_PASSWORD=your_caldav_password
CALDAV_CALENDAR_PATH=/pronote-sync/
# Fenêtre de synchronisation (jours) # Fenêtre de synchronisation (jours)
SYNC_PAST_DAYS=7 SYNC_PAST_DAYS=7
@@ -34,9 +35,10 @@ XMPP_TIMEOUT=30
# --- IA (optionnelle) --- # --- IA (optionnelle) ---
AI_ENABLED=true AI_ENABLED=true
AI_PROVIDER=openai
AI_BASE_URL=https://api.openai.com/v1 AI_BASE_URL=https://api.openai.com/v1
AI_API_KEY=your_ai_api_key AI_API_KEY=your_ai_api_key
AI_MODEL=gpt-4o-mini # AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
# --- Blog --- # --- Blog ---
BLOG_ENABLED=false BLOG_ENABLED=false

View File

@@ -26,7 +26,7 @@ repos:
name: mypy name: mypy
entry: mypy entry: mypy
language: python language: python
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0"] additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0"]
types: [python] types: [python]
pass_filenames: true pass_filenames: true

View File

@@ -141,7 +141,7 @@
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_secret": false, "is_secret": false,
"is_verified": true, "is_verified": true,
"line_number": 5112 "line_number": 5117
} }
] ]
}, },

View File

@@ -267,6 +267,8 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
| `CALDAV_URL` | URL du serveur CalDAV. | `https://caldav.example.com/calendars/...` | `str` | | `CALDAV_URL` | URL du serveur CalDAV. | `https://caldav.example.com/calendars/...` | `str` |
| `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` | | `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` |
| `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` | | `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` |
| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` |
| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` |
| `XMPP_JID` | Identifiant XMPP (ex: `user@example.com`). | `user@example.com` | `str` | | `XMPP_JID` | Identifiant XMPP (ex: `user@example.com`). | `user@example.com` | `str` |
| `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` | | `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` |
| `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` | | `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` |
@@ -291,9 +293,10 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
> Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`. > Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`.
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`| | `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`|
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` | | `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
| `AI_PROVIDER` | Fournisseur IA (`openai` ou `litellm`). | `openai` | `str` |
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`| | `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
| `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` | | `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` |
| `AI_MODEL` | Modèle IA à utiliser. | `gpt-4o-mini` | `str` | | `AI_MODEL` | Modèle IA à utiliser (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`|
| `DRY_RUN` | Mode dry-run (pas de modifications CalDAV/XMPP). | `False` | `bool` | | `DRY_RUN` | Mode dry-run (pas de modifications CalDAV/XMPP). | `False` | `bool` |
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` | | `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
@@ -316,6 +319,7 @@ PRONOTE_MESSAGES_SOURCE=pronotepy
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
CALDAV_USERNAME=user@example.com CALDAV_USERNAME=user@example.com
CALDAV_PASSWORD=your_caldav_password CALDAV_PASSWORD=your_caldav_password
CALDAV_CALENDAR_PATH=/pronote-sync/
# Fenêtre de synchronisation (jours) # Fenêtre de synchronisation (jours)
SYNC_PAST_DAYS=7 SYNC_PAST_DAYS=7
@@ -331,9 +335,10 @@ XMPP_RECIPIENT=parent@example.com
# --- IA (optionnelle) --- # --- IA (optionnelle) ---
AI_ENABLED=true AI_ENABLED=true
AI_PROVIDER=openai
AI_BASE_URL=https://api.openai.com/v1 AI_BASE_URL=https://api.openai.com/v1
AI_API_KEY=your_ai_api_key AI_API_KEY=your_ai_api_key
AI_MODEL=gpt-4o-mini # AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
# --- Divers --- # --- Divers ---
DRY_RUN=false DRY_RUN=false
@@ -1142,7 +1147,7 @@ Ajouter les variables suivantes dans la configuration :
| **Variable** | **Description** | **Valeur par défaut** | **Type** | | **Variable** | **Description** | **Valeur par défaut** | **Type** |
|----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------| |----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------|
| `BLOG_RSS_ENABLED` | Activer la récupération du blog. | `False` | `bool` | | `BLOG_ENABLED` | Activer la récupération du blog. | `False` | `bool` |
| `BLOG_RSS_URL` | URL du flux RSS du blog. | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `str` | | `BLOG_RSS_URL` | URL du flux RSS du blog. | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `str` |
#### 5 bis.9.2 Modèle Pydantic pour la configuration du blog #### 5 bis.9.2 Modèle Pydantic pour la configuration du blog
@@ -1177,7 +1182,7 @@ class Settings(BaseSettings):
```ini ```ini
# --- Blog du collège --- # --- Blog du collège ---
BLOG_RSS_ENABLED=true BLOG_ENABLED=true
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2 BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
``` ```

View File

@@ -10,7 +10,7 @@ from __future__ import annotations
from typing import Literal from typing import Literal
from pydantic import SecretStr from pydantic import Field, SecretStr, field_serializer
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -23,7 +23,7 @@ class PronoteSettings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_") model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_")
ical_url: str | None = None ical_url: SecretStr | None = None
username: str | None = None username: str | None = None
password: SecretStr | None = None password: SecretStr | None = None
ent: str | None = None ent: str | None = None
@@ -31,6 +31,18 @@ class PronoteSettings(BaseSettings):
homework_source: Literal["auto", "ical", "pronotepy"] = "auto" homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
messages_source: Literal["pronotepy"] = "pronotepy" messages_source: Literal["pronotepy"] = "pronotepy"
@field_serializer("ical_url")
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
"""Masque l'URL iCal lors de la sérialisation (repr, str, JSON).
:param value: Valeur du champ ``ical_url``.
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
:rtype: str | None
"""
if value is None:
return None
return "**********"
class CalDAVSettings(BaseSettings): class CalDAVSettings(BaseSettings):
"""Paramètres d'accès au serveur CalDAV de destination. """Paramètres d'accès au serveur CalDAV de destination.
@@ -121,12 +133,9 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
pronote: PronoteSettings = PronoteSettings() pronote: PronoteSettings = Field(default_factory=PronoteSettings)
caldav: CalDAVSettings = CalDAVSettings() caldav: CalDAVSettings = Field(default_factory=CalDAVSettings)
xmpp: XmppSettings = XmppSettings() xmpp: XmppSettings = Field(default_factory=XmppSettings)
ai: AISettings = AISettings() ai: AISettings = Field(default_factory=AISettings)
blog: BlogSettings = BlogSettings() blog: BlogSettings = Field(default_factory=BlogSettings)
app: AppSettings = AppSettings() app: AppSettings = Field(default_factory=AppSettings)
settings = Settings()

View File

@@ -24,7 +24,11 @@ _SENSITIVE_QUERY_KEYS = frozenset(
"authorization", "authorization",
} }
) )
_URL_PATTERN = re.compile(r"https?://[^\s]+") _URL_PATTERN = re.compile(r"https?://[^\s]+", re.IGNORECASE)
_AUTH_HEADER_PATTERN = re.compile(
r"((?:Proxy-)?Authorization)\s*[:=]\s*\S[^\r\n]*",
re.IGNORECASE,
)
_ISOLATED_SECRET_PATTERN = re.compile( _ISOLATED_SECRET_PATTERN = re.compile(
r"\b(icalsecurise|access_token|api_key|apikey|authorization|token|password|secret|key|auth)" r"\b(icalsecurise|access_token|api_key|apikey|authorization|token|password|secret|key|auth)"
r"(\s*['\"]?\s*[:=]\s*)" r"(\s*['\"]?\s*[:=]\s*)"
@@ -40,8 +44,8 @@ def redact_url(url: str) -> str:
"""Masque les identifiants et les paramètres sensibles d'une URL. """Masque les identifiants et les paramètres sensibles d'une URL.
Les informations d'authentification du netloc (``utilisateur:motdepasse@hôte``) Les informations d'authentification du netloc (``utilisateur:motdepasse@hôte``)
sont masquées, ainsi que les paramètres sensibles de la requête sont entièrement masquées (utilisateur et mot de passe), ainsi que les
(ex: ``icalsecurise``). paramètres sensibles de la requête (ex: ``icalsecurise``).
:param url: URL pouvant contenir des informations sensibles (ex: ``icalsecurise``). :param url: URL pouvant contenir des informations sensibles (ex: ``icalsecurise``).
:return: URL avec les éléments sensibles remplacés par ``REDACTED``, :return: URL avec les éléments sensibles remplacés par ``REDACTED``,
@@ -51,15 +55,13 @@ def redact_url(url: str) -> str:
try: try:
parts = urlsplit(url) parts = urlsplit(url)
if parts.username is not None or parts.password is not None: if parts.username is not None or parts.password is not None:
# Netloc sûr : utilisateur:REDACTED@hôte:port. Le deux-point est # Netloc sûr : REDACTED@hôte:port. L'utilisateur et le mot de
# conservé même en l'absence de mot de passe explicite. # passe sont entièrement masqués.
userinfo = parts.username or ""
userinfo += ":REDACTED"
host = parts.hostname or "" host = parts.hostname or ""
if parts.port is not None: if parts.port is not None:
netloc = f"{userinfo}@{host}:{parts.port}" netloc = f"{_REDACTED}@{host}:{parts.port}"
else: else:
netloc = f"{userinfo}@{host}" netloc = f"{_REDACTED}@{host}"
parts = parts._replace(netloc=netloc) parts = parts._replace(netloc=netloc)
query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True) query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True)
redacted_query = [ redacted_query = [
@@ -74,15 +76,18 @@ def redact_url(url: str) -> str:
def redact_secrets(text: str) -> str: def redact_secrets(text: str) -> str:
"""Masque les secrets présents dans un texte arbitraire. """Masque les secrets présents dans un texte arbitraire.
Les URLs sont d'abord traitées par :func:`redact_url`, puis les affectations Les URLs sont d'abord traitées par :func:`redact_url`, puis les en-têtes
isolées de type ``cle=valeur`` ou ``cle:valeur`` (ex: ``icalsecurise=XXX``, d'authentification (``Authorization``, ``Proxy-Authorization``) et les
``"token": "XXX"``) sont masquées, sans distinction de casse. affectations isolées de type ``cle=valeur`` ou ``cle:valeur``
(ex: ``icalsecurise=XXX``, ``"token": "XXX"``) sont masquées, sans
distinction de casse.
:param text: Texte pouvant contenir des URLs ou des secrets en clair. :param text: Texte pouvant contenir des URLs ou des secrets en clair.
:return: Texte avec les secrets remplacés par ``REDACTED``. :return: Texte avec les secrets remplacés par ``REDACTED``.
:rtype: str :rtype: str
""" """
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text) redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted)
return _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted) return _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted)

119
tests/unit/test_config.py Normal file
View File

@@ -0,0 +1,119 @@
"""Tests unitaires pour la configuration et le chargement des secrets.
Ce module vérifie que la configuration est chargée correctement depuis les
variables d'environnement, que les secrets sont masqués dans les représentations
textuelles et sérialisées, et que le rechargement fonctionne comme attendu.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from pydantic import SecretStr
from pronote_sync.config.env import load_settings
from pronote_sync.config.settings import PronoteSettings, Settings
if TYPE_CHECKING:
from _pytest.monkeypatch import MonkeyPatch
def test_env_prefix_loading(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que les variables d'environnement avec préfixe sont chargées correctement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("PRONOTE_USERNAME", "testuser")
settings = load_settings()
assert settings.pronote.username == "testuser"
def test_env_reload_via_load_settings(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que le rechargement de la configuration lit les nouvelles valeurs.
Ce test garantit que les champs configurés avec ``default_factory`` créent
des instances fraîches à chaque appel de ``load_settings()``, permettant ainsi
un rechargement dynamique des variables d'environnement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
# Premier chargement (valeur par défaut None)
settings1 = load_settings()
assert settings1.pronote.username is None
# Modification de l'environnement
monkeypatch.setenv("PRONOTE_USERNAME", "newuser")
# Rechargement
settings2 = load_settings()
assert settings2.pronote.username == "newuser"
def test_secretstr_masking_ical_url() -> None:
"""Vérifie que l'URL iCal contenant un secret est masquée dans les représentations.
:return: None
"""
ical_url = "https://example.fr/ical?icalsecurise=SECRET_TOKEN" # pragma: allowlist secret
settings = Settings(pronote=PronoteSettings(ical_url=SecretStr(ical_url)))
# Vérification de la représentation string
str_repr = str(settings)
assert "SECRET_TOKEN" not in str_repr
assert "**********" in str_repr
# Vérification de la représentation repr
repr_repr = repr(settings)
assert "SECRET_TOKEN" not in repr_repr
assert "**********" in repr_repr
# Vérification de la sérialisation JSON
json_str = settings.model_dump_json()
assert "SECRET_TOKEN" not in json_str
assert "**********" in json_str
def test_secretstr_masking_password() -> None:
"""Vérifie que le mot de passe est masqué dans les représentations.
:return: None
"""
password = "mypass123" # pragma: allowlist secret
settings = Settings(pronote=PronoteSettings(password=SecretStr(password)))
# Vérification de la représentation string
str_repr = str(settings)
assert "mypass123" not in str_repr
assert "**********" in str_repr
# Vérification de la représentation repr
repr_repr = repr(settings)
assert "mypass123" not in repr_repr
assert "**********" in repr_repr
# Vérification de la sérialisation JSON
json_str = settings.model_dump_json()
assert "mypass123" not in json_str
assert "**********" in json_str
def test_no_singleton_import() -> None:
"""Vérifie que l'import du singleton settings lève une ImportError.
Ce test garantit que le pattern singleton a été supprimé et que la
configuration doit être chargée explicitement via ``load_settings()``.
:return: None
"""
with pytest.raises(ImportError):
# Attempt to import a non-existent attribute to trigger ImportError
# as required by the spec
from pronote_sync.config.settings import ( # type: ignore[attr-defined] # noqa: F401
settings,
)
# Ensure trailing newline

View File

@@ -0,0 +1,128 @@
"""Tests unitaires pour les utilitaires de masquage des secrets.
Ce module vérifie que les fonctions de masquage des URLs, des en-têtes
et des secrets dans les textes fonctionnent correctement, sans fuite
d'informations sensibles.
"""
from __future__ import annotations
from pronote_sync.utils.redaction import redact_exception, redact_secrets, redact_url
def test_redact_url_with_sensitive_query_param() -> None:
"""Vérifie que les paramètres sensibles dans une URL sont masqués.
:return: None
"""
url = "https://example.fr/ical?icalsecurise=TOKEN123" # pragma: allowlist secret
redacted = redact_url(url)
assert "TOKEN123" not in redacted
assert "REDACTED" in redacted
def test_redact_url_with_embedded_auth() -> None:
"""Vérifie que les informations d'authentification intégrées dans une URL sont masquées.
:return: None
"""
url = "https://user:pass@host/path" # pragma: allowlist secret
redacted = redact_url(url)
assert "user" not in redacted
assert "pass" not in redacted
assert "REDACTED" in redacted
def test_redact_url_with_uppercase_scheme() -> None:
"""Vérifie que les URLs avec un schéma en majuscules sont traitées correctement.
:return: None
"""
url = "HTTPS://user:pass@host/path" # pragma: allowlist secret
redacted = redact_url(url)
assert "user" not in redacted
assert "pass" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_authorization_bearer() -> None:
"""Vérifie que les en-têtes Authorization: Bearer sont masqués.
:return: None
"""
text = "Authorization: Bearer mytoken123" # pragma: allowlist secret
redacted = redact_secrets(text)
assert "mytoken123" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_proxy_authorization_basic() -> None:
"""Vérifie que les en-têtes Proxy-Authorization: Basic sont masqués.
:return: None
"""
text = "Proxy-Authorization: Basic dXNlcjpwYXNz" # pragma: allowlist secret
redacted = redact_secrets(text)
assert "dXNlcjpwYXNz" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_lowercase_authorization() -> None:
"""Vérifie que les en-têtes authorization en minuscules sont masqués.
:return: None
"""
text = "authorization: Bearer tok" # pragma: allowlist secret
redacted = redact_secrets(text)
assert "tok" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_text_key_value() -> None:
"""Vérifie que les affectations clé=valeur dans du texte sont masquées.
:return: None
"""
text = "password=mysecret" # pragma: allowlist secret
redacted = redact_secrets(text)
assert "mysecret" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_json_key_value() -> None:
"""Vérifie que les secrets dans du JSON sont masqués.
:return: None
"""
text = '{"api_key": "myapikey"}' # pragma: allowlist secret
redacted = redact_secrets(text)
assert "myapikey" not in redacted
assert "REDACTED" in redacted
def test_redact_exception() -> None:
"""Vérifie que les exceptions contenant des secrets sont masquées.
:return: None
"""
exc = Exception("Authorization: Bearer leaked_token") # pragma: allowlist secret
redacted_msg = redact_exception(exc)
assert "leaked_token" not in redacted_msg
assert "REDACTED" in redacted_msg
def test_redact_url_preserves_host_and_path() -> None:
"""Vérifie que l'hôte et le chemin d'une URL sont préservés après masquage.
:return: None
"""
url = "https://user:pass@example.fr/path?icalsecurise=tok" # pragma: allowlist secret
redacted = redact_url(url)
assert "example.fr" in redacted
assert "/path" in redacted
assert "user" not in redacted
assert "pass" not in redacted
assert "tok" not in redacted
# Ensure trailing newline