Compare commits
3 Commits
feature/m1
...
m15-docume
| Author | SHA1 | Date | |
|---|---|---|---|
|
2deeb83c76
|
|||
|
b518508632
|
|||
|
b474f02e90
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -52,6 +52,7 @@ Thumbs.db
|
||||
|
||||
# --- Local scratch / WIP files ---
|
||||
FIXME_*
|
||||
FEAT_*
|
||||
TEST_*
|
||||
.worktrees/
|
||||
|
||||
|
||||
27
CHANGELOG.md
Normal file
27
CHANGELOG.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.0] - 2026-09-08
|
||||
|
||||
Initial release covering milestones M1 through M14.
|
||||
|
||||
### Added
|
||||
- **M1 (Scaffolding)**: Python project structure with `pyproject.toml`, and tooling configuration for `ruff`, `mypy`, `bandit`, and `pre-commit`.
|
||||
- **M2 (Configuration & secrets)**: Pydantic Settings for configuration management, `SecretStr` for sensitive fields, and redaction utilities (`redact_url`, `redact_secrets`, `redact_exception`) with `RedactingFormatter` for logging.
|
||||
- **M3 (Data models)**: 16 Pydantic models and 6 enums across 10 modules, including frozen contracts and mutable work results.
|
||||
- **M4 (Pronote sources)**: iCal fetch and parse, `pronotepy.ParentClient` integration, automatic fallback logic for `auto`, `ical`, and `pronotepy` modes, and error redaction for sensitive data.
|
||||
- **M5 (Blog RSS)**: `feedparser`-based RSS client with GUID deduplication, HTTP cache support (ETag/If-Modified-Since), and `BlogRSSState` persistence.
|
||||
- **M6 (Theoretical agenda)**: JSON provider with week parity (even/odd), school holidays calendar, and deterministic IDs for events.
|
||||
- **M7 (CalDAV sync)**: Differential synchronization by UID, `X-PRONOTE-SYNC-MANAGED` marker for managed events, idempotent operations, preserved cancelled events, and dry-run support.
|
||||
- **M8 (Agenda diff)**: `AgendaComparator` with deterministic matching, and generation of `AgendaDiff`/`AgendaChange` objects for tracking differences.
|
||||
- **M9 (AI synthesis)**: `SynthesisProvider` protocol, OpenAI provider, optional `litellm` provider, and `openai-compatible` provider with degraded mode (returns `None` on failure).
|
||||
- **M10 (XMPP channel)**: `XmppChannel` using `slixmpp`, formatted messages (synthesis, homeworks, changes, messages, blog), and error handling that returns `False` on failure.
|
||||
- **M11 (Pipeline orchestration)**: `PipelineRunner` as composition root, 7 pipeline steps, degraded error handling, dry-run mode, and iCal reuse within a single run.
|
||||
- **M12 (CLI entry point)**: `pronote-sync` command with `--dry-run` and `--log-level` options, redacted error display, and safe traceback in DEBUG mode.
|
||||
- **M13 (Tests & coverage)**: 636 tests with 95.67% coverage, test fixtures (`pronote-4e.ics`, `pronote-6e.ics`), shared `conftest.py`, and secret non-leak tests.
|
||||
- **M14 (Deployment)**: systemd service and timer (daily at 18:00), logrotate configuration (daily, rotate 7, compress), `check_secrets.py` pre-deployment scanner, and exploitation guide.
|
||||
- **Other**: MIT License. Gitea Actions CI/CD reference for LXC/VPS (Debian/CentOS) is planned and optional, not delivered in this release.
|
||||
@@ -6090,7 +6090,7 @@ Ce guide fournit une **base architecturale et technique solide** pour développe
|
||||
1. **Créer le dépôt** : Initialiser un nouveau dépôt Python avec la structure proposée.
|
||||
2. **Implémenter le cœur** : Commencer par les modules `models/`, `sources/pronote/ical.py` et `utils/`.
|
||||
3. **Ajouter les tests** : Écrire des tests unitaires pour chaque module dès le début.
|
||||
4. **Configurer CI/CD** : Mettre en place GitHub Actions pour exécuter les tests et vérifier la sécurité.
|
||||
4. **Configurer Gitea Actions** : Mettre en place Gitea Actions pour exécuter les tests et vérifier la sécurité, en vue d'un déploiement sur LXC/VPS (Debian/CentOS).
|
||||
5. **Tester en conditions réelles** : Utiliser des flux iCal Pronote anonymisés pour valider le parsing.
|
||||
|
||||
> **⚠️ Rappel** : Ce guide est **volontairement détaillé** pour préserver les connaissances acquises sur les spécificités de Pronote. Certaines sections (ex: parsing iCal) contiennent des **observations précises** issues de l'analyse du code TypeScript existant. **Ne pas sous-estimer l'importance de ces détails** : ils sont critiques pour un fonctionnement fiable du projet.
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Antoine Van Elstraete
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
147
README.LLM.md
Normal file
147
README.LLM.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# pronote-sync — AI Agent Setup Guide
|
||||
|
||||
This document guides an AI agent through installing and pre-configuring the `pronote-sync` project on a fresh Linux host (Debian/CentOS). It covers environment setup, dependency installation, and configuration file preparation. It does **NOT** cover secrets provisioning — those must be provided by the operator.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python ≥ 3.13.5 (check with `python3 --version`)
|
||||
- Git
|
||||
- A non-root service user (e.g., `pronote-sync`)
|
||||
- Target paths:
|
||||
- `/opt/pronote-sync` (code)
|
||||
- `/var/lib/pronote-sync` (state)
|
||||
- `/var/log/pronote-sync` (logs)
|
||||
- `/etc/pronote-sync` (config)
|
||||
|
||||
---
|
||||
|
||||
## Installation Steps
|
||||
|
||||
```bash
|
||||
# Create service user
|
||||
sudo useradd --system --no-create-home --shell /usr/sbin/nologin pronote-sync
|
||||
|
||||
# Clone the repository
|
||||
sudo git clone <repo-url> /opt/pronote-sync
|
||||
sudo chown -R pronote-sync:pronote-sync /opt/pronote-sync
|
||||
|
||||
# Create virtual environment
|
||||
cd /opt/pronote-sync
|
||||
sudo -u pronote-sync python3.13 -m venv .venv
|
||||
sudo -u pronote-sync .venv/bin/pip install -e ".[dev]"
|
||||
|
||||
# Create directories
|
||||
sudo install -d -m 0700 -o pronote-sync -g pronote-sync /etc/pronote-sync
|
||||
sudo install -d -m 0750 -o pronote-sync -g pronote-sync /var/lib/pronote-sync
|
||||
sudo install -d -m 0750 -o pronote-sync -g pronote-sync /var/log/pronote-sync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Preparation (Without Secrets)
|
||||
|
||||
```bash
|
||||
# Copy the example config
|
||||
sudo -u pronote-sync cp /opt/pronote-sync/.env.example /etc/pronote-sync/pronote-sync.env
|
||||
|
||||
# The operator must fill in secrets (PRONOTE_PASSWORD, CALDAV_PASSWORD, XMPP_PASSWORD, AI_API_KEY, etc.)
|
||||
# Do NOT populate secrets automatically — leave them for the operator.
|
||||
```
|
||||
|
||||
### Non-Secret Environment Variables (Pre-Configurable)
|
||||
|
||||
The following variables can be safely pre-configured in `/etc/pronote-sync/pronote-sync.env`:
|
||||
|
||||
- **Pronote:**
|
||||
- `PRONOTE_ACCOUNT_TYPE` (default: `parent`)
|
||||
- `PRONOTE_ENT` (ENT slug, e.g., `lyceeconnecte`)
|
||||
- `PRONOTE_AGENDA_SOURCE`, `PRONOTE_HOMEWORK_SOURCE`, `PRONOTE_MESSAGES_SOURCE` (`auto`, `ical`, or `pronotepy`)
|
||||
|
||||
- **CalDAV:**
|
||||
- `CALDAV_CALENDAR_PATH` (e.g., `/pronote-sync/`)
|
||||
- `CALDAV_ALLOW_INSECURE_HTTP` (default: `false`)
|
||||
|
||||
- **Sync Window:**
|
||||
- `SYNC_PAST_DAYS`, `SYNC_FUTURE_DAYS`
|
||||
|
||||
- **Theoretical Agenda:**
|
||||
- `THEORETICAL_AGENDA_PATH`, `SCHOOL_HOLIDAYS_PATH`
|
||||
- `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE`
|
||||
|
||||
- **XMPP:**
|
||||
- `XMPP_ENABLED`, `XMPP_HOST`, `XMPP_PORT`, `XMPP_USE_TLS`, `XMPP_TIMEOUT`, `XMPP_RESOURCE`
|
||||
|
||||
- **AI:**
|
||||
- `AI_ENABLED`, `AI_PROVIDER`, `AI_BASE_URL`, `AI_MODEL`, `AI_ALLOW_INSECURE_HTTP`
|
||||
|
||||
- **Blog:**
|
||||
- `BLOG_ENABLED`, `BLOG_RSS_URL`
|
||||
|
||||
- **General:**
|
||||
- `DRY_RUN`, `LOG_LEVEL`
|
||||
|
||||
### Secret Variables (Operator Must Provide)
|
||||
|
||||
**Do NOT set these variables automatically.** The operator must manually provide the following secrets:
|
||||
|
||||
- **Pronote:**
|
||||
- `PRONOTE_ICAL_URL`, `PRONOTE_URL`, `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`
|
||||
|
||||
- **CalDAV:**
|
||||
- `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`
|
||||
|
||||
- **XMPP:**
|
||||
- `XMPP_JID`, `XMPP_PASSWORD`, `XMPP_TO`
|
||||
|
||||
- **AI:**
|
||||
- `AI_API_KEY`
|
||||
|
||||
---
|
||||
|
||||
## Pre-Deployment Checks
|
||||
|
||||
```bash
|
||||
# Verify no secrets in the codebase
|
||||
/opt/pronote-sync/.venv/bin/python /opt/pronote-sync/scripts/check_secrets.py
|
||||
|
||||
# Verify dependencies
|
||||
/opt/pronote-sync/.venv/bin/python -m pip check
|
||||
|
||||
# Dry-run test (will fail without secrets, but verifies the environment)
|
||||
/opt/pronote-sync/.venv/bin/pronote-sync --dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## systemd Installation
|
||||
|
||||
```bash
|
||||
# Install systemd units
|
||||
sudo install -m 0644 /opt/pronote-sync/deploy/systemd/pronote-sync.service /etc/systemd/system/
|
||||
sudo install -m 0644 /opt/pronote-sync/deploy/systemd/pronote-sync.timer /etc/systemd/system/
|
||||
|
||||
# Install logrotate config
|
||||
sudo install -m 0644 /opt/pronote-sync/deploy/logrotate/pronote_sync /etc/logrotate.d/pronote_sync
|
||||
|
||||
# Reload and enable
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now pronote-sync.timer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the AI Agent
|
||||
|
||||
- **Do NOT commit or write secrets** to any file in the repository.
|
||||
- **Do NOT modify** `.gitignore`, `pyproject.toml`, or existing source files.
|
||||
- If `python3.13` is not available, install it first:
|
||||
- Debian: `sudo apt install python3.13`
|
||||
- CentOS: Compile from source or use `dnf` if available.
|
||||
- The `check_secrets.py` script exits with:
|
||||
- `0` (clean)
|
||||
- `1` (secrets found)
|
||||
- `2` (error)
|
||||
- All paths in the systemd unit assume `/opt/pronote-sync` — adjust if installed elsewhere.
|
||||
- The operator **must** provide real values for all **SECRET** variables before enabling the timer.
|
||||
69
README.md
Normal file
69
README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# pronote-sync
|
||||
|
||||
Synchronisation Pronote → CalDAV + XMPP.
|
||||
|
||||
---
|
||||
|
||||
Synchronise l'agenda et les devoirs de **Pronote** vers un calendrier **CalDAV** et envoie un résumé quotidien par **XMPP**. Supporte les sources iCal et `pronotepy` avec repli automatique. Synthèse IA optionnelle.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Démarrage rapide
|
||||
|
||||
```bash
|
||||
# Cloner le dépôt
|
||||
git clone <repo-url>
|
||||
cd pronote-sync
|
||||
|
||||
# Créer l'environnement virtuel
|
||||
python3.13 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Installer
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Configurer
|
||||
cp .env.example .env
|
||||
# Éditer .env avec vos paramètres (voir .env.example pour le détail)
|
||||
|
||||
# Tester
|
||||
pronote-sync --dry-run --log-level DEBUG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Utilisation
|
||||
|
||||
```bash
|
||||
pronote-sync # Exécute la synchronisation
|
||||
pronote-sync --dry-run # Simulation sans écriture
|
||||
pronote-sync --log-level DEBUG # Verbosité des journaux
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Déploiement
|
||||
|
||||
Les artefacts pour **systemd/timer** et **logrotate** sont fournis dans `deploy/`. Voir [docs/exploitation.md](docs/exploitation.md) pour plus de détails.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Remerciements
|
||||
|
||||
Ce projet repose sur les bibliothèques open-source suivantes :
|
||||
- [pronotepy](https://github.com/bain3/pronotepy) — client Pronote
|
||||
- [icalendar](https://github.com/collective/icalendar) — parsing iCal
|
||||
- [caldav](https://github.com/python-caldav/caldav) — client CalDAV
|
||||
- [slixmpp](https://github.com/poezio/slixmpp) — client XMPP
|
||||
- [pydantic](https://github.com/pydantic/pydantic) — validation et configuration
|
||||
- [openai](https://github.com/openai/openai-python) — synthèse IA
|
||||
- [feedparser](https://github.com/kurtmckee/feedparser) — parsing RSS
|
||||
- [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/) — parsing HTML
|
||||
|
||||
Inspiré de [pronote-digest](https://github.com/antoine-coulon/pronote-digest) par Antoine Coulon.
|
||||
|
||||
---
|
||||
|
||||
## Licence
|
||||
|
||||
MIT — voir [LICENSE](LICENSE).
|
||||
22
TODO.md
22
TODO.md
@@ -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).
|
||||
|
||||
- [ ] 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).
|
||||
- [ ] 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).
|
||||
- [ ] Vérifier `pip check` et tester le dry-run avant mise en production.
|
||||
- [x] Créer une unité systemd (`pronote-sync.service` + timer) ou une ligne cron (exécution quotidienne).
|
||||
- [x] Créer `logrotate.d/pronote_sync` (daily, rotate 7, compress, delaycompress).
|
||||
- [x] Ajouter un script de vérification des secrets (§13.6) exécuté avant chaque déploiement.
|
||||
- [x] Documenter la supervision (logs, alertes en cas d'échec) et la maintenance (maj dépendances, dry-run avant MAJ).
|
||||
- [x] Vérifier `pip check` et tester le dry-run avant mise en production.
|
||||
|
||||
### Critères d'acceptation
|
||||
- Le service/timer systemd (ou cron) lance le pipeline quotidiennement.
|
||||
@@ -294,13 +294,13 @@ Mettre en production de façon supervisée (planification, rotation des logs, v
|
||||
|
||||
Rédiger la documentation utilisateur et finaliser le projet.
|
||||
|
||||
- [ ] Créer `README.md` (installation, configuration `.env`, usage CLI, systemd/docker, limites, RGPD).
|
||||
- [ ] Documenter l'architecture (pipeline, modules) en résumé.
|
||||
- [ ] Ajouter `CHANGELOG` initial et la licence (MIT).
|
||||
- [ ] Revue finale : cohérence avec le guide, aucun secret documenté en clair.
|
||||
- [ ] (Optionnel) Configurer GitHub Actions CI/CD (pytest + bandit + ruff + mypy) d'après §Prochaines étapes.
|
||||
- [x] Créer `README.md` (installation, configuration `.env`, usage CLI, systemd/docker, limites, RGPD).
|
||||
- [x] Documenter l'architecture (pipeline, modules) en résumé.
|
||||
- [x] Ajouter `CHANGELOG` initial et la licence (MIT).
|
||||
- [x] Revue finale : cohérence avec le guide, aucun secret documenté en clair.
|
||||
- [ ] (Optionnel) Configurer Gitea Actions (pytest + bandit + ruff + mypy) pour le déploiement LXC/VPS (Debian/CentOS).
|
||||
|
||||
### Critères d'acceptation
|
||||
- `README.md` permet d'installer et de lancer le projet sans le guide.
|
||||
- La CI exécute tests + lint + sécurité.
|
||||
- Gitea Actions exécute tests + lint + sécurité.
|
||||
- Aucun secret dans la documentation.
|
||||
|
||||
@@ -26,17 +26,18 @@ _TEXT_SUFFIXES = frozenset(
|
||||
{".conf", ".ini", ".json", ".md", ".py", ".service", ".timer", ".toml", ".txt", ".yaml", ".yml"}
|
||||
)
|
||||
_LITERAL_SECRET_RE = re.compile(
|
||||
r"(?ix)\b(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
||||
r"\s*[:=]\s*['\"][^'\"\r\n]{8,}['\"]"
|
||||
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(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
||||
r"\s*[:=]\s*[a-z0-9][a-z0-9._~+/-]{7,}"
|
||||
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]{8,})"
|
||||
r"=([^&#\s]{3,})"
|
||||
)
|
||||
_EXTRA_NAMES = frozenset({"pronote_sync"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -54,11 +55,16 @@ class SecretFinding:
|
||||
|
||||
|
||||
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
|
||||
@@ -68,7 +74,7 @@ def _is_candidate(path: Path) -> bool:
|
||||
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
|
||||
and (path.suffix in _TEXT_SUFFIXES or path.name in _EXTRA_NAMES)
|
||||
)
|
||||
|
||||
|
||||
@@ -112,7 +118,38 @@ def _staged_files(root: Path, runner: CommandRunner) -> list[Path]:
|
||||
return sorted(path for path in paths if _is_candidate(path))
|
||||
|
||||
|
||||
def find_secrets(root: Path, files: Iterable[Path]) -> list[SecretFinding]:
|
||||
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 :
|
||||
@@ -120,6 +157,10 @@ def find_secrets(root: Path, files: Iterable[Path]) -> list[SecretFinding]:
|
||||
|
||||
: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]
|
||||
"""
|
||||
@@ -127,7 +168,12 @@ def find_secrets(root: Path, files: Iterable[Path]) -> list[SecretFinding]:
|
||||
for relative_path in files:
|
||||
path = root / relative_path
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
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):
|
||||
@@ -177,15 +223,16 @@ def main(
|
||||
parsed_arguments = _parse_arguments(arguments)
|
||||
repository_root = root or Path(__file__).resolve().parents[1]
|
||||
try:
|
||||
files = (
|
||||
_staged_files(repository_root, runner)
|
||||
if parsed_arguments.staged
|
||||
else _repository_files(repository_root)
|
||||
)
|
||||
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)
|
||||
findings = find_secrets(repository_root, files, content_provider=content_provider)
|
||||
if not findings:
|
||||
print("OK: aucun secret littéral détecté.")
|
||||
return 0
|
||||
|
||||
@@ -144,3 +144,106 @@ def test_staged_mode_inspects_only_paths_provided_by_git(
|
||||
|
||||
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