Compare commits
4 Commits
fixme-m12
...
m14-deploy
| Author | SHA1 | Date | |
|---|---|---|---|
|
b474f02e90
|
|||
|
e6e4b10047
|
|||
|
d60357a017
|
|||
|
000416f24e
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -53,7 +53,7 @@ Thumbs.db
|
|||||||
# --- Local scratch / WIP files ---
|
# --- Local scratch / WIP files ---
|
||||||
FIXME_*
|
FIXME_*
|
||||||
TEST_*
|
TEST_*
|
||||||
.worktress/
|
.worktrees/
|
||||||
|
|
||||||
# --- Logs ---
|
# --- Logs ---
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
14
TODO.md
14
TODO.md
@@ -257,8 +257,8 @@ Exposer le lancement du pipeline via une interface en ligne de commande.
|
|||||||
|
|
||||||
Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisées, jusqu'à ≥ 90 %.
|
Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisées, jusqu'à ≥ 90 %.
|
||||||
|
|
||||||
- [ ] Créer `tests/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.json`, `school_holidays.json`, `blog_rss.xml` (anonymisés, sans `icalsecurise`).
|
- [x] Créer `tests/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.json`, `school_holidays.json`, `blog_rss.xml` (anonymisés, sans `icalsecurise`).
|
||||||
- [ ] Créer `tests/conftest.py` : fixtures partagées (sample_lesson, sample_cancelled_lesson, sample_homework, sample_school_event, sample_message, sample_pronote_data…).
|
- [x] Créer `tests/conftest.py` : fixtures partagées (sample_lesson, sample_cancelled_lesson, sample_homework, sample_school_event, sample_message, sample_pronote_data…).
|
||||||
- [x] Écrire `tests/unit/` : `test_models`, `test_parsing` (iCal), `test_uid`, `test_redaction`, `test_diff`, `test_sync`.
|
- [x] Écrire `tests/unit/` : `test_models`, `test_parsing` (iCal), `test_uid`, `test_redaction`, `test_diff`, `test_sync`.
|
||||||
- [x] Couvrir les régressions M4 : signature réelle de `ParentClient`, ENT autorisé/inconnu, erreur vs résultat vide, `STATUS:CANCELLED` sans catégorie, plusieurs devoirs à la même date, filtrage `pronotepy` sur la date cible et stabilité d'identité entre sources.
|
- [x] Couvrir les régressions M4 : signature réelle de `ParentClient`, ENT autorisé/inconnu, erreur vs résultat vide, `STATUS:CANCELLED` sans catégorie, plusieurs devoirs à la même date, filtrage `pronotepy` sur la date cible et stabilité d'identité entre sources.
|
||||||
- [x] Écrire `tests/integration/` : `test_pipeline`, `test_caldav` (mocké), `test_xmpp` (mocké).
|
- [x] Écrire `tests/integration/` : `test_pipeline`, `test_caldav` (mocké), `test_xmpp` (mocké).
|
||||||
@@ -277,11 +277,11 @@ Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisée
|
|||||||
|
|
||||||
Mettre en production de façon supervisée (planification, rotation des logs, vérification des secrets).
|
Mettre en production de façon supervisée (planification, rotation des logs, vérification des secrets).
|
||||||
|
|
||||||
- [ ] Créer une unité systemd (`pronote-sync.service` + timer) ou une ligne cron (exécution quotidienne).
|
- [x] Créer une unité systemd (`pronote-sync.service` + timer) ou une ligne cron (exécution quotidienne).
|
||||||
- [ ] Créer `logrotate.d/pronote_sync` (daily, rotate 7, compress, delaycompress).
|
- [x] Créer `logrotate.d/pronote_sync` (daily, rotate 7, compress, delaycompress).
|
||||||
- [ ] Ajouter un script de vérification des secrets (§13.6) exécuté avant chaque déploiement.
|
- [x] Ajouter un script de vérification des secrets (§13.6) exécuté avant chaque déploiement.
|
||||||
- [ ] Documenter la supervision (logs, alertes en cas d'échec) et la maintenance (maj dépendances, dry-run avant MAJ).
|
- [x] Documenter la supervision (logs, alertes en cas d'échec) et la maintenance (maj dépendances, dry-run avant MAJ).
|
||||||
- [ ] Vérifier `pip check` et tester le dry-run avant mise en production.
|
- [x] Vérifier `pip check` et tester le dry-run avant mise en production.
|
||||||
|
|
||||||
### Critères d'acceptation
|
### Critères d'acceptation
|
||||||
- Le service/timer systemd (ou cron) lance le pipeline quotidiennement.
|
- Le service/timer systemd (ou cron) lance le pipeline quotidiennement.
|
||||||
|
|||||||
9
deploy/logrotate/pronote_sync
Normal file
9
deploy/logrotate/pronote_sync
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/var/log/pronote-sync/pronote-sync.log {
|
||||||
|
daily
|
||||||
|
missingok
|
||||||
|
rotate 7
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
notifempty
|
||||||
|
create 0640 pronote-sync pronote-sync
|
||||||
|
}
|
||||||
23
deploy/systemd/pronote-sync.service
Normal file
23
deploy/systemd/pronote-sync.service
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Synchronisation Pronote vers CalDAV et XMPP
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=pronote-sync
|
||||||
|
Group=pronote-sync
|
||||||
|
WorkingDirectory=/var/lib/pronote-sync
|
||||||
|
EnvironmentFile=/etc/pronote-sync/pronote-sync.env
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
StateDirectory=pronote-sync
|
||||||
|
LogsDirectory=pronote-sync
|
||||||
|
ExecStartPre=/opt/pronote-sync/.venv/bin/python /opt/pronote-sync/scripts/check_secrets.py
|
||||||
|
ExecStart=/opt/pronote-sync/.venv/bin/pronote-sync
|
||||||
|
StandardOutput=append:/var/log/pronote-sync/pronote-sync.log
|
||||||
|
StandardError=append:/var/log/pronote-sync/pronote-sync.log
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/var/lib/pronote-sync /var/log/pronote-sync
|
||||||
10
deploy/systemd/pronote-sync.timer
Normal file
10
deploy/systemd/pronote-sync.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Exécution quotidienne de pronote-sync
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 18:00:00
|
||||||
|
Persistent=true
|
||||||
|
Unit=pronote-sync.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
143
docs/exploitation.md
Normal file
143
docs/exploitation.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Exploitation de `pronote-sync`
|
||||||
|
|
||||||
|
Ce guide décrit l'installation et l'exploitation des artefacts de déploiement
|
||||||
|
fournis par le projet. Les paramètres de l'unité systemd fournie sont des
|
||||||
|
exemples d'installation : adaptez-les à l'hôte cible avant son installation.
|
||||||
|
Ne placez jamais de secret dans une unité systemd, une commande shell, un
|
||||||
|
journal ou ce document.
|
||||||
|
|
||||||
|
## Préparer l'hôte
|
||||||
|
|
||||||
|
Installez le projet et ses dépendances dans le répertoire choisi, puis créez le
|
||||||
|
fichier d'environnement référencé par l'unité à partir de `.env.example`. Il
|
||||||
|
doit rester local et lisible uniquement par le compte de service :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -d -m 0700 -o <utilisateur-service> -g <groupe-service> <repertoire-configuration>
|
||||||
|
sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> .env <fichier-environnement>
|
||||||
|
```
|
||||||
|
|
||||||
|
Les unités fournies nécessitent l'interface CLI livrée au jalon M12. Avant de
|
||||||
|
les installer, vérifiez que la version installée contient bien ce point
|
||||||
|
d'entrée :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pronote-sync --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Avant toute activation ou mise à jour, exécutez les contrôles depuis la racine
|
||||||
|
du projet :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python scripts/check_secrets.py
|
||||||
|
.venv/bin/python -m pip check
|
||||||
|
.venv/bin/pronote-sync --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Le contrôle des secrets doit réussir avant le déploiement. Il inspecte les
|
||||||
|
fichiers textuels de l'artefact, en excluant volontairement `.env`, les
|
||||||
|
environnements virtuels, les répertoires générés, `tests/` et
|
||||||
|
`GUIDE_DEV_PYTHON.md` ; les sentinelles et exemples de ces deux derniers ne
|
||||||
|
bloquent donc pas le déploiement. Il ne valide ni les valeurs ni les permissions
|
||||||
|
du fichier d'environnement. Pour analyser seulement le contenu indexé avant un
|
||||||
|
commit, utilisez `scripts/check_secrets.py --staged`.
|
||||||
|
|
||||||
|
Le dry-run vérifie le pipeline sans appliquer les écritures de synchronisation ;
|
||||||
|
il ne remplace pas une vérification des paramètres réellement chargés.
|
||||||
|
|
||||||
|
## Installation systemd
|
||||||
|
|
||||||
|
Les fichiers versionnés sont :
|
||||||
|
|
||||||
|
- `deploy/systemd/pronote-sync.service` ;
|
||||||
|
- `deploy/systemd/pronote-sync.timer`.
|
||||||
|
|
||||||
|
Copiez-les dans le répertoire d'unités systemd de l'hôte. Avant de les activer,
|
||||||
|
adaptez `User`, `Group`, `WorkingDirectory`, `EnvironmentFile`, les chemins des
|
||||||
|
exécutables dans `ExecStartPre` et `ExecStart`, ainsi que les chemins de
|
||||||
|
`StateDirectory`, `LogsDirectory` et `ReadWritePaths`. L'artefact fourni prend
|
||||||
|
pour exemple le compte `pronote-sync`, le code dans `/opt/pronote-sync`, l'état
|
||||||
|
dans `/var/lib/pronote-sync`, les logs dans `/var/log/pronote-sync` et le fichier
|
||||||
|
d'environnement `/etc/pronote-sync/pronote-sync.env`. Ne copiez pas de valeur
|
||||||
|
secrète dans l'unité.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 0644 deploy/systemd/pronote-sync.service /etc/systemd/system/
|
||||||
|
sudo install -m 0644 deploy/systemd/pronote-sync.timer /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now pronote-sync.timer
|
||||||
|
systemctl list-timers pronote-sync.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour tester une exécution sans attendre la prochaine échéance :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start pronote-sync.service
|
||||||
|
sudo systemctl status pronote-sync.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Une exécution en échec laisse l'unité `pronote-sync.service` en état `failed`.
|
||||||
|
La supervision de l'hôte doit donc déclencher une alerte sur cet état ou sur un
|
||||||
|
échec du timer/service ; le transport de cette alerte (courriel, XMPP ou système
|
||||||
|
de supervision) relève de l'exploitation locale.
|
||||||
|
|
||||||
|
## Journaux et alertes
|
||||||
|
|
||||||
|
La configuration systemd redirige la sortie standard et la sortie d'erreur vers
|
||||||
|
`/var/log/pronote-sync/pronote-sync.log`. Consultez ce fichier ou, selon la
|
||||||
|
configuration de l'hôte, le journal de l'unité :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo tail -f /var/log/pronote-sync/pronote-sync.log
|
||||||
|
sudo journalctl -u pronote-sync.service --since today
|
||||||
|
sudo journalctl -u pronote-sync.service -f
|
||||||
|
systemctl status pronote-sync.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Traitez un statut non nul ou une unité `failed` comme un échec à investiguer.
|
||||||
|
Les logs applicatifs masquent les secrets configurés, mais évitez tout de même
|
||||||
|
de partager sans relecture un export de journal : une donnée sensible issue de
|
||||||
|
l'environnement ou d'un outil tiers ne doit pas être supposée sûre par défaut.
|
||||||
|
|
||||||
|
## Rotation des journaux
|
||||||
|
|
||||||
|
L'artefact `deploy/logrotate/pronote_sync` cible le fichier
|
||||||
|
`/var/log/pronote-sync/pronote-sync.log` utilisé par l'unité fournie. Installez-
|
||||||
|
le puis validez sa syntaxe avant activation :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 0644 deploy/logrotate/pronote_sync /etc/logrotate.d/pronote_sync
|
||||||
|
sudo logrotate --debug /etc/logrotate.d/pronote_sync
|
||||||
|
```
|
||||||
|
|
||||||
|
La rotation configurée est quotidienne, conserve sept archives et utilise
|
||||||
|
`compress` avec `delaycompress`. Elle recrée le fichier avec les droits `0640`
|
||||||
|
pour le compte de service. Si vous modifiez le chemin de journal dans l'unité,
|
||||||
|
mettez aussi à jour la règle logrotate correspondante.
|
||||||
|
|
||||||
|
## Mise à jour et retour au service
|
||||||
|
|
||||||
|
Avant de remplacer les dépendances ou le code, conservez une copie protégée du
|
||||||
|
fichier d'environnement local, sans l'ajouter au dépôt. Après la mise à jour,
|
||||||
|
réexécutez, dans cet ordre, les contrôles de secrets, de cohérence des paquets
|
||||||
|
et le dry-run :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python scripts/check_secrets.py
|
||||||
|
.venv/bin/python -m pip check
|
||||||
|
.venv/bin/pronote-sync --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Rechargez ensuite les unités si leurs fichiers ont changé, puis vérifiez une
|
||||||
|
exécution et son journal :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart pronote-sync.timer
|
||||||
|
sudo systemctl start pronote-sync.service
|
||||||
|
journalctl -u pronote-sync.service -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
En cas d'échec, ne relancez pas automatiquement après avoir modifié des
|
||||||
|
identifiants : corrigez la configuration locale, repassez le contrôle des
|
||||||
|
secrets et le dry-run, puis consultez le journal expurgé.
|
||||||
@@ -94,7 +94,7 @@ skips = ["B101"] # Ignorer les assertions (utilisées dans les tests)
|
|||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py313"
|
target-version = "py313"
|
||||||
# Exclure la documentation markdown (ruff format ne doit pas toucher aux blocs de code Python inclus)
|
# Exclure la documentation markdown (ruff format ne doit pas toucher aux blocs de code Python inclus)
|
||||||
extend-exclude = ["GUIDE_DEV_PYTHON.md"]
|
extend-exclude = ["GUIDE_DEV_PYTHON.md", ".worktrees"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = [
|
select = [
|
||||||
|
|||||||
245
scripts/check_secrets.py
Normal file
245
scripts/check_secrets.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Vérifie l'absence de secrets littéraux avant un déploiement.
|
||||||
|
|
||||||
|
Le script inspecte le contenu textuel du dépôt, ou uniquement les fichiers
|
||||||
|
ajoutés/modifiés dans l'index avec ``--staged``. Il ne transmet jamais la
|
||||||
|
valeur détectée : les résultats ne contiennent que le chemin, le numéro de
|
||||||
|
ligne et le type de motif. Les fichiers d'environnement et les répertoires
|
||||||
|
générés sont exclus, car ils ne doivent pas être versionnés ni déployés.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import subprocess # nosec B404
|
||||||
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_EXCLUDED_PARTS = frozenset({".git", ".venv", ".worktrees", "__pycache__", ".."})
|
||||||
|
_EXCLUDED_NAMES = frozenset({".env", ".secrets.baseline", "GUIDE_DEV_PYTHON.md"})
|
||||||
|
_EXCLUDED_TOP_LEVEL = frozenset({"tests"})
|
||||||
|
_ALLOWLIST_MARKER = "secret-check: allow"
|
||||||
|
_UNQUOTED_CONFIG_SUFFIXES = frozenset({".conf", ".ini", ".toml", ".yaml", ".yml"})
|
||||||
|
_TEXT_SUFFIXES = frozenset(
|
||||||
|
{".conf", ".ini", ".json", ".md", ".py", ".service", ".timer", ".toml", ".txt", ".yaml", ".yml"}
|
||||||
|
)
|
||||||
|
_LITERAL_SECRET_RE = re.compile(
|
||||||
|
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
||||||
|
r"\s*[:=]\s*['\"][^'\"\r\n]{3,}['\"]"
|
||||||
|
)
|
||||||
|
_UNQUOTED_SECRET_RE = re.compile(
|
||||||
|
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
||||||
|
r"\s*[:=]\s*[a-z0-9][a-z0-9._~+/-]{2,}"
|
||||||
|
)
|
||||||
|
_URL_SECRET_RE = re.compile(
|
||||||
|
r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
||||||
|
r"=([^&#\s]{3,})"
|
||||||
|
)
|
||||||
|
_EXTRA_NAMES = frozenset({"pronote_sync"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SecretFinding:
|
||||||
|
"""Représente un motif sensible détecté sans exposer sa valeur.
|
||||||
|
|
||||||
|
:ivar path: Chemin relatif du fichier concerné.
|
||||||
|
:ivar line: Numéro de ligne du motif.
|
||||||
|
:ivar rule: Règle ayant détecté le motif.
|
||||||
|
"""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
line: int
|
||||||
|
rule: str
|
||||||
|
|
||||||
|
|
||||||
|
CommandRunner = Callable[..., subprocess.CompletedProcess[str]]
|
||||||
|
#: Fournisseur de contenu pour un chemin relatif ; retourne ``None`` pour ignorer.
|
||||||
|
ContentProvider = Callable[[Path], str | None]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_candidate(path: Path) -> bool:
|
||||||
|
"""Indique si un chemin peut être analysé comme fichier texte.
|
||||||
|
|
||||||
|
Les fichiers de déploiement sans extension, nommés explicitement dans
|
||||||
|
``_EXTRA_NAMES``, sont également retenus.
|
||||||
|
|
||||||
|
:param path: Chemin relatif au dépôt.
|
||||||
|
:return: ``True`` lorsque le fichier est textuel et non exclu.
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
not path.is_absolute()
|
||||||
|
and path.name not in _EXCLUDED_NAMES
|
||||||
|
and path.parts[0] not in _EXCLUDED_TOP_LEVEL
|
||||||
|
and not any(part in _EXCLUDED_PARTS for part in path.parts)
|
||||||
|
and (path.suffix in _TEXT_SUFFIXES or path.name in _EXTRA_NAMES)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _repository_files(root: Path) -> list[Path]:
|
||||||
|
"""Liste les fichiers textuels présents dans le dépôt de travail.
|
||||||
|
|
||||||
|
Les tests et la spécification historique ne font pas partie de l'artefact
|
||||||
|
déployé : leurs sentinelles et exemples intentionnels ne doivent donc pas
|
||||||
|
bloquer le déploiement.
|
||||||
|
|
||||||
|
:param root: Racine du dépôt à analyser.
|
||||||
|
:return: Chemins relatifs triés des fichiers analysables.
|
||||||
|
:rtype: list[Path]
|
||||||
|
"""
|
||||||
|
return sorted(
|
||||||
|
path.relative_to(root)
|
||||||
|
for path in root.rglob("*")
|
||||||
|
if path.is_file() and _is_candidate(path.relative_to(root))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _staged_files(root: Path, runner: CommandRunner) -> list[Path]:
|
||||||
|
"""Retourne les fichiers ajoutés ou modifiés actuellement indexés.
|
||||||
|
|
||||||
|
:param root: Racine du dépôt Git.
|
||||||
|
:param runner: Exécuteur de sous-processus injectable pour les tests.
|
||||||
|
:return: Chemins relatifs triés des fichiers indexés analysables.
|
||||||
|
:rtype: list[Path]
|
||||||
|
:raises RuntimeError: Si Git ne peut pas fournir les fichiers indexés.
|
||||||
|
"""
|
||||||
|
result = runner(
|
||||||
|
["git", "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"],
|
||||||
|
cwd=root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError("Impossible de lister les fichiers Git indexés") from None
|
||||||
|
paths = [Path(value) for value in result.stdout.split("\0") if value]
|
||||||
|
return sorted(path for path in paths if _is_candidate(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _staged_content_provider(root: Path, runner: CommandRunner) -> ContentProvider:
|
||||||
|
"""Retourne un lecteur de contenu depuis l'index Git.
|
||||||
|
|
||||||
|
Lit le blob indexé via ``git show :<chemin>`` afin de ne pas dépendre de
|
||||||
|
l'état du working tree, dont la copie de travail peut différer de l'index.
|
||||||
|
|
||||||
|
:param root: Racine du dépôt Git.
|
||||||
|
:param runner: Exécuteur de sous-processus injectable pour les tests.
|
||||||
|
:return: Fonction de lecture du contenu indexé ; ``None`` si indisponible.
|
||||||
|
:rtype: ContentProvider
|
||||||
|
"""
|
||||||
|
|
||||||
|
def provider(relative_path: Path) -> str | None:
|
||||||
|
result = runner(
|
||||||
|
["git", "show", f":{relative_path}"],
|
||||||
|
cwd=root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def find_secrets(
|
||||||
|
root: Path,
|
||||||
|
files: Iterable[Path],
|
||||||
|
content_provider: ContentProvider | None = None,
|
||||||
|
) -> list[SecretFinding]:
|
||||||
|
"""Détecte les motifs de secrets littéraux dans les fichiers désignés.
|
||||||
|
|
||||||
|
Les lignes explicitement marquées ``secret-check: allow`` sont exclues :
|
||||||
|
cette échappatoire doit rester locale à une fixture ou un exemple contrôlé.
|
||||||
|
|
||||||
|
:param root: Racine du dépôt analysé.
|
||||||
|
:param files: Chemins relatifs à inspecter.
|
||||||
|
:param content_provider: Lecteur optionnel du contenu d'un fichier ; par
|
||||||
|
défaut le contenu est lu depuis le working tree via ``read_text``.
|
||||||
|
Si le lecteur retourne ``None`` ou lève une erreur d'encodage, le
|
||||||
|
fichier est ignoré.
|
||||||
|
:return: Résultats triés par chemin, ligne et règle.
|
||||||
|
:rtype: list[SecretFinding]
|
||||||
|
"""
|
||||||
|
findings: list[SecretFinding] = []
|
||||||
|
for relative_path in files:
|
||||||
|
path = root / relative_path
|
||||||
|
try:
|
||||||
|
if content_provider is not None:
|
||||||
|
content = content_provider(relative_path)
|
||||||
|
else:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
if content is None:
|
||||||
|
continue
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
continue
|
||||||
|
for number, line in enumerate(content.splitlines(), start=1):
|
||||||
|
if _ALLOWLIST_MARKER in line:
|
||||||
|
continue
|
||||||
|
is_literal_secret = _LITERAL_SECRET_RE.search(line) or (
|
||||||
|
relative_path.suffix in _UNQUOTED_CONFIG_SUFFIXES
|
||||||
|
and _UNQUOTED_SECRET_RE.search(line)
|
||||||
|
)
|
||||||
|
if is_literal_secret:
|
||||||
|
findings.append(SecretFinding(relative_path, number, "affectation-litterale"))
|
||||||
|
if _URL_SECRET_RE.search(line):
|
||||||
|
findings.append(SecretFinding(relative_path, number, "parametre-url"))
|
||||||
|
return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace:
|
||||||
|
"""Analyse les options de vérification.
|
||||||
|
|
||||||
|
:param arguments: Arguments explicites, ou ``None`` pour ceux du processus.
|
||||||
|
:return: Options validées.
|
||||||
|
:rtype: argparse.Namespace
|
||||||
|
"""
|
||||||
|
parser = argparse.ArgumentParser(description="Vérifie les secrets avant déploiement.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--staged",
|
||||||
|
action="store_true",
|
||||||
|
help="Analyse uniquement les fichiers ajoutés ou modifiés dans l'index Git.",
|
||||||
|
)
|
||||||
|
return parser.parse_args(arguments)
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
arguments: Sequence[str] | None = None,
|
||||||
|
*,
|
||||||
|
root: Path | None = None,
|
||||||
|
runner: CommandRunner = subprocess.run,
|
||||||
|
) -> int:
|
||||||
|
"""Exécute la vérification de secrets et retourne un code de sortie.
|
||||||
|
|
||||||
|
:param arguments: Arguments de ligne de commande.
|
||||||
|
:param root: Racine à analyser ; le dépôt du script par défaut.
|
||||||
|
:param runner: Exécuteur Git injectable pour les tests.
|
||||||
|
:return: ``0`` sans motif, ``1`` si un motif est trouvé, ``2`` si le contrôle échoue.
|
||||||
|
:rtype: int
|
||||||
|
"""
|
||||||
|
parsed_arguments = _parse_arguments(arguments)
|
||||||
|
repository_root = root or Path(__file__).resolve().parents[1]
|
||||||
|
try:
|
||||||
|
if parsed_arguments.staged:
|
||||||
|
files = _staged_files(repository_root, runner)
|
||||||
|
content_provider = _staged_content_provider(repository_root, runner)
|
||||||
|
else:
|
||||||
|
files = _repository_files(repository_root)
|
||||||
|
content_provider = None
|
||||||
|
except RuntimeError as error:
|
||||||
|
print(f"ERREUR: {error}")
|
||||||
|
return 2
|
||||||
|
findings = find_secrets(repository_root, files, content_provider=content_provider)
|
||||||
|
if not findings:
|
||||||
|
print("OK: aucun secret littéral détecté.")
|
||||||
|
return 0
|
||||||
|
for finding in findings:
|
||||||
|
print(f"ECHEC: {finding.path}:{finding.line} ({finding.rule})")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -14,6 +14,7 @@ from pydantic import SecretStr
|
|||||||
from pronote_sync.config.settings import CalDAVSettings
|
from pronote_sync.config.settings import CalDAVSettings
|
||||||
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
|
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
|
||||||
from pronote_sync.models.homework import Homework
|
from pronote_sync.models.homework import Homework
|
||||||
|
from pronote_sync.models.message import Message, MessageType
|
||||||
from pronote_sync.models.pronote import PronoteData
|
from pronote_sync.models.pronote import PronoteData
|
||||||
|
|
||||||
|
|
||||||
@@ -85,19 +86,38 @@ def caldav_settings() -> CalDAVSettings:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_message() -> Message:
|
||||||
|
"""Message Pronote pour les tests.
|
||||||
|
|
||||||
|
:return: Message Pronote de test.
|
||||||
|
:rtype: Message
|
||||||
|
"""
|
||||||
|
return Message(
|
||||||
|
id="msg-001",
|
||||||
|
type=MessageType.INFORMATION,
|
||||||
|
title="Information de rentrée",
|
||||||
|
content="La rentrée est prévue le 1er septembre.",
|
||||||
|
author="Administration",
|
||||||
|
date=datetime(2026, 1, 15, 9, 0),
|
||||||
|
read=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def pronote_data(
|
def pronote_data(
|
||||||
sample_lesson: Lesson,
|
sample_lesson: Lesson,
|
||||||
sample_cancelled_lesson: Lesson,
|
sample_cancelled_lesson: Lesson,
|
||||||
sample_homework: Homework,
|
sample_homework: Homework,
|
||||||
sample_school_event: SchoolEvent,
|
sample_school_event: SchoolEvent,
|
||||||
|
sample_message: Message,
|
||||||
) -> PronoteData:
|
) -> PronoteData:
|
||||||
"""Données Pronote de test avec des cours, devoirs et événements."""
|
"""Données Pronote de test avec des cours, devoirs, événements et messages."""
|
||||||
return PronoteData(
|
return PronoteData(
|
||||||
lessons=[sample_lesson, sample_cancelled_lesson],
|
lessons=[sample_lesson, sample_cancelled_lesson],
|
||||||
homeworks=[sample_homework],
|
homeworks=[sample_homework],
|
||||||
school_events=[sample_school_event],
|
school_events=[sample_school_event],
|
||||||
messages=[],
|
messages=[sample_message],
|
||||||
target_date=date(2026, 1, 15),
|
target_date=date(2026, 1, 15),
|
||||||
generated_at=datetime(2026, 1, 15, 0, 0),
|
generated_at=datetime(2026, 1, 15, 0, 0),
|
||||||
)
|
)
|
||||||
|
|||||||
56
tests/fixtures/pronote-6e.ics
vendored
Normal file
56
tests/fixtures/pronote-6e.ics
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Index Education//Pronote//FR
|
||||||
|
X-WR-CALNAME:Classe de 6e
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:Edt_22222@index-education.net-20260908T140000Z-Index-Education
|
||||||
|
DTSTAMP:20260908T140000Z
|
||||||
|
DTSTART:20260908T140000Z
|
||||||
|
DTEND:20260908T150000Z
|
||||||
|
SUMMARY:SVT
|
||||||
|
CATEGORIES:Cours
|
||||||
|
DESCRIPTION:<div>
|
||||||
|
Matière : SVT
|
||||||
|
Professeur : M. Dubois
|
||||||
|
Salle : 104
|
||||||
|
Groupe : Classe entière
|
||||||
|
|
||||||
|
<strong>Contenu pédagogique :
|
||||||
|
</strong>
|
||||||
|
Découverte de la cellule et de ses constituants.
|
||||||
|
<strong>Pour le 15/09/2026 :
|
||||||
|
</strong>
|
||||||
|
Lire le chapitre 2 et schématiser une cellule végétale.
|
||||||
|
<strong>Donné le 08/09/2026 :
|
||||||
|
</strong>
|
||||||
|
Lire le chapitre 2 et schématiser une cellule végétale.
|
||||||
|
</div>
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:Edt_33333@index-education.net-20260908T140000Z-Index-Education
|
||||||
|
DTSTAMP:20260908T140000Z
|
||||||
|
DTSTART:20260909T100000Z
|
||||||
|
DTEND:20260909T110000Z
|
||||||
|
SUMMARY:Histoire-Géographie
|
||||||
|
CATEGORIES:Cours - Cours modifié
|
||||||
|
DESCRIPTION:<div>
|
||||||
|
Matière : Histoire-Géographie
|
||||||
|
Professeur : Mme Lefevre
|
||||||
|
Salle : 203
|
||||||
|
Groupe : Classe entière
|
||||||
|
|
||||||
|
<strong>Contenu pédagogique :
|
||||||
|
</strong>
|
||||||
|
Les grands repères du temps long : la Préhistoire.
|
||||||
|
</div>
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:Edt_44444@index-education.net-20260908T140000Z-Index-Education
|
||||||
|
DTSTAMP:20260908T140000Z
|
||||||
|
DTSTART;VALUE=DATE:20260928
|
||||||
|
DTEND;VALUE=DATE:20260929
|
||||||
|
SUMMARY:Sortie pédagogique
|
||||||
|
CATEGORIES:Sortie scolaire
|
||||||
|
DESCRIPTION:Journée de sortie pédagogique au musée d'histoire naturelle.
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
249
tests/unit/test_check_secrets.py
Normal file
249
tests/unit/test_check_secrets.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"""Tests unitaires du contrôle de secrets de déploiement."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from _pytest.capture import CaptureFixture
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def secret_checker() -> ModuleType:
|
||||||
|
"""Charge le script de vérification sans l'exécuter comme programme.
|
||||||
|
|
||||||
|
:return: Module du script de contrôle de secrets.
|
||||||
|
:rtype: ModuleType
|
||||||
|
"""
|
||||||
|
script_path = Path(__file__).parents[2] / "scripts" / "check_secrets.py"
|
||||||
|
specification = importlib.util.spec_from_file_location("check_secrets", script_path)
|
||||||
|
assert specification is not None
|
||||||
|
assert specification.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(specification)
|
||||||
|
sys.modules[specification.name] = module
|
||||||
|
try:
|
||||||
|
specification.loader.exec_module(module)
|
||||||
|
finally:
|
||||||
|
del sys.modules[specification.name]
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_accepts_clean_files_and_ignores_environment_file(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un dépôt propre réussit sans analyser le fichier d'environnement.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
(tmp_path / "application.py").write_text("value = 'safe'\n", encoding="utf-8")
|
||||||
|
ignored_environment_secret = 'password = "private-value"\n' # pragma: allowlist secret
|
||||||
|
(tmp_path / ".env").write_text(
|
||||||
|
ignored_environment_secret, encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 0
|
||||||
|
assert "OK:" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_reports_a_literal_secret_without_disclosing_its_value(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un secret littéral échoue sans fuite de sa valeur.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
sentinel = "m14-literal-sentinel"
|
||||||
|
(tmp_path / "settings.py").write_text(
|
||||||
|
f'password = "{sentinel}"\n', encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "settings.py:1 (affectation-litterale)" in output
|
||||||
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_reports_an_unquoted_configuration_secret_without_disclosing_its_value(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un secret de configuration non cité échoue sans fuite de sa valeur.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
sentinel = "m14-unquoted-sentinel"
|
||||||
|
(tmp_path / "settings.yaml").write_text(
|
||||||
|
f"password: {sentinel}\n", encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "settings.yaml:1 (affectation-litterale)" in output
|
||||||
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_detects_sensitive_url_parameter(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un paramètre URL sensible déclenche un échec.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
sentinel = "m14-url-sentinel"
|
||||||
|
(tmp_path / "settings.yaml").write_text(
|
||||||
|
f"url: https://example.invalid/calendar?icalsecurise={sentinel}\n", encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "settings.yaml:1 (parametre-url)" in output
|
||||||
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_staged_mode_inspects_only_paths_provided_by_git(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que l'option staged ignore les fichiers non indexés.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
(tmp_path / "indexed.py").write_text("answer = 42\n", encoding="utf-8")
|
||||||
|
untracked_secret = 'api_key = "m14-untracked-sentinel"\n' # pragma: allowlist secret
|
||||||
|
(tmp_path / "untracked.py").write_text(
|
||||||
|
untracked_secret, encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
def runner(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Simule Git avec un seul fichier indexé.
|
||||||
|
|
||||||
|
:return: Résultat Git simulé.
|
||||||
|
:rtype: subprocess.CompletedProcess[str]
|
||||||
|
"""
|
||||||
|
return subprocess.CompletedProcess([], 0, stdout="indexed.py\0", stderr="")
|
||||||
|
|
||||||
|
assert secret_checker.main(["--staged"], root=tmp_path, runner=runner) == 0
|
||||||
|
assert "OK:" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_detects_prefixed_secret_assignment(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'une variable préfixée (PRONOTE_PASSWORD) est détectée.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
sentinel = "m14-prefixed-secret"
|
||||||
|
(tmp_path / "config.py").write_text(
|
||||||
|
f'PRONOTE_PASSWORD = "{sentinel}"\n', encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "config.py:1" in output
|
||||||
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_detects_short_secret_assignment(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un secret court (< 8 caractères) est détecté.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
sentinel = "s3cr3t"
|
||||||
|
(tmp_path / "config.py").write_text(
|
||||||
|
f'password = "{sentinel}"\n', encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "config.py:1" in output
|
||||||
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_staged_mode_reads_index_content_not_working_tree(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que --staged lit le contenu indexé, pas le working tree.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
indexed_secret = "m14-indexed-only-secret" # pragma: allowlist secret
|
||||||
|
(tmp_path / "staged.py").write_text(
|
||||||
|
f'password = "{indexed_secret}"\n', encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
(tmp_path / "staged.py").write_text('value = "safe"\n', encoding="utf-8")
|
||||||
|
|
||||||
|
def runner(*args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Simule Git en renvoyant le contenu indexé pour le blob demandé.
|
||||||
|
|
||||||
|
:return: Résultat Git simulé.
|
||||||
|
:rtype: subprocess.CompletedProcess[str]
|
||||||
|
"""
|
||||||
|
first_argument = args[0] if args else []
|
||||||
|
command = (
|
||||||
|
[str(argument) for argument in first_argument]
|
||||||
|
if isinstance(first_argument, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
if "show" in command:
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
command, 0, stdout=f'password = "{indexed_secret}"\n', stderr=""
|
||||||
|
)
|
||||||
|
return subprocess.CompletedProcess(command, 0, stdout="staged.py\0", stderr="")
|
||||||
|
|
||||||
|
assert secret_checker.main(["--staged"], root=tmp_path, runner=runner) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "staged.py:1" in output
|
||||||
|
assert indexed_secret not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_scans_extensionless_deployment_file(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un fichier de déploiement sans extension est scanné.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
sentinel = "m14-logrotate-secret"
|
||||||
|
(tmp_path / "pronote_sync").write_text(
|
||||||
|
f'password = "{sentinel}"\n', encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "pronote_sync:1" in output
|
||||||
|
assert sentinel not in output
|
||||||
Reference in New Issue
Block a user