Compare commits

..
Author SHA1 Message Date
Codex 6575a5f60b fix(caldav): rendre les devoirs transparents 2026-09-12 15:43:12 +02:00
Codex 6bb8ad1ed1 docs(deploiement) : imposer les permissions de la configuration
Aligne la documentation d'exploitation et le wiki sur les permissions protegees des secrets et de l'etat d'authentification.

Co-authored-by: Codex <codex@antoineve.me>
2026-09-12 15:26:57 +02:00
6 changed files with 135 additions and 48 deletions
+26
View File
@@ -17,6 +17,24 @@ sudo install -d -m 0700 -o <utilisateur-service> -g <groupe-service> <repertoire
sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> .env <fichier-environnement> sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> .env <fichier-environnement>
``` ```
Pour créer directement la configuration à partir du modèle versionné, utilisez
la même installation protégée puis éditez le fichier avec un compte autorisé :
```bash
sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> \
/opt/pronote-sync/.env.example <fichier-environnement>
```
Contrôlez ensuite uniquement les métadonnées du fichier, sans afficher son
contenu :
```bash
stat -c 'mode=%a proprietaire=%U groupe=%G chemin=%n' <fichier-environnement>
```
Le résultat attendu est le mode `600` et le propriétaire/groupe du compte de
service. Le répertoire de configuration doit rester en `0700`.
Les unités fournies nécessitent l'interface CLI livrée au jalon M12. Avant de 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 les installer, vérifiez que la version installée contient bien ce point
d'entrée : d'entrée :
@@ -48,6 +66,14 @@ processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette gara
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant. le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
Le dry-run ne remplace pas une vérification des paramètres réellement chargés. Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
`.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
`0600`. Ils contiennent des credentials vivantes et ne doivent être ni copiés,
ni versionnés, ni affichés. Le PIN du QR code et, si nécessaire, le PIN de
second facteur du compte sont également des secrets ; configurez-les uniquement
dans le fichier d'environnement protégé.
## Installation systemd ## Installation systemd
Les fichiers versionnés sont : Les fichiers versionnés sont :
+16 -7
View File
@@ -14,7 +14,7 @@ l'outil et de ne jamais toucher aux événements étrangers du calendrier.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, time from datetime import datetime, timedelta
from typing import cast from typing import cast
from icalendar import Calendar, Component, Event, vDate, vDatetime from icalendar import Calendar, Component, Event, vDate, vDatetime
@@ -33,7 +33,15 @@ MANAGED_VALUE = "v1"
PRODID = "-//pronote-sync//NONSGML v1.0//EN" PRODID = "-//pronote-sync//NONSGML v1.0//EN"
#: Propriétés prises en compte dans la signature sémantique d'un composant. #: Propriétés prises en compte dans la signature sémantique d'un composant.
_SIGNATURE_KEYS: tuple[str, ...] = ("UID", "SUMMARY", "DTSTART", "DTEND", "STATUS", "DESCRIPTION") _SIGNATURE_KEYS: tuple[str, ...] = (
"UID",
"SUMMARY",
"DTSTART",
"DTEND",
"STATUS",
"TRANSP",
"DESCRIPTION",
)
def lesson_to_vevent(lesson: Lesson) -> Event: def lesson_to_vevent(lesson: Lesson) -> Event:
@@ -88,8 +96,9 @@ def lesson_to_vevent(lesson: Lesson) -> Event:
def homework_to_vevent(homework: Homework) -> Event: def homework_to_vevent(homework: Homework) -> Event:
"""Convertit un devoir Pronote en composant VEVENT iCalendar. """Convertit un devoir Pronote en composant VEVENT iCalendar.
Le devoir est représenté comme une tâche (``STATUS:NEEDS-ACTION``) sur la Le devoir est représenté comme un événement toute la journée à la date
journée d'échéance, entre 08:00 et 18:00. d'échéance. Il est transparent pour ne pas bloquer les disponibilités ;
aucun statut de tâche ``VTODO`` n'est ajouté à ce ``VEVENT``.
:param homework: Devoir Pronote à sérialiser. :param homework: Devoir Pronote à sérialiser.
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil. :return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
@@ -98,10 +107,10 @@ def homework_to_vevent(homework: Homework) -> Event:
event = Event() event = Event()
event.add("uid", f"homework-{homework.id}") event.add("uid", f"homework-{homework.id}")
event.add("summary", f"Devoir: {homework.subject}") event.add("summary", f"Devoir: {homework.subject}")
event.add("dtstart", vDatetime(datetime.combine(homework.due_on, time(8, 0)))) event.add("dtstart", vDate(homework.due_on))
event.add("dtend", vDatetime(datetime.combine(homework.due_on, time(18, 0)))) event.add("dtend", vDate(homework.due_on + timedelta(days=1)))
event.add("description", homework.text) event.add("description", homework.text)
event.add("status", "NEEDS-ACTION") event.add("transp", "TRANSPARENT")
event.add("categories", ["Pronote", "Devoir"]) event.add("categories", ["Pronote", "Devoir"])
event.add(MANAGED_PROPERTY, MANAGED_VALUE) event.add(MANAGED_PROPERTY, MANAGED_VALUE)
return event return event
+1 -3
View File
@@ -37,7 +37,6 @@ _URL_SECRET_RE = re.compile(
r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)" r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
r"=([^&#\s]{3,})" r"=([^&#\s]{3,})"
) )
_URL_PLACEHOLDER_RE = re.compile(r"(?x)^(?:\{[^{}\r\n]+\}|<[^<>\r\n]+>|\.+|[•…*]+)$")
_EXTRA_NAMES = frozenset({"pronote_sync"}) _EXTRA_NAMES = frozenset({"pronote_sync"})
@@ -186,8 +185,7 @@ def find_secrets(
) )
if is_literal_secret: if is_literal_secret:
findings.append(SecretFinding(relative_path, number, "affectation-litterale")) findings.append(SecretFinding(relative_path, number, "affectation-litterale"))
url_match = _URL_SECRET_RE.search(line) if _URL_SECRET_RE.search(line):
if url_match and not _URL_PLACEHOLDER_RE.fullmatch(url_match.group(1)):
findings.append(SecretFinding(relative_path, number, "parametre-url")) findings.append(SecretFinding(relative_path, number, "parametre-url"))
return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule)) return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule))
+81 -1
View File
@@ -7,7 +7,7 @@ ajouts/mises à jour/suppressions, et la préservation des événements non gér
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import date, datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import pytest import pytest
@@ -122,10 +122,16 @@ class FakeCalendar:
event_start = raw_start.dt event_start = raw_start.dt
event_end = raw_end.dt if raw_end is not None else event_start event_end = raw_end.dt if raw_end is not None else event_start
overlaps = True overlaps = True
if isinstance(event_start, datetime):
if start is not None: if start is not None:
overlaps = overlaps and event_end > start overlaps = overlaps and event_end > start
if end is not None: if end is not None:
overlaps = overlaps and event_start < end overlaps = overlaps and event_start < end
else:
if start is not None:
overlaps = overlaps and event_end > start.date()
if end is not None:
overlaps = overlaps and event_start < end.date()
if overlaps: if overlaps:
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server)) results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
break break
@@ -686,6 +692,80 @@ class TestCalDAVSynchronize:
assert result.added == 1 assert result.added == 1
assert len(fake_caldav_server.get_events()) == 1 assert len(fake_caldav_server.get_events()) == 1
def test_legacy_homework_vevent_is_migrated_idempotently_and_removed(
self,
fake_caldav_server: FakeCalDAVServer,
full_settings: Settings,
) -> None:
"""Migre un ancien devoir puis vérifie l'idempotence et la suppression."""
fake_caldav_server._events["homework-hw-legacy"] = _create_vevent_text(
uid="homework-hw-legacy",
summary="Devoir: Histoire",
start=datetime(2026, 1, 20, 8, 0),
end=datetime(2026, 1, 20, 18, 0),
status="NEEDS-ACTION",
managed=True,
)
homework = Homework(
id="hw-legacy",
subject="Histoire",
assigned_on=None,
due_on=date(2026, 1, 20),
text="Lire le chapitre 5",
)
def make_data(homeworks: list[Homework]) -> PronoteData:
"""Construit les données de synchronisation du scénario."""
return PronoteData(
lessons=[],
homeworks=homeworks,
school_events=[],
messages=[],
target_date=date(2026, 1, 20),
generated_at=datetime(2026, 1, 14, 0, 0),
)
first = synchronize(
pronote_data=make_data([homework]),
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert first.status == CalDAVSyncStatus.SUCCESS
assert first.added == 0
assert first.updated == 1
assert first.removed == 0
migrated = Calendar.from_ical(fake_caldav_server.get_events()["homework-hw-legacy"])
event = migrated.walk("VEVENT")[0]
assert event.get("DTSTART").dt == date(2026, 1, 20)
assert event.get("DTEND").dt == date(2026, 1, 21)
assert event.get("STATUS") is None
assert str(event.get("TRANSP")) == "TRANSPARENT"
second = synchronize(
pronote_data=make_data([homework]),
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert second.status == CalDAVSyncStatus.SKIPPED
assert second.added == 0
assert second.updated == 0
assert second.removed == 0
removed = synchronize(
pronote_data=make_data([]),
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert removed.status == CalDAVSyncStatus.SUCCESS
assert removed.added == 0
assert removed.updated == 0
assert removed.removed == 1
assert fake_caldav_server.get_events() == {}
def test_school_event_sync( def test_school_event_sync(
self, self,
fake_caldav_server: FakeCalDAVServer, fake_caldav_server: FakeCalDAVServer,
-27
View File
@@ -118,33 +118,6 @@ def test_main_detects_sensitive_url_parameter(
assert sentinel not in output assert sentinel not in output
def test_main_ignores_documentation_url_placeholders(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Ignore les marqueurs de remplacement utilisés dans une documentation.
: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 / "guide.md").write_text(
"\n".join(
(
"https://example.invalid/?icalsecurise={jeton}",
"https://example.invalid/?icalsecurise=••••••••",
"https://example.invalid/?icalsecurise=<token>",
"https://example.invalid/?icalsecurise=...",
)
)
+ "\n",
encoding="utf-8",
)
assert secret_checker.main([], root=tmp_path) == 0
assert "OK:" in capsys.readouterr().out
def test_staged_mode_inspects_only_paths_provided_by_git( def test_staged_mode_inspects_only_paths_provided_by_git(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str] secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None: ) -> None:
+7 -6
View File
@@ -239,15 +239,16 @@ def test_homework_to_vevent_uid_prefix() -> None:
assert str(event.get("UID")) == "homework-HW-5678" assert str(event.get("UID")) == "homework-HW-5678"
def test_homework_to_vevent_status() -> None: def test_homework_to_vevent_is_transparent_without_task_status() -> None:
"""Vérifie qu'un devoir a STATUS=NEEDS-ACTION. """Vérifie qu'un devoir VEVENT est transparent et sans statut VTODO.
:return: None :return: None
""" """
homework = _make_homework() homework = _make_homework()
event = homework_to_vevent(homework) event = homework_to_vevent(homework)
assert str(event.get("STATUS")) == "NEEDS-ACTION" assert event.get("STATUS") is None
assert str(event.get("TRANSP")) == "TRANSPARENT"
def test_homework_to_vevent_categories() -> None: def test_homework_to_vevent_categories() -> None:
@@ -265,15 +266,15 @@ def test_homework_to_vevent_categories() -> None:
def test_homework_to_vevent_dtstart_dtend() -> None: def test_homework_to_vevent_dtstart_dtend() -> None:
"""Vérifie que DTSTART et DTEND couvrent la journée d'échéance (08:00-18:00). """Vérifie que DTSTART et DTEND encadrent la journée d'échéance.
:return: None :return: None
""" """
homework = _make_homework(due_on=date(2026, 1, 20)) homework = _make_homework(due_on=date(2026, 1, 20))
event = homework_to_vevent(homework) event = homework_to_vevent(homework)
assert event.get("DTSTART").dt == datetime(2026, 1, 20, 8, 0) assert event.get("DTSTART").dt == date(2026, 1, 20)
assert event.get("DTEND").dt == datetime(2026, 1, 20, 18, 0) assert event.get("DTEND").dt == date(2026, 1, 21)
def test_homework_to_vevent_summary() -> None: def test_homework_to_vevent_summary() -> None: