diff --git a/docs/exploitation.md b/docs/exploitation.md index dca4ebc..f2b609d 100644 --- a/docs/exploitation.md +++ b/docs/exploitation.md @@ -42,6 +42,13 @@ bloquent donc pas le déploiement. Il ne valide ni les valeurs ni les permission du fichier d'environnement. Pour analyser seulement le contenu indexé avant un commit, utilisez `scripts/check_secrets.py --staged`. +Dans une URL, le contrôle exempte uniquement les placeholders documentaires +explicitement reconnus : `{token}`/`{jeton}` (et les quelques noms documentaires +équivalents), leur forme ``, les suites de trois points ou les caractères +de masquage `•`/`…`. Les valeurs entre accolades ou chevrons au contenu arbitraire, +les astérisques et toute valeur réelle restent signalés. Un même paramètre URL est +contrôlé même si un autre paramètre sensible de la ligne est déjà un placeholder. + Le dry-run autorise les lectures nécessaires à la simulation, mais n'applique aucune sortie CalDAV/XMPP et ne modifie aucun état local persistant. L'état RSS reste limité à la mémoire du processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette garantie : la commande diff --git a/scripts/check_secrets.py b/scripts/check_secrets.py index 773a3d6..155f6ac 100644 --- a/scripts/check_secrets.py +++ b/scripts/check_secrets.py @@ -37,7 +37,15 @@ _URL_SECRET_RE = re.compile( r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)" r"=([^&#\s]{3,})" ) -_URL_PLACEHOLDER_RE = re.compile(r"(?x)^(?:\{[^{}\r\n]+\}|<[^<>\r\n]+>|\.+|[•…*]+)$") +_URL_PLACEHOLDER_RE = re.compile( + r"(?ix)^(?:" + r"\{(?:token|jeton|secret|valeur|value|param(?:etre)?|version|prenom|" + r"etablissement|hote|host)\}|" + r"<(?:token|jeton|secret|valeur|value|param(?:etre)?|version|prenom|" + r"etablissement|hote|host)>|" + r"\.{3,}|[•…]+" + r")$" +) _EXTRA_NAMES = frozenset({"pronote_sync"}) @@ -186,8 +194,11 @@ def find_secrets( ) if is_literal_secret: findings.append(SecretFinding(relative_path, number, "affectation-litterale")) - url_match = _URL_SECRET_RE.search(line) - if url_match and not _URL_PLACEHOLDER_RE.fullmatch(url_match.group(1)): + has_real_url_secret = any( + not _URL_PLACEHOLDER_RE.fullmatch(match.group(1)) + for match in _URL_SECRET_RE.finditer(line) + ) + if has_real_url_secret: findings.append(SecretFinding(relative_path, number, "parametre-url")) return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule)) diff --git a/tests/unit/test_check_secrets.py b/tests/unit/test_check_secrets.py index 6bdaedf..110b38d 100644 --- a/tests/unit/test_check_secrets.py +++ b/tests/unit/test_check_secrets.py @@ -145,6 +145,48 @@ def test_main_ignores_documentation_url_placeholders( assert "OK:" in capsys.readouterr().out +def test_main_detects_a_real_url_secret_after_a_placeholder( + secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str] +) -> None: + """Détecte un secret réel placé après un placeholder sur la même URL. + + :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-after-placeholder-sentinel" + (tmp_path / "guide.md").write_text( + f"https://example.invalid/?token={{jeton}}&api_key={sentinel}\n", + encoding="utf-8", + ) + + assert secret_checker.main([], root=tmp_path) == 1 + output = capsys.readouterr().out + assert "guide.md:1 (parametre-url)" in output + assert sentinel not in output + + +@pytest.mark.parametrize("value", ["", "***"]) +def test_main_rejects_ambiguous_url_placeholders( + secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str], value: str +) -> None: + """Refuse les valeurs qui ne sont pas des placeholders documentaires fermé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. + :param value: Valeur ambiguë à ne pas exempter. + :return: None + """ + (tmp_path / "guide.md").write_text( + f"https://example.invalid/?token={value}\n", encoding="utf-8" + ) + + assert secret_checker.main([], root=tmp_path) == 1 + assert "guide.md:1 (parametre-url)" in capsys.readouterr().out + + def test_staged_mode_inspects_only_paths_provided_by_git( secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str] ) -> None: