diff --git a/.claude/rules/secrets-hygiene.md b/.claude/rules/secrets-hygiene.md index 5333183f59..b5c30e6530 100644 --- a/.claude/rules/secrets-hygiene.md +++ b/.claude/rules/secrets-hygiene.md @@ -44,6 +44,15 @@ Les secrets **partages** (HF, OpenAI, Anthropic, Civitai, API keys par service, Detail complet (inventaire, rotation, règle restart, incident fondateur) : [docs/genai/secrets-management.md](../../docs/genai/secrets-management.md). +## Trousseau partage `MyIA-Keys` (sign-off user 2026-09-24) + +Organe : [`scripts/secrets/agent_keyring.py`](../../scripts/secrets/agent_keyring.py) — passphrase posee par machine (`bootstrap`, DPAPI), preuve cross-machine par empreinte publiee (`doctor`). Deux interdits : + +- **Empreinte `doctor` differente de la reference du dashboard `global` = ne rien ecrire dans le coffre**, et le signaler. +- **Ne jamais recopier la passphrase sur un chemin partage.** Le PDF d'urgence est une voie de rattrapage, pas un lieu de stockage. + +Detail (sous-commandes, gardes, critere de retrait du PDF) : [docs/reference/shared-keyring-myia-keys.md](../../docs/reference/shared-keyring-myia-keys.md). + ## Transmission d'un secret — canal RooSync prive (fusion 2026-08-21) **Statut** : ACTIF. Decision user 2026-07-02, reaffirmee en session directe 2026-07-03. diff --git a/docs/reference/shared-keyring-myia-keys.md b/docs/reference/shared-keyring-myia-keys.md new file mode 100644 index 0000000000..cac9093c15 --- /dev/null +++ b/docs/reference/shared-keyring-myia-keys.md @@ -0,0 +1,171 @@ +# Trousseau partagé `MyIA-Keys` — organe d'accès, bootstrap par machine, empreinte de preuve + +Détail durable de l'organe `scripts/secrets/agent_keyring.py`. La règle qui le gouverne reste +[.claude/rules/secrets-hygiene.md](../../.claude/rules/secrets-hygiene.md) — ce fichier ne pose +aucune prescription nouvelle, il documente un outil et l'état vérifié de son déploiement. + +## Ce que c'est, et ce que ce n'est pas + +Le user a créé le 2026-09-22 un coffre KeePass partagé et y a exporté son dossier `Agents` : + +``` +G:\Mon Drive\Synchronisation\RooSync\.shared-state\MyIA-Keys.kdbx +``` + +La passphrase maîtresse est saisie dans un PDF de secours (§ « Le PDF d'urgence » ci-dessous). + +**Le coffre porte aujourd'hui des mots de passe de connexion GitHub, pas des PAT.** C'est le +constat le plus important de cette page, et il est mesurable : `verify` rend `secret=mot de passe` +sur les entrées peuplées. Conséquence opérationnelle — **le coffre n'est pas encore un canal de +distribution de jetons**. `gh auth login --with-token` refuse un mot de passe de compte, avec un +message d'erreur qui ne nomme pas la cause ; c'est précisément pour éviter ce diagnostic à +l'aveugle que l'organe tranche lui-même sur la **forme** du secret et refuse avant d'appeler `gh`. + +Émettre un PAT par compte (scopes minimaux) et le ranger dans son entrée reste la **phase C** +de #17418 : entièrement devant nous, pas un résidu. + +## L'organe — `scripts/secrets/agent_keyring.py` + +**Règle cardinale : aucune sous-commande n'imprime un secret par défaut.** Une valeur est soit +tubée vers son consommateur (`gh-login`, `--to-env-file`), soit rendue masquée. Il n'existe pas de +sous-commande « affiche-moi la clé ». + +| Sous-commande | Ce qu'elle fait | +|---|---| +| `bootstrap` | Extrait la passphrase du PDF d'urgence et la pose dans le gestionnaire d'identifiants Windows. `--from-stdin` en repli si l'extraction échoue. | +| `doctor` | État du dispositif sur CETTE machine + **empreinte de la passphrase** (voir ci-dessous). | +| `list` | Titres et usernames des entrées. Aucun secret. | +| `show ` | Métadonnées d'une entrée : titre, groupe, user, url, **nature** du secret, date de modification. | +| `verify` | Chaque compte attendu a-t-il une entrée, et son secret est-il utilisable comme jeton ? | +| `get ` | Écrit la valeur vers un `.env` (`--to-env-file`), **après avoir vérifié que git l'ignore**. | +| `gh-login ` | Tube le jeton dans `gh auth login --with-token`. Refuse si le secret n'est pas un jeton. | + +### Deux gardes qui méritent d'être connus + +**`get` refuse d'écrire dans un fichier que git ne prouve pas ignoré.** « Non suivi » ne suffit +pas : un fichier peut n'être ignoré que par `.git/info/exclude`, **local au clone et non +versionné**, donc pas ignoré chez le voisin. L'organe emprunte la mesure de l'organe de couverture +des secrets (`scripts/ci/check_secret_paths_ignored.py`, #17442) : `git check-ignore -v --no-index` +**nomme la source gagnante**. Une source locale au clone (`.git/info/exclude`, `core.excludesFile`) +est refusée en la nommant, un motif de négation `!` gagnant compte comme « non ignoré », et l'organe +refuse (`EXIT_UNKNOWN`) quand il n'a pas pu mesurer plutôt que de supposer. `--allow-unignored` existe et se justifie dans le body de la PR qui l'emploie. + +**La nature du secret se tranche sur la FORME, jamais sur le nom de l'entrée.** Un titre +`github ai-01` ne prouve pas que l'entrée contient un PAT. `secret_kind()` rend `vide` / `jeton` / +`mot de passe` en confrontant la valeur au format documenté des jetons GitHub. + +### L'entrée se cherche par son titre ET par son username + +Une entrée est demandable sous son **titre** (`github ai-01`) ou sous son **username** +(`myia-ai-01`). Ces deux clés ne coïncident pas dans ce coffre, et ne pas le savoir a produit +deux défauts opposés de même racine : `verify` rendait `0/6` sur un coffre qui en portait 5 +(sur-accusation), et `gh-login` comparait à `None` — donc **le contrôle de compte ne s'exécutait +jamais** et un jeton du mauvais compte serait passé en silence (sous-accusation muette). + +## Bootstrap : pourquoi il n'y a pas d'alternative à une empreinte publiée + +La passphrase est rangée dans le **gestionnaire d'identifiants Windows** (DPAPI), via `keyring`. +Ce stockage est **lié au profil utilisateur Windows** : il n'est ni exportable, ni transférable, +ni interrogeable à distance. Aucune machine ne peut donc vérifier qu'une autre a bootstrappé. + +C'est la contrainte qui dicte tout le reste : **une empreinte publiée est le seul moyen de preuve +cross-machine disponible.** Chaque lane lance `doctor`, lit son empreinte, et la compare à la +référence publiée sur le dashboard `global`. + +```bash +python scripts/secrets/agent_keyring.py bootstrap +python scripts/secrets/agent_keyring.py doctor # imprime l'empreinte à comparer +``` + +- **Empreinte identique** → la machine est enregistrée. +- **Empreinte différente** → **ne rien écrire dans le coffre**, et le signaler. Une divergence est + soit une passphrase différente, soit une copie GDrive en retard : les deux se traitent, aucune + ne se contourne. + +### L'empreinte est un PBKDF2, et le sel est public par nécessité + +``` +empreinte : <32 car.> pbkdf2:85bd9366fb37 +``` + +`fingerprint()` est un **PBKDF2-HMAC-SHA256 à 600 000 tours** (recommandation OWASP 2023), sel +public et fixe. Les trois propriétés sont voulues, et chacune répond à une contrainte : + +1. **Déterministe** — sans quoi deux machines ne pourraient pas comparer. C'est aussi ce qui + impose un sel **fixe** : un sel aléatoire rendrait la comparaison impossible. Ce sel ne cache + rien, il sépare les domaines. +2. **Coûteuse à forcer** — l'empreinte est **publiée sur un dashboard partagé**, ce qui en fait un + oracle hors-ligne : deviner, hacher, comparer. Un sha256 y répondrait en microsecondes ; PBKDF2 + 600k y met ~0,3 s. Sur une passphrase à haute entropie le risque restait théorique ; il ne + l'est pas sur un secret faible, et un organe générique ne choisit pas ce qu'on lui donne. + CodeQL a ouvert `py/weak-sensitive-data-hashing` sur la version sha256, et l'alerte portait. +3. **Payée une fois** — `doctor` l'appelle une seule fois, mesure de bout en bout : 0,9 s. + +**La longueur (`<32 car.>`) est conservée délibérément.** Quand deux machines divergent, l'écart +de longueur rend la cause **lisible** — une extraction tronquée — là où un écart de hash est muet. +Sur un secret de 32 caractères à haute entropie, la divulgation est négligeable face à ce gain de +diagnostic. La même longueur a en revanche été **retirée** de `show` et `verify` sur les entrées : +là elle ne tranchait aucune décision (`secret_kind()` distingue déjà `vide`), donc elle n'était +que de la surface en plus. Une longueur qui diagnostique se garde ; une longueur qui décore se +retire. + +### Les entrées du coffre n'ont pas d'empreinte, et c'est structurel + +Le coffre est **partagé** : ses entrées sont identiques sur toutes les machines par construction. +Il n'y a rien à comparer, donc rien à empreindre. Le seul cas réel — détecter une copie GDrive en +retard — est déjà couvert par le champ `modifiee` que `show` affiche. Seule la passphrase garde +une empreinte, parce qu'elle est la seule chose stockée **par machine**. + +## Le PDF d'urgence, et son critère de retrait + +``` +G:\Mon Drive\MyIA\IA\Emergency MyIA Keys.pdf +``` + +Ce PDF porte la passphrase maîtresse, saisie par le user via PDFgear en annotation `/FreeText`. +L'extraction lit `/Contents` (jumeau texte brut) **et** `/RC` (XHTML riche, dont les runs de style +peuvent découper une valeur en plusieurs fragments) ; c'est ce second cas qui avait fait échouer +les premières tentatives. + +**Le PDF est la seule voie de rattrapage tant qu'une machine n'a pas bootstrappé.** Le user a +donné son feu vert à la suppression le 2026-09-22 — il détient la passphrase dans son propre +KeePass, le risque de perte est donc levé — **conditionné à ce que toutes les machines soient +enregistrées**. + +Critère de retrait, mesurable : **7/7 empreintes concordantes**. + +| Machine | Compte GitHub | +|---|---| +| `myia-ai-01` | `myia-ai-01` | +| `myia-po-2023` | `myia-po-2023` | +| `myia-po-2024` | `myia-po-2024` | +| `myia-po-2025` | `myia-po-2025` | +| `myia-po-2026` | `myia-po-2026` | +| `myia-po-2027` | `myia-po-2027` | +| `myia-web1` | `MyIA-Web1` | + +**La flotte compte sept machines, pas six.** `web1` n'apparaît pas dans +[cluster-agents.md](cluster-agents.md) — non par omission de ce document, mais parce qu'il décrit +les machines portant des **grains CoursIA**, et que web1 travaille sur `roo-extensions`. Pour le +trousseau, la population pertinente est « toute machine de la flotte », et elle est plus large. +Un document fait autorité sur **la population qu'il décrit**, pas au-delà. + +## Le trou `.secrets/` — fermé par #17442 + +`.gitignore` énumérait des fichiers de secrets un par un, et **`.secrets/master.env` — la source +unique désignée par `secrets-hygiene.md` — n'y figurait pas**. Il ne devait son exclusion qu'à +`.git/info/exclude`, local à un clone et non versionné : sur toute autre machine, un `git add -A` +l'aurait stagé. + +Cette PR portait d'abord son propre correctif ; #17442 l'a livré entre-temps, avec une garde CI +(`scripts/ci/check_secret_paths_ignored.py`) qui sépare une source versionnée d'une source locale. +L'organe du trousseau réutilise cette mesure pour `get` au lieu d'en tenir une seconde. La leçon +générale reste : **un test d'ignorance lancé dans ce clone mesure ce clone, pas le dépôt** — et une +énumération est par construction aveugle au fichier qu'on ajoutera demain. + +## Voir aussi + +- [.claude/rules/secrets-hygiene.md](../../.claude/rules/secrets-hygiene.md) — la règle +- [genai/secrets-management.md](../genai/secrets-management.md) — `.secrets/master.env` + `render_envs.py` +- [.claude/rules/codeql-suppressions-inertes.md](../../.claude/rules/codeql-suppressions-inertes.md) — pourquoi la rationale d'une alerte va dans le body de la PR +- #17418 — provisionnement d'un jeton par lane (phase C : émettre les PAT) diff --git a/scripts/secrets/agent_keyring.py b/scripts/secrets/agent_keyring.py new file mode 100644 index 0000000000..fef40644f6 --- /dev/null +++ b/scripts/secrets/agent_keyring.py @@ -0,0 +1,808 @@ +#!/usr/bin/env python3 +"""Acces agent au trousseau partage MyIA-Keys.kdbx. + +Le coffre est distribue par le GDrive RooSync (`.shared-state/`), ou toutes les +machines du cluster le voient. Sa passphrase, elle, n'y est JAMAIS : elle vit +dans le gestionnaire d'identifiants Windows (DPAPI, par utilisateur), pose une +fois par machine. + +C'est la seule propriete qui fait tenir le dispositif. Le coffre et sa clef +partagent aujourd'hui le meme Google Drive : le PDF de secours +`Emergency MyIA Keys.pdf` est lisible par quiconque lit le Drive qui porte +deja le `.kdbx`. Qui a le Drive a les deux moities. `doctor` le mesure et le +dit ; l'organe, lui, ne recopie jamais la passphrase sur un chemin partage. + +REGLE CARDINALE -- aucune sous-commande n'imprime un secret par defaut. +Une valeur est soit *tuyautee* vers son consommateur (`gh-login`, +`--to-env-file`), soit montree *masquee* (`show`, `verify`). Une preuve de +provisionnement est un appel qui passe, jamais une valeur affichee. + +Sous-commandes + doctor etat du dispositif : coffre, passphrase, outillage, co-localisation + bootstrap pose la passphrase dans le gestionnaire d'identifiants (une fois par machine) + list noms des entrees (JAMAIS les valeurs) + show une entree, masquee + get une valeur, vers un fichier .env gitignore + gh-login tuyaute un jeton directement dans `gh auth login --with-token` + verify les entrees attendues sont-elles la, et le jeton repond-il ? + +Codes de sortie -- 0 succes / 1 defaut mesure / 2 impossible a mesurer. +Le 2 n'est PAS un feu vert : il dit que l'organe n'a pas pu conclure. +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import re +import shutil +import subprocess +import sys +from pathlib import Path + +EXIT_OK = 0 +EXIT_DEFECT = 1 +EXIT_UNKNOWN = 2 + +SERVICE = "MyIA-Keys" +DEFAULT_VAULT = r"G:\Mon Drive\Synchronisation\RooSync\.shared-state\MyIA-Keys.kdbx" +DEFAULT_EMERGENCY_PDF = r"G:\Mon Drive\MyIA\IA\Emergency MyIA Keys.pdf" +DEFAULT_GROUP = "Agents" + +# Entrees attendues, par login GitHub. La valeur est le login que le jeton DOIT +# rendre -- c'est ce qui distingue un jeton provisionne d'un jeton qui MARCHE. +# +# `MyIA-Web1` figure ici bien que `docs/reference/cluster-agents.md` ne le liste +# pas : ce document decrit les machines qui portent des grains **CoursIA**, et +# web1 travaille sur `roo-extensions`. Ce n'est pas la bonne population pour ce +# trousseau -- la population pertinente est « les machines qui doivent ouvrir le +# coffre », et web1 en est une (compte GitHub cree le 2026-04-17, entree +# `github web1` dans le coffre, dashboard machine actif). +EXPECTED_GH = { + "myia-ai-01": "myia-ai-01", + "myia-po-2023": "myia-po-2023", + "myia-po-2024": "myia-po-2024", + "myia-po-2025": "myia-po-2025", + "myia-po-2026": "myia-po-2026", + "myia-po-2027": "myia-po-2027", + "MyIA-Web1": "MyIA-Web1", +} + +# Les machines qui doivent BOOTSTRAPPER, c'est-a-dire detenir la passphrase dans +# leur propre gestionnaire d'identifiants. Distinct de EXPECTED_GH : une machine +# doit ouvrir le coffre meme si son compte GitHub n'existe pas encore. +# +# C'est le denominateur du critere de retrait du PDF de secours : tant que les +# N machines n'ont pas bootstrappe, le PDF est leur SEULE source -- le supprimer +# rendrait le coffre inouvrable chez elles (DPAPI n'est ni exportable, ni +# transferable d'une machine a l'autre). +FLEET_MACHINES = ( + "myia-ai-01", + "myia-po-2023", + "myia-po-2024", + "myia-po-2025", + "myia-po-2026", + "myia-po-2027", + "myia-web1", +) + + +def machine_id() -> str: + for var in ("ROOSYNC_MACHINE_ID", "MYIA_MACHINE_ID"): + val = os.environ.get(var) + if val: + return val.strip() + return platform.node().strip().lower() + + +def vault_path() -> Path: + return Path(os.environ.get("MYIA_KEYS_VAULT", DEFAULT_VAULT)) + + +# Un coffre de comptes melange DEUX natures de secret, et aucune ne s'annonce : +# le mot de passe de CONNEXION du compte, et le JETON d'API. `gh` n'accepte que +# le second ; lui donner le premier produit un refus d'authentification que rien +# ne relie a sa cause. Mesure du 2026-09-22 sur MyIA-Keys : les 7 entrees +# `github *` portent des mots de passe generes de 20 caracteres, zero jeton. +GH_TOKEN_RE = re.compile(r"^(gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})$") + + +def secret_kind(value: str | None) -> str: + """'vide' | 'jeton' | 'mot de passe' -- tranche sur la FORME, jamais sur le + nom de l'entree : un titre ne prouve pas ce qu'il contient.""" + if not value: + return "vide" + if GH_TOKEN_RE.match(value.strip()): + return "jeton" + return "mot de passe" + + +def entry_key(entry) -> tuple[str, str]: + """Les deux noms sous lesquels une entree peut etre demandee : son titre + ('github ai-01') et son username ('myia-ai-01'). Chercher par le seul titre + rendait `verify` faux -- il annoncait 0/6 sur un coffre qui en portait 6.""" + return ((entry.title or "").strip().lower(), (entry.username or "").strip().lower()) + + +# Sel public et fixe : il n'apporte PAS de secret (il est dans le source), il +# separe les domaines. Ce qui protege ici, c'est le cout par essai. +_FP_SALT = b"MyIA-Keys/agent_keyring/fingerprint/v1" +_FP_ROUNDS = 600_000 # recommandation OWASP 2023 pour PBKDF2-HMAC-SHA256 + + +def fingerprint(value: str) -> str: + """Empreinte NON reversible et COUTEUSE a forcer, pour la passphrase. + + Deux decisions, prises contre deux alertes CodeQL distinctes, et dans les + deux cas parce que l'outil visait juste sur le fond : + + **1. Pas de queue de valeur.** Une version anterieure publiait les 4 + derniers caracteres -- convention des fournisseurs d'API, ou l'on recoupe + une queue avec leur interface. Un coffre KeePass n'expose rien de tel : on + payait une fuite sans rien acheter. + + **2. Pas de sha256 nu.** `py/weak-sensitive-data-hashing` a signale qu'un + hash rapide est inadapte a un secret -- et cette empreinte est **publiee sur + un dashboard**, donc elle offre a un attaquant un oracle hors-ligne : il + devine, il hache, il compare. Sur une passphrase a haute entropie le risque + est theorique ; sur un secret faible il ne l'est pas, et un outil generique + ne choisit pas ce qu'on lui donne. + + PBKDF2-HMAC-SHA256, 600 000 tours : deterministe (donc deux machines + peuvent comparer), mais ~0,3 s par essai -- ce qui rend l'oracle inutile + sans rien couter a l'usage, puisqu'on l'appelle une fois par `doctor`. + + Le sel est public et fixe : il DOIT l'etre pour que la comparaison + cross-machine fonctionne. Il ne cache rien, il separe les domaines. + """ + import hashlib + + if not value: + return "" + digest = hashlib.pbkdf2_hmac("sha256", value.encode("utf-8"), _FP_SALT, _FP_ROUNDS) + return f"<{len(value)} car.> pbkdf2:{digest.hex()[:12]}" + + +# -------------------------------------------------------------------------- +# passphrase : gestionnaire d'identifiants uniquement +# -------------------------------------------------------------------------- + +def _keyring(): + try: + import keyring + except ImportError: + print("UNKNOWN: module `keyring` absent -- `python -m pip install keyring`", file=sys.stderr) + raise SystemExit(EXIT_UNKNOWN) + return keyring + + +def read_passphrase() -> str | None: + kr = _keyring() + try: + return kr.get_password(SERVICE, machine_id()) + except Exception as exc: # backend indisponible + print(f"UNKNOWN: gestionnaire d'identifiants illisible : {exc}", file=sys.stderr) + raise SystemExit(EXIT_UNKNOWN) + + +# Backends acceptables pour DEPOSER la passphrase. `keyring` bascule +# silencieusement sur un backend de repli quand le coffre natif est +# indisponible -- et certains replis ecrivent EN CLAIR sur disque +# (`keyrings.alt.file.PlaintextKeyring`, ou `EncryptedKeyring` a mot de passe +# faible). Deposer la passphrase du coffre partage dans un fichier clair +# annulerait tout l'interet de l'organe, et le ferait sans rien dire. +_ACCEPTED_BACKENDS = ( + "WinVaultKeyring", # Windows -- gestionnaire d'identifiants (DPAPI) + "SecretServiceKeyring", # Linux -- libsecret / gnome-keyring + "Keyring", # macOS -- Keychain (keyring.backends.macOS.Keyring) +) + + +def assert_backend_is_native() -> str: + """Refuse d'ECRIRE tant que le backend n'est pas un coffre natif. + + La LECTURE reste permise sur n'importe quel backend : lire depuis un repli + ne cree pas de nouvelle exposition, alors qu'ecrire dedans en cree une. La + fonction rend le nom du backend, pour qu'un rapport puisse l'attester au + lieu de l'affirmer. + """ + kr = _keyring() + name = type(kr).__name__ + module = type(kr).__module__ + if name not in _ACCEPTED_BACKENDS or module.startswith("keyrings.alt"): + print(f"DEFECT: backend `keyring` non natif : {module}.{name}", file=sys.stderr) + print(" Un repli peut ecrire la passphrase EN CLAIR sur disque.", file=sys.stderr) + print(" Attendu : WinVaultKeyring (Windows/DPAPI), SecretServiceKeyring", file=sys.stderr) + print(" (Linux/libsecret) ou Keyring (macOS/Keychain). Aucune ecriture faite.", file=sys.stderr) + raise SystemExit(EXIT_DEFECT) + return f"{module}.{name}" + + +def write_passphrase(value: str) -> None: + """Depose la passphrase, apres attestation du backend natif. + + Ne rend rien : CodeQL classe comme secret la valeur de retour de toute + fonction dont le nom evoque une passphrase. Le nom du backend, pour le + journal, se lit par `assert_backend_is_native()`. + """ + assert_backend_is_native() + _keyring().set_password(SERVICE, machine_id(), value) + + +# -------------------------------------------------------------------------- +# coffre +# -------------------------------------------------------------------------- + +def open_vault(passphrase: str | None = None, quiet: bool = False): + """Ouvre le coffre. `quiet` sert la boucle d'essai du bootstrap, ou un echec + est l'issue attendue de presque tous les candidats et n'est pas un defaut.""" + try: + from pykeepass import PyKeePass + except ImportError: + print("UNKNOWN: module `pykeepass` absent -- `python -m pip install pykeepass`", file=sys.stderr) + raise SystemExit(EXIT_UNKNOWN) + + path = vault_path() + if not path.is_file(): + print(f"UNKNOWN: coffre introuvable : {path}", file=sys.stderr) + print(" (GDrive non monte ? synchronisation en cours ?)", file=sys.stderr) + raise SystemExit(EXIT_UNKNOWN) + + if passphrase is None: + passphrase = read_passphrase() + if not passphrase: + if quiet: + raise SystemExit(EXIT_DEFECT) + print(f"DEFECT: aucune passphrase posee pour la machine '{machine_id()}'.", file=sys.stderr) + print(" Poser une fois : python scripts/secrets/agent_keyring.py bootstrap", file=sys.stderr) + raise SystemExit(EXIT_DEFECT) + + try: + return PyKeePass(str(path), password=passphrase) + except Exception as exc: + # Ne jamais renvoyer la passphrase dans le message. + if not quiet: + print(f"DEFECT: ouverture du coffre refusee ({type(exc).__name__}).", file=sys.stderr) + raise SystemExit(EXIT_DEFECT) + + +def iter_entries(kp, group: str | None): + entries = kp.entries + if group: + entries = [e for e in entries if e.group and e.group.name == group] + return sorted(entries, key=lambda e: (e.title or "").lower()) + + +def find_entry(kp, title: str, group: str | None): + wanted = title.strip().lower() + hits = [e for e in iter_entries(kp, group) if wanted in entry_key(e)] + if not hits and group: + hits = [e for e in iter_entries(kp, None) if wanted in entry_key(e)] + return hits[0] if hits else None + + +# -------------------------------------------------------------------------- +# bootstrap +# -------------------------------------------------------------------------- + +def pdf_candidates(pdf: Path) -> list[str]: + """Chaines plausibles extraites du PDF de secours. + + Rien de ce qui sort d'ici n'est imprime : les candidats sont essayes + contre le coffre et seul le verdict est rendu. + """ + try: + from pypdf import PdfReader + except ImportError: + print("UNKNOWN: module `pypdf` absent -- `python -m pip install pypdf`", file=sys.stderr) + raise SystemExit(EXIT_UNKNOWN) + + import html + import re + + block_re = re.compile(r"]*>", re.I) + + def strip_markup(text: str) -> list[str]: + """Le champ /RC d'une annotation est du rich text XHTML. Deux lectures + sont possibles et on ne peut PAS deviner laquelle est la bonne, donc on + rend les DEUX : + + A. balises de BLOC -> saut de ligne, balises INLINE -> supprimees. + PDFgear decoupe une valeur en *runs* de style : remplacer TOUTE + balise par un saut de ligne coupe la passphrase en morceaux. C'est + le defaut qui a fait echouer le second essai (74 candidats, aucun + bon) -- la variante B seule, qui etait le fix du premier. + B. toute balise -> separateur, pour le cas ou deux champs voisins sont + colles sans balise de bloc entre eux. + """ + a = html.unescape(re.sub(r"<[^>]+>", "", block_re.sub("\n", text))) + b = html.unescape(re.sub(r"<[^>]+>", "\n", text)) + return [a, b] + + chunks: list[str] = [] + reader = PdfReader(str(pdf)) + for page in reader.pages: + try: + chunks.append(page.extract_text() or "") + except Exception: + pass + # PDFgear ecrit en annotation, pas dans le flux de page. + try: + for annot in (page.get("/Annots") or []): + obj = annot.get_object() + for key in ("/Contents", "/RC", "/V"): + val = obj.get(key) + if isinstance(val, str): + chunks.extend(strip_markup(val)) + except Exception: + pass + + seen: set[str] = set() + out: list[str] = [] + + def push(value: str) -> None: + value = value.strip().strip('"').strip("'").strip() + if 6 <= len(value) <= 256 and value not in seen: + seen.add(value) + out.append(value) + + # Ordre = probabilite decroissante. Chaque essai coute un KDF Argon2 + # (~1 s) : un ordre au hasard transforme un bootstrap en attente de + # plusieurs minutes, ce qu'on a mesure au premier passage. + labelled, lines, tokens = [], [], [] + label_re = re.compile(r"(pass(phrase|word)?|master|mot de passe|clef|cle)\s*[:=]\s*(?P.+)", re.I) + + def variants(value: str): + """Un PDF porte des espaces insecables et des guillemets typographiques + que le clavier n'a jamais produits : une comparaison stricte echoue sur + un caractere invisible. On essaie la forme brute ET la normalisee.""" + yield value + norm = value.replace("\xa0", " ").replace("\u202f", " ") + norm = norm.replace("\u2018", "'").replace("\u2019", "'") + norm = norm.replace("\u201c", '"').replace("\u201d", '"') + if norm != value: + yield norm + collapsed = re.sub(r"\s+", " ", norm).strip() + if collapsed != norm: + yield collapsed + + for chunk in chunks: + for raw in chunk.splitlines(): + m = label_re.search(raw) + if m: + labelled.append(m.group("v")) + lines.append(raw) + # Le chunk entier : une passphrase a espaces peut n'occuper qu'une + # annotation, sans aucun saut de ligne autour d'elle. + lines.append(chunk) + for chunk in chunks: + tokens.extend(chunk.split()) + + for value in labelled + lines + tokens: + for form in variants(value): + push(form) + return out + + +def cmd_bootstrap(args) -> int: + if read_passphrase() and not args.force: + print(f"OK passphrase deja posee pour '{machine_id()}'. `--force` pour la remplacer.") + return EXIT_OK + + candidates: list[str] = [] + source = "" + if args.from_stdin: + source = "stdin" + data = sys.stdin.read().strip() + if data: + candidates = [data] + else: + pdf = Path(args.from_pdf or DEFAULT_EMERGENCY_PDF) + source = str(pdf) + if not pdf.is_file(): + print(f"UNKNOWN: PDF de secours introuvable : {pdf}", file=sys.stderr) + return EXIT_UNKNOWN + candidates = pdf_candidates(pdf) + + if not candidates: + print(f"DEFECT: aucune chaine exploitable dans {source}.", file=sys.stderr) + return EXIT_DEFECT + + # La validation est l'ouverture reelle du coffre : on ne stocke jamais une + # passphrase qu'on n'a pas vue fonctionner. + print(f"{len(candidates)} candidat(s) a essayer (~1 s chacun, KDF Argon2)...", file=sys.stderr) + for idx, cand in enumerate(candidates, 1): + if idx % 25 == 0: + print(f" ... {idx}/{len(candidates)}", file=sys.stderr) + try: + open_vault(cand, quiet=True) + except SystemExit: + continue + write_passphrase(cand) + backend = assert_backend_is_native() + print(f"OK passphrase validee contre le coffre et posee pour '{machine_id()}'.") + print(f" source : {source}") + # Pas d'empreinte ici : le candidat vient du PDF (source « password » + # pour CodeQL, py/clear-text-logging-sensitive-data). Elle se lit + # apres coup par `doctor`, depuis la valeur STOCKEE -- c'est celle-la + # que le critere de retrait du PDF compare entre machines. + print(" empreinte : `python scripts/secrets/agent_keyring.py doctor`") + print(f" stockage : {backend}, service '{SERVICE}'") + print(" la valeur n'a ete ni imprimee, ni ecrite sur disque, ni mise en variable d'environnement.") + return EXIT_OK + + print(f"DEFECT: aucun des {len(candidates)} candidats de {source} n'ouvre le coffre.", file=sys.stderr) + print(" Le PDF porte peut-etre la passphrase en image (scan) plutot qu'en texte.", file=sys.stderr) + print(" Repli : | python scripts/secrets/agent_keyring.py bootstrap --from-stdin", file=sys.stderr) + return EXIT_DEFECT + + +# -------------------------------------------------------------------------- +# lectures +# -------------------------------------------------------------------------- + +def cmd_list(args) -> int: + kp = open_vault() + entries = iter_entries(kp, args.group) + if args.json: + print(json.dumps([{"title": e.title, "username": e.username, + "group": e.group.name if e.group else None} for e in entries], indent=2)) + return EXIT_OK + if not entries: + print(f"DEFECT: aucune entree dans le groupe '{args.group}'.", file=sys.stderr) + return EXIT_DEFECT + print(f"{len(entries)} entree(s) -- groupe '{args.group or '*'}' (valeurs NON affichees)") + for e in entries: + print(f" {e.title:<32} user={e.username or '-'}") + return EXIT_OK + + +def cmd_show(args) -> int: + kp = open_vault() + entry = find_entry(kp, args.entry, args.group) + if entry is None: + print(f"DEFECT: entree '{args.entry}' absente.", file=sys.stderr) + return EXIT_DEFECT + print(f"titre : {entry.title}") + print(f"groupe : {entry.group.name if entry.group else '-'}") + print(f"user : {entry.username or '-'}") + print(f"url : {entry.url or '-'}") + print(f"password : {secret_kind(entry.password)}") + if entry.mtime: + print(f"modifiee : {entry.mtime.isoformat()}") + return EXIT_OK + + +def _git_tracked(path: Path) -> bool: + try: + res = subprocess.run(["git", "ls-files", "--error-unmatch", str(path)], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15) + return res.returncode == 0 + except Exception: + return False + + +def _git_ignored(path: Path) -> tuple[str, str | None] | None: + """(statut, source) d'ignorance de `path`, ou None si on n'a pas pu mesurer. + + « Non suivi » ne suffit PAS : un fichier neuf n'est pas suivi et reste + parfaitement stageable. Ce qui protege, c'est d'etre *ignore* -- et par une + regle qui VOYAGE avec le depot. `.git/info/exclude` et le `core.excludesFile` + sont locaux au clone : vrais ici, faux sur la machine d'a cote. + + Statut : "VERSIONNEE" (source = un .gitignore suivi), "LOCALE" (source locale + au clone), "NON_IGNORE". La mesure est celle de l'organe de couverture + des secrets (`scripts/ci/check_secret_paths_ignored.py`, #17442) : on la + reutilise au lieu d'en maintenir une seconde. + """ + ci_dir = str(Path(__file__).resolve().parents[1] / "ci") + if ci_dir not in sys.path: + sys.path.insert(0, ci_dir) + try: + import check_secret_paths_ignored as couverture + + top = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, + text=True, encoding="utf-8", errors="replace", timeout=15) + if top.returncode != 0: + return None + racine = Path(top.stdout.strip()).resolve() + rel = os.path.relpath(path.resolve(), racine) + if rel.startswith(".."): + return None # hors du depot : git ne tranche pas, on ne suppose rien + verdict = couverture.verdict_chemin(rel.replace("\\", "/"), racine, + couverture.fichiers_suivis(racine)) + except Exception: # noqa: BLE001 -- tout echec de mesure est fail-closed + return None + # Un motif `!` qui gagne DE-ignore le chemin : ce n'est pas une protection. + if str(verdict.get("motif") or "").startswith("!"): + return "NON_IGNORE", verdict.get("source") + return verdict["statut"], verdict.get("source") + + +def cmd_get(args) -> int: + kp = open_vault() + entry = find_entry(kp, args.entry, args.group) + if entry is None: + print(f"DEFECT: entree '{args.entry}' absente.", file=sys.stderr) + return EXIT_DEFECT + value = {"password": entry.password, "username": entry.username, "url": entry.url}.get(args.field) + if not value: + print(f"DEFECT: champ '{args.field}' vide sur '{entry.title}'.", file=sys.stderr) + return EXIT_DEFECT + + target = Path(args.to_env_file) + if _git_tracked(target): + print(f"DEFECT: {target} est SUIVI PAR GIT -- refus d'y ecrire un secret.", file=sys.stderr) + return EXIT_DEFECT + mesure = _git_ignored(target) + if mesure is None: + print(f"UNKNOWN: impossible de savoir si {target} est ignore par git -- refus fail-closed.", file=sys.stderr) + return EXIT_UNKNOWN + ignored, source = mesure + if ignored == "LOCALE" and not args.allow_unignored: + print(f"DEFECT: {target} n'est ignore que par une regle LOCALE a ce clone ({source}) -- " + "refus d'y ecrire un secret.", file=sys.stderr) + print(" Sur une autre machine, ce chemin serait stageable par `git add .`.", file=sys.stderr) + print(" Ajouter le chemin au .gitignore VERSIONNE, ou --allow-unignored.", file=sys.stderr) + return EXIT_DEFECT + if ignored == "NON_IGNORE" and not args.allow_unignored: + print(f"DEFECT: {target} n'est PAS ignore par git -- refus d'y ecrire un secret.", file=sys.stderr) + print(" Un fichier neuf n'est pas 'suivi', mais il reste stageable par `git add .`.", file=sys.stderr) + print(" Ajouter le chemin au .gitignore VERSIONNE (pas .git/info/exclude, qui est local", file=sys.stderr) + print(" a ce clone et laisse les autres machines sans protection), ou --allow-unignored.", file=sys.stderr) + return EXIT_DEFECT + + key = args.env_key or f"{entry.title.upper().replace('-', '_')}_TOKEN" + target.parent.mkdir(parents=True, exist_ok=True) + lines = [] + if target.exists(): + lines = [l for l in target.read_text(encoding="utf-8").splitlines() + if not l.startswith(f"{key}=")] + lines.append(f"{key}={value}") + # UTF-8 sans BOM : les parsers .env cassent dessus. + target.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") + print(f"OK '{entry.title}'.{args.field} -> {target} sous la cle {key}") + print(f" empreinte : {fingerprint(value)} (valeur non imprimee)") + return EXIT_OK + + +def cmd_gh_login(args) -> int: + """Tuyaute le jeton dans `gh` sans qu'il touche ni disque ni terminal.""" + if not shutil.which("gh"): + print("UNKNOWN: `gh` introuvable dans le PATH.", file=sys.stderr) + return EXIT_UNKNOWN + kp = open_vault() + entry = find_entry(kp, args.entry, args.group) + if entry is None: + print(f"DEFECT: entree '{args.entry}' absente.", file=sys.stderr) + return EXIT_DEFECT + token = entry.password + if not token: + print(f"DEFECT: pas de jeton sur '{entry.title}'.", file=sys.stderr) + return EXIT_DEFECT + + kind = secret_kind(token) + if kind != "jeton": + print(f"DEFECT: le secret de '{entry.title}' est un {kind}, pas un jeton d'API.", + file=sys.stderr) + print(" `gh auth login --with-token` refuserait, avec un message sans rapport", file=sys.stderr) + print(" visible avec la cause. Emettre un PAT pour ce compte et le ranger ici.", file=sys.stderr) + return EXIT_DEFECT + + + # Resolution de l'identite ATTENDUE -- avant tout appel reseau, et avant + # toute persistance. Par titre OU username : le coffre titre 'github ai-01' + # quand la clef attendue est 'myia-ai-01'. Chercher par le seul titre + # rendait la verification MUETTE (expected=None -> aucun controle). + # + # La variable de boucle s'appelle `mapped`, PAS `login` : nommer la variable + # de boucle comme la variable d'identite l'ECRASE, et la comparaison finale + # confronte alors deux valeurs ATTENDUES -- elle ne peut plus echouer, donc + # elle ne verifie plus rien. C'est le defaut principal que corrige ce bloc. + expected = args.account + if not expected: + for key in entry_key(entry): + for name, mapped in EXPECTED_GH.items(): + if key == name.lower(): + expected = mapped + break + if expected: + break + + # ---- VALIDER D'ABORD, PERSISTER ENSUITE ------------------------------- + # `gh auth login` ECRIT le jeton dans la configuration de `gh`, partagee par + # toutes les lanes de la machine. Le faire avant de savoir a QUI appartient + # le jeton, c'est installer une mauvaise identite puis la constater : le + # degat est deja fait, et il frappe les sessions voisines. On interroge donc + # l'API avec le jeton passe par l'ENVIRONNEMENT (aucune persistance), et on + # ne persiste qu'une fois l'identite confirmee. + who = subprocess.run(["gh", "api", "user", "--jq", ".login"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, + env={**os.environ, "GH_TOKEN": token, "GH_HOST": "github.com"}) + if who.returncode != 0: + print(f"DEFECT: le jeton de '{entry.title}' n'ouvre pas `gh api user`.", file=sys.stderr) + print(f" {who.stderr.strip()[:300]}", file=sys.stderr) + return EXIT_DEFECT + + login = who.stdout.strip() + if not login: + # Un rc=0 avec une sortie vide n'est pas une identite. Sans ce refus, le + # `if expected and login` de la version precedente SAUTAIT le controle + # en silence : une reponse vide se lisait comme un succes. + print(f"DEFECT: `gh api user` rend un login VIDE pour '{entry.title}'.", file=sys.stderr) + print(" rc=0 sans identite n'est pas une validation -- refus.", file=sys.stderr) + return EXIT_DEFECT + + if expected and login.lower() != expected.lower(): + print(f"DEFECT: login attendu '{expected}', obtenu '{login}'.", file=sys.stderr) + print(" aucune ecriture n'a ete faite dans la configuration de `gh`.", file=sys.stderr) + return EXIT_DEFECT + if not expected: + print(f"UNKNOWN: aucune identite attendue connue pour '{entry.title}'.", file=sys.stderr) + print(f" `gh api user` rend '{login}', mais rien ne permet de la confirmer.", file=sys.stderr) + print(" Passer --account pour declarer l'identite attendue.", file=sys.stderr) + return EXIT_UNKNOWN + + # ---- l'identite est confirmee : on peut persister ---------------------- + res = subprocess.run(["gh", "auth", "login", "--hostname", "github.com", "--with-token"], + input=token, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=90) + if res.returncode != 0: + print(f"DEFECT: `gh auth login` a refuse le jeton de '{entry.title}'.", file=sys.stderr) + print(f" {res.stderr.strip()[:300]}", file=sys.stderr) + return EXIT_DEFECT + + print(f"OK jeton de '{entry.title}' valide AVANT persistance -- identite : {login}") + return EXIT_OK + + +# -------------------------------------------------------------------------- +# doctor / verify +# -------------------------------------------------------------------------- + +def cmd_doctor(args) -> int: + defects = 0 + print(f"machine : {machine_id()}") + + path = vault_path() + if path.is_file(): + print(f"coffre : OK {path} ({path.stat().st_size} octets)") + else: + print(f"coffre : ABSENT {path}") + defects += 1 + + for mod in ("pykeepass", "keyring", "pypdf"): + try: + __import__(mod) + print(f"outil : OK {mod}") + except ImportError: + print(f"outil : ABSENT {mod} -- `python -m pip install {mod}`") + defects += 1 + + try: + stored = read_passphrase() + except SystemExit: + stored = None + has = bool(stored) + print(f"passphrase: {'OK posee dans le gestionnaire d identifiants' if has else 'ABSENTE -- lancer `bootstrap`'}") + if has: + # L'empreinte EST l'organe du critere de retrait du PDF de secours. + # DPAPI n'etant ni exportable ni interrogeable a distance, aucune + # machine ne peut verifier qu'une AUTRE a bootstrappe. Ce qui circule, + # c'est ce sha256 tronque : non reversible, donc publiable sur un + # dashboard, et suffisant pour repondre a la seule question qui compte + # -- les N machines portent-elles la MEME passphrase ? + # + # Sans cette ligne, le critere « toutes les machines ont bootstrappe » + # ne serait pas mesurable, et « tout le monde est enregistre » resterait + # une affirmation invérifiable. + print(f"empreinte : {fingerprint(stored)}") + print(f" a comparer aux {len(FLEET_MACHINES)} machines : " + + ", ".join(FLEET_MACHINES)) + if not has: + defects += 1 + + # Co-localisation coffre / clef de secours : ce n'est pas un defaut de + # l'organe, c'est une propriete du rangement -- on la mesure et on la dit. + pdf = Path(os.environ.get("MYIA_EMERGENCY_PDF", DEFAULT_EMERGENCY_PDF)) + if pdf.is_file() and path.is_file(): + try: + same = pdf.resolve().drive.lower() == path.resolve().drive.lower() + except Exception: + same = False + if same: + print() + print("ATTENTION -- le coffre et sa clef de secours sont sur le MEME volume :") + print(f" coffre : {path}") + print(f" clef : {pdf}") + print(" Qui lit ce volume tient les deux moities. L'organe ne recopie jamais la") + print(" passphrase sur un chemin partage, mais il ne peut pas deplacer le PDF :") + print(" c'est un arbitrage de rangement, pas un geste d'agent.") + + if defects: + print(f"\n{defects} defaut(s).") + return EXIT_DEFECT + print("\nDispositif operationnel.") + return EXIT_OK + + +def cmd_verify(args) -> int: + kp = open_vault() + entries = list(iter_entries(kp, args.group)) + index: dict[str, object] = {} + for e in entries: + for key in entry_key(e): + if key: + index.setdefault(key, e) + + missing, present, unusable = [], [], [] + for name in EXPECTED_GH: + entry = index.get(name.lower()) + if entry is None: + missing.append(name) + continue + present.append(name) + kind = secret_kind(entry.password) + if kind != "jeton": + unusable.append((name, kind)) + print(f" {name:<16} present entree='{entry.title}' secret={kind}") + for name in missing: + print(f" {name:<16} ABSENT du coffre") + + print(f"\n{len(present)}/{len(EXPECTED_GH)} entree(s) attendue(s) presente(s).") + if missing: + print("Manquantes : " + ", ".join(missing)) + if unusable: + print("\nPresentes mais INUTILISABLES pour `gh` -- un mot de passe de compte") + print("n'est pas un jeton d'API, et `gh auth login --with-token` le refuse :") + for name, kind in unusable: + print(f" {name:<16} secret={kind}") + print("Emettre un PAT par compte (scopes minimaux) et le ranger dans son entree.") + return EXIT_DEFECT if (missing or unusable) else EXIT_OK + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--group", default=DEFAULT_GROUP, help=f"groupe du coffre (defaut: {DEFAULT_GROUP})") + sub = ap.add_subparsers(dest="cmd", required=True) + + sub.add_parser("doctor").set_defaults(func=cmd_doctor) + + b = sub.add_parser("bootstrap", help="poser la passphrase (une fois par machine)") + src = b.add_mutually_exclusive_group() + src.add_argument("--from-pdf", metavar="PATH", help=f"PDF de secours (defaut: {DEFAULT_EMERGENCY_PDF})") + src.add_argument("--from-stdin", action="store_true", help="lire la passphrase sur stdin") + b.add_argument("--force", action="store_true", help="remplacer une passphrase deja posee") + b.set_defaults(func=cmd_bootstrap) + + l = sub.add_parser("list", help="noms des entrees, jamais les valeurs") + l.add_argument("--json", action="store_true") + l.set_defaults(func=cmd_list) + + s = sub.add_parser("show", help="une entree, masquee") + s.add_argument("entry") + s.set_defaults(func=cmd_show) + + g = sub.add_parser("get", help="ecrire une valeur dans un .env gitignore") + g.add_argument("entry") + g.add_argument("--field", default="password", choices=["password", "username", "url"]) + g.add_argument("--to-env-file", required=True) + g.add_argument("--env-key") + g.add_argument("--allow-unignored", action="store_true", + help="ecrire meme si la cible n'est pas ignoree par git (a eviter)") + g.set_defaults(func=cmd_get) + + gh = sub.add_parser("gh-login", help="tuyauter un jeton dans `gh auth login --with-token`") + gh.add_argument("entry") + gh.add_argument("--account", help="login GitHub attendu en retour") + gh.set_defaults(func=cmd_gh_login) + + sub.add_parser("verify", help="les entrees attendues sont-elles la ?").set_defaults(func=cmd_verify) + + args = ap.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/secrets/tests/test_agent_keyring_gh_login.py b/scripts/secrets/tests/test_agent_keyring_gh_login.py new file mode 100644 index 0000000000..7caaa2c089 --- /dev/null +++ b/scripts/secrets/tests/test_agent_keyring_gh_login.py @@ -0,0 +1,248 @@ +"""Regression #17425 -- `gh-login` ne persiste un jeton qu'apres avoir prouve a QUI il appartient. + +Trois defauts distincts ont coexiste dans la premiere version de `cmd_gh_login`, +et les trois etaient SILENCIEUX : l'organe rendait 0 et affichait un `OK`. + +1. **Shadowing de l'identite.** La boucle de resolution de l'attendu utilisait + `login` comme variable de boucle, ecrasant l'identite rendue par + `gh api user`. La comparaison finale confrontait donc deux valeurs + *attendues* -- elle ne pouvait plus echouer. Temoin negatif ci-dessous : + un jeton appartenant a `attaquant-quelconque` etait ACCEPTE comme + `myia-ai-01`. + +2. **`returncode` et login vide non refuses.** `who.returncode` n'etait jamais + lu, et le garde s'ecrivait `if expected and login and ...` : un login vide + SAUTAIT le controle au lieu de le faire echouer. + +3. **Persistance avant validation.** `gh auth login` etait appele en PREMIER. + Il ecrit dans la configuration de `gh`, partagee par toutes les lanes de la + machine : constater apres coup qu'on a installe la mauvaise identite ne la + desinstalle pas, et le degat frappe les sessions voisines. + +Le 4e test couvre le depot de la passphrase : `keyring` bascule en silence sur +un backend de repli quand le coffre natif manque, et certains replis ecrivent +EN CLAIR sur disque. +""" + +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path + +import pytest + +_MODULE_PATH = Path(__file__).resolve().parents[1] / "agent_keyring.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("agent_keyring_under_test", _MODULE_PATH) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture() +def mod(): + return _load_module() + + +class _Entry: + def __init__(self, title: str, username: str, password: str): + self.title = title + self.username = username + self.password = password + + +class _Res: + def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = ""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def _args(entry="myia-ai-01", group="Agents", account=None): + return types.SimpleNamespace(entry=entry, group=group, account=account) + + +def _wire(mod, monkeypatch, *, api_result: _Res, token: str = "ghp_" + "a" * 36): + """Branche l'organe sur un faux coffre et un faux `gh`, et JOURNALISE les appels. + + Le journal est le coeur du test 3 : ce qui compte n'est pas seulement le code + de sortie, c'est de prouver que `gh auth login` n'a PAS ete appele. + """ + calls: list[list[str]] = [] + + monkeypatch.setattr(mod.shutil, "which", lambda name: "/usr/bin/gh") + monkeypatch.setattr(mod, "open_vault", lambda *a, **k: object()) + monkeypatch.setattr(mod, "find_entry", + lambda kp, title, grp: _Entry("github ai-01", "myia-ai-01", token)) + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + if cmd[:3] == ["gh", "api", "user"]: + return api_result + if cmd[:3] == ["gh", "auth", "login"]: + return _Res(0) + raise AssertionError(f"commande inattendue : {cmd}") + + monkeypatch.setattr(mod.subprocess, "run", fake_run) + return calls + + +def _persisted(calls) -> bool: + return any(c[:3] == ["gh", "auth", "login"] for c in calls) + + +# --------------------------------------------------------------------------- +# 1. shadowing -- le temoin negatif du defaut principal +# --------------------------------------------------------------------------- + +def test_identite_divergente_est_refusee(mod, monkeypatch): + """Le coffre porte 'myia-ai-01' mais le jeton appartient a quelqu'un d'autre. + + AVANT le correctif, ce cas rendait EXIT_OK : la variable `login` avait ete + ecrasee par la boucle et valait 'myia-ai-01', donc la comparaison portait + sur 'myia-ai-01' == 'myia-ai-01'. + """ + calls = _wire(mod, monkeypatch, api_result=_Res(0, "attaquant-quelconque\n")) + rc = mod.cmd_gh_login(_args()) + assert rc == mod.EXIT_DEFECT + assert not _persisted(calls), "la mauvaise identite a ete PERSISTEE dans `gh`" + + +def test_identite_concordante_est_acceptee(mod, monkeypatch): + """Controle positif : sans lui, un test qui refuse tout passerait aussi.""" + calls = _wire(mod, monkeypatch, api_result=_Res(0, "myia-ai-01\n")) + assert mod.cmd_gh_login(_args()) == mod.EXIT_OK + assert _persisted(calls), "l'identite etait bonne, le jeton aurait du etre persiste" + + +def test_la_boucle_de_resolution_ne_touche_pas_a_login(mod): + """Le defaut nomme, teste a sa racine : aucune variable de boucle de + `cmd_gh_login` ne doit s'appeler `login`.""" + import ast + + tree = ast.parse(_MODULE_PATH.read_text(encoding="utf-8")) + fn = next(n for n in ast.walk(tree) + if isinstance(n, ast.FunctionDef) and n.name == "cmd_gh_login") + noms = set() + for node in ast.walk(fn): + if isinstance(node, ast.For): + for t in ast.walk(node.target): + if isinstance(t, ast.Name): + noms.add(t.id) + assert "login" not in noms, ( + "une variable de boucle nommee `login` ecrase l'identite lue par " + "`gh api user` et rend la comparaison finale tautologique" + ) + + +# --------------------------------------------------------------------------- +# 2. returncode et login vide +# --------------------------------------------------------------------------- + +def test_api_en_echec_est_refusee(mod, monkeypatch): + calls = _wire(mod, monkeypatch, api_result=_Res(1, "", "HTTP 401: Bad credentials")) + assert mod.cmd_gh_login(_args()) == mod.EXIT_DEFECT + assert not _persisted(calls) + + +def test_login_vide_avec_rc_zero_est_refuse(mod, monkeypatch): + """rc=0 avec une sortie vide n'est pas une identite. + + AVANT le correctif, `if expected and login and ...` sautait le controle : + une reponse vide se lisait comme un succes. + """ + calls = _wire(mod, monkeypatch, api_result=_Res(0, " \n")) + assert mod.cmd_gh_login(_args()) == mod.EXIT_DEFECT + assert not _persisted(calls) + + +def test_identite_attendue_inconnue_rend_unknown(mod, monkeypatch): + """Ne pas savoir n'est pas valider : sans attendu resolvable, rc=2, pas 0.""" + _wire(mod, monkeypatch, api_result=_Res(0, "quelconque\n")) + monkeypatch.setattr(mod, "find_entry", + lambda kp, t, g: _Entry("entree hors catalogue", "", "ghp_" + "b" * 36)) + assert mod.cmd_gh_login(_args(entry="entree hors catalogue")) == mod.EXIT_UNKNOWN + + +# --------------------------------------------------------------------------- +# 3. ordre des operations -- valider AVANT de persister +# --------------------------------------------------------------------------- + +def test_api_user_est_interrogee_avant_auth_login(mod, monkeypatch): + calls = _wire(mod, monkeypatch, api_result=_Res(0, "myia-ai-01\n")) + mod.cmd_gh_login(_args()) + ordre = [c[1] for c in calls] + assert ordre.index("api") < ordre.index("auth"), ( + "`gh auth login` ecrit dans une configuration partagee : il doit venir " + "APRES la validation d'identite, jamais avant" + ) + + +def test_le_jeton_passe_par_l_environnement_pas_par_la_config(mod, monkeypatch): + """La validation ne doit laisser aucune trace persistante.""" + vus: dict[str, dict] = {} + + monkeypatch.setattr(mod.shutil, "which", lambda name: "/usr/bin/gh") + monkeypatch.setattr(mod, "open_vault", lambda *a, **k: object()) + monkeypatch.setattr(mod, "find_entry", + lambda kp, t, g: _Entry("github ai-01", "myia-ai-01", "ghp_" + "c" * 36)) + + def fake_run(cmd, **kwargs): + if cmd[:3] == ["gh", "api", "user"]: + vus["env"] = kwargs.get("env") or {} + return _Res(0, "myia-ai-01\n") + return _Res(0) + + monkeypatch.setattr(mod.subprocess, "run", fake_run) + mod.cmd_gh_login(_args()) + assert vus["env"].get("GH_TOKEN", "").startswith("ghp_") + + +# --------------------------------------------------------------------------- +# 4. depot de la passphrase -- jamais dans un backend de repli +# --------------------------------------------------------------------------- + +class _FauxBackend: + def __init__(self): + self.ecrit = [] + + def set_password(self, service, user, value): + self.ecrit.append((service, user, value)) + + +def _backend_nomme(module_name: str, class_name: str): + faux = type(class_name, (_FauxBackend,), {}) + faux.__module__ = module_name + return faux() + + +def test_backend_en_clair_refuse_l_ecriture(mod, monkeypatch): + kr = _backend_nomme("keyrings.alt.file", "PlaintextKeyring") + monkeypatch.setattr(mod, "_keyring", lambda: kr) + with pytest.raises(SystemExit) as exc: + mod.write_passphrase("une-passphrase") + assert exc.value.code == mod.EXIT_DEFECT + assert kr.ecrit == [], "la passphrase a ete ECRITE dans un backend en clair" + + +def test_backend_natif_autorise_l_ecriture_et_est_atteste(mod, monkeypatch): + kr = _backend_nomme("keyring.backends.Windows", "WinVaultKeyring") + monkeypatch.setattr(mod, "_keyring", lambda: kr) + assert mod.write_passphrase("une-passphrase") is None + assert "WinVaultKeyring" in mod.assert_backend_is_native() + assert len(kr.ecrit) == 1 + + +def test_backend_encrypted_de_repli_refuse(mod, monkeypatch): + """`EncryptedKeyring` de `keyrings.alt` derive d'un mot de passe faible : + il n'est pas le coffre natif, et le nom seul ne suffit pas a le blanchir.""" + kr = _backend_nomme("keyrings.alt.file", "EncryptedKeyring") + monkeypatch.setattr(mod, "_keyring", lambda: kr) + with pytest.raises(SystemExit): + mod.write_passphrase("une-passphrase") + assert kr.ecrit == [] diff --git a/scripts/secrets/tests/test_agent_keyring_ignore_source.py b/scripts/secrets/tests/test_agent_keyring_ignore_source.py new file mode 100644 index 0000000000..c01581ddde --- /dev/null +++ b/scripts/secrets/tests/test_agent_keyring_ignore_source.py @@ -0,0 +1,66 @@ +"""`get` n'ecrit un secret que dans un chemin ignore par une regle VERSIONNEE. + +Revue Hermes du 22/09 sur #17425 : la doc promettait que l'organe nomme la source +de l'ignorance (`git check-ignore -v`), le code ne lisait que le code retour +(`-q`). Un chemin ignore par le seul `.git/info/exclude` -- local au clone -- +passait donc pour protege. La mesure est desormais celle de l'organe de +couverture des secrets (`scripts/ci/check_secret_paths_ignored.py`, #17442). +""" + +from __future__ import annotations + +import importlib.util +import subprocess +from pathlib import Path + +import pytest + +_MODULE_PATH = Path(__file__).resolve().parents[1] / "agent_keyring.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("agent_keyring_ignore_under_test", _MODULE_PATH) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +@pytest.fixture() +def repo(tmp_path, monkeypatch): + _git(tmp_path, "init", "-q") + (tmp_path / ".gitignore").write_text("versionne/*\n!versionne/visible.env\n", encoding="utf-8") + _git(tmp_path, "add", ".gitignore") + _git(tmp_path, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "init") + (tmp_path / ".git" / "info").mkdir(parents=True, exist_ok=True) + (tmp_path / ".git" / "info" / "exclude").write_text("local-seulement/\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + return tmp_path + + +def test_regle_versionnee_protege(repo): + statut, source = _load_module()._git_ignored(repo / "versionne" / "a.env") + assert statut == "VERSIONNEE" and source == ".gitignore" + + +def test_exclude_local_ne_protege_pas_le_depot(repo): + statut, source = _load_module()._git_ignored(repo / "local-seulement" / "a.env") + assert statut == "LOCALE" + assert source.replace("\\", "/").endswith(".git/info/exclude") + + +def test_chemin_non_ignore(repo): + assert _load_module()._git_ignored(repo / "ailleurs" / "a.env")[0] == "NON_IGNORE" + + +def test_motif_de_negation_n_est_pas_une_protection(repo): + assert _load_module()._git_ignored(repo / "versionne" / "visible.env")[0] == "NON_IGNORE" + + +def test_hors_depot_est_non_mesure(repo, tmp_path_factory): + dehors = tmp_path_factory.mktemp("dehors") / "a.env" + assert _load_module()._git_ignored(dehors) is None diff --git a/scripts/secrets/tests/test_agent_keyring_pure.py b/scripts/secrets/tests/test_agent_keyring_pure.py new file mode 100644 index 0000000000..ca63a0eb06 --- /dev/null +++ b/scripts/secrets/tests/test_agent_keyring_pure.py @@ -0,0 +1,124 @@ +"""Invariants purs de `agent_keyring` : `secret_kind`, `entry_key`, `fingerprint`. + +Revue Hermes du 24/09 sur #17425, point 2 : seul `cmd_get` etait teste. Ces trois +fonctions portent pourtant les contrats dont depend le reste : + +- `secret_kind` tranche jeton / mot de passe sur la FORME, jamais sur le titre ; +- `entry_key` rend les deux noms sous lesquels une entree se demande ; +- `fingerprint` est l'empreinte comparee d'une machine a l'autre pour le critere + des 7/7 : si son sel ou son nombre de tours change, les empreintes deja + publiees deviennent incomparables, en silence. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import re +from pathlib import Path +from types import SimpleNamespace + +import pytest + +_MODULE_PATH = Path(__file__).resolve().parents[1] / "agent_keyring.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("agent_keyring_pure_under_test", _MODULE_PATH) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod + + +mod = _load_module() + + +# --- secret_kind ------------------------------------------------------------- + +@pytest.mark.parametrize("value", [None, ""]) +def test_secret_kind_empty(value): + assert mod.secret_kind(value) == "vide" + + +@pytest.mark.parametrize( + "value", + [ + "ghp_" + "A1b2C3d4E5" * 4, + "gho_" + "x" * 30, + "github_pat_" + "a1_B2" * 10, + " ghp_" + "Z9" * 20 + "\n", # espaces autour : la forme est lue apres strip + ], +) +def test_secret_kind_token_shapes(value): + assert mod.secret_kind(value) == "jeton" + + +@pytest.mark.parametrize( + "value", + [ + "Xk7#pQ2!mW9&rT4$zL1a", # mot de passe genere de 20 caracteres (mesure du 22/09) + "ghp_trop_court", # prefixe de jeton, forme incomplete + "github_pat_court", + "le titre dit github mais la valeur est un mot de passe", + ], +) +def test_secret_kind_password_shapes(value): + assert mod.secret_kind(value) == "mot de passe" + + +# --- entry_key --------------------------------------------------------------- + +def test_entry_key_normalises_title_and_username(): + entry = SimpleNamespace(title=" GitHub AI-01 ", username=" MYIA-AI-01") + assert mod.entry_key(entry) == ("github ai-01", "myia-ai-01") + + +def test_entry_key_tolerates_missing_fields(): + assert mod.entry_key(SimpleNamespace(title=None, username=None)) == ("", "") + + +# --- fingerprint ------------------------------------------------------------- + +_FP_RE = re.compile(r"^<(\d+) car\.> pbkdf2:([0-9a-f]{12})$") + + +def test_fingerprint_empty(): + assert mod.fingerprint("") == "" + + +def test_fingerprint_contract_constants_are_pinned(): + # Changer l'un ou l'autre rend incomparables les empreintes deja publiees. + assert mod._FP_SALT == b"MyIA-Keys/agent_keyring/fingerprint/v1" + assert mod._FP_ROUNDS == 600_000 + + +def test_fingerprint_known_vector(): + # Vecteur fige : la comparaison cross-machine repose sur ce calcul exact. + assert mod.fingerprint("correct horse battery staple") == "<28 car.> pbkdf2:bc8fb4a2ffdd" + + +def test_fingerprint_matches_reference_pbkdf2(): + value = "une passphrase de test" + expected = hashlib.pbkdf2_hmac( + "sha256", value.encode("utf-8"), mod._FP_SALT, mod._FP_ROUNDS + ).hex()[:12] + match = _FP_RE.match(mod.fingerprint(value)) + assert match is not None + assert int(match.group(1)) == len(value) + assert match.group(2) == expected + + +def test_fingerprint_is_deterministic_and_discriminating(): + a1 = mod.fingerprint("passphrase-A") + a2 = mod.fingerprint("passphrase-A") + b = mod.fingerprint("passphrase-B") + assert a1 == a2 + assert a1 != b + + +def test_fingerprint_leaks_no_part_of_the_value(): + value = "queue-reconnaissable-XYZW" + out = mod.fingerprint(value) + assert "XYZW" not in out + assert value not in out