From 5ddff8576ecf953f42c0f173059aec5852efe048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Mon, 19 Oct 2026 15:56:24 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(securite):=20la=20solution=20de=20r?= =?UTF-8?q?=C3=A9f=C3=A9rence,=20chiffr=C3=A9e=20par=20ansible-vault?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le workflow de `fondations-premier-workflow` était commité en clair dans un dépôt public : l'apprenant qui clone avait la réponse sous la main. `meta.yml` déclarait `solutions: plaintext` et motivait ce choix par le fait qu'un workflow chiffré « ne se relirait pas ». C'était vrai des OUTILS du dépôt, pas une fatalité : deux lecteurs lisaient le fichier en clair, et il suffisait de leur apprendre à déchiffrer. `scripts/valider-labs.py` pose la solution par-dessus le répertoire de travail : il la déchiffre à la pose, sinon l'erreur parlerait de YAML invalide et non de chiffrement. `tests/test_solutions_exemplaires.py` contrôle que la solution épingle ses actions sur un SHA et déclare ses `permissions` : ses trois lectures déchiffrent. Sans cette seconde adaptation, il aurait gardé ses quinze verts en n'ayant plus aucun `uses:` à examiner, ce qui est précisément le défaut que ce dépôt traque chez les autres. Vérifié sur le comportement : le contrôle voit les 62 lignes du workflow et ses 2 `uses:` épinglés ; sans le secret, la lecture lève et les tests se déclarent non joués. `validate-structure` est vert et les 15 tests passent. Le rejeu « 0 avant, 100 après » ne se joue plus en CI, et c'est le coût assumé du chiffrement : le cycle a besoin du clair, et `.vault-pass` n'est pas sur la forge, comme dans aucun catalogue de la collection. Les deux étapes se déclarent non jouées par un avertissement visible. L'opposabilité du relevé ne tient donc plus à cette CI mais à la campagne locale qui l'a produit. L'autre voie, confier le secret au dépôt GitHub, a été écartée en connaissance. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 26 ++- .gitignore | 4 + CHANGELOG.fr.md | 36 ++++ CHANGELOG.md | 35 ++++ .../solution/.github/workflows/ci.yml | 192 ++++++++++++------ meta.yml | 18 +- pyproject.toml | 2 +- scripts/lecture_solution.py | 81 ++++++++ scripts/valider-labs.py | 9 +- tests/test_solutions_exemplaires.py | 29 ++- uv.lock | 2 +- 11 files changed, 357 insertions(+), 77 deletions(-) create mode 100644 scripts/lecture_solution.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2985960..7cc496f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,14 +239,36 @@ jobs: # se reproduit pas ici est ROUGE. Le validateur refuse de jouer avec une # autre version d'act que celle de `mise.toml`, et enregistre cette # version avec le digest de l'image du runner. + # + # DEPUIS QUE LES SOLUTIONS SONT CHIFFRÉES, CE REJEU PEUT NE PAS AVOIR LIEU + # + # Le cycle pose la solution du formateur sur le répertoire de travail, il + # lui faut donc le clair. `.vault-pass` n'est pas sur la forge, et c'est + # la convention de toute la collection : aucun catalogue n'y confie son + # secret. Le rejeu se déclare alors NON JOUÉ, par un avertissement + # visible ici, plutôt que de passer pour un succès. + # + # Ce qu'on perd est réel : l'opposabilité du relevé ne tient plus à cette + # CI, mais à la campagne locale qui l'a produit, comme dans les onze + # autres catalogues dont la CI ne rejoue rien. - name: Rejouer le cycle « 0 avant, 100 après » - run: mise exec -- uv run --no-sync scripts/valider-labs.py + run: | + if [ ! -f .vault-pass ]; then + echo "::warning::rejeu NON JOUÉ : les solutions sont chiffrées et .vault-pass n'est pas sur la forge. La preuve est le validation-labs.json commité, produit en local." + exit 0 + fi + mise exec -- uv run --no-sync scripts/valider-labs.py # Le verdict versionné doit couvrir TOUT le catalogue : un lab ajouté # sans validation fait échouer ce contrôle, même si tous les autres # verdicts sont verts. - name: Le verdict versionné couvre le catalogue et est vert - run: uv run --no-sync scripts/valider-labs.py --check + run: | + if [ ! -f .vault-pass ]; then + echo "::warning::contrôle NON JOUÉ, même raison : il rejoue les solutions pour conclure." + exit 0 + fi + uv run --no-sync scripts/valider-labs.py --check - name: Ce que la campagne a changé, s'il y a lieu if: always() diff --git a/.gitignore b/.gitignore index 22b7bdd..ee0eb5a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ __pycache__/ # État local de dsoxlab, propre à chaque poste. .dsoxlab.db .dsoxlab-context.json + +# Le mot de passe qui déchiffre les solutions du formateur. Jamais commité : +# le commiter annulerait le chiffrement, puisqu'il voyage avec ce qu'il ouvre. +.vault-pass diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index a627f4b..5416e24 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -21,6 +21,42 @@ ne recule pas, et 0.2.0 sous v1.2.0 aurait menti sur l'ordre des choses. --- +## [0.2.2] - 2026-10-19 + +### Sécurité + +- **La solution de référence est chiffrée par ansible-vault, et `meta.yml` ne + déclare plus ses solutions en clair.** Le workflow de + `fondations-premier-workflow` était commité en clair dans un dépôt public : + l'apprenant qui clone avait la réponse sous la main. + + La déclaration `solutions: plaintext` motivait ce choix par le fait qu'un + workflow chiffré « ne se relirait pas ». C'était vrai des OUTILS du dépôt, + pas une fatalité : deux lecteurs lisaient le fichier en clair, et il + suffisait de leur apprendre à déchiffrer. + + `scripts/valider-labs.py` pose la solution par-dessus le répertoire de + travail : il la déchiffre à la pose. `tests/test_solutions_exemplaires.py` + contrôle que la solution épingle ses actions sur un SHA et déclare ses + `permissions` : ses trois lectures déchiffrent. Sans cette seconde + adaptation, le contrôle aurait gardé ses quinze verts en n'ayant plus aucun + `uses:` à examiner, ce qui est précisément le défaut que ce dépôt traque + chez les autres. + + Vérifié sur le comportement : le contrôle voit les 62 lignes du workflow et + ses 2 `uses:` épinglés ; sans le secret, la lecture lève et les tests se + déclarent non joués. + +- **Le rejeu « 0 avant, 100 après » ne se joue plus en CI.** C'est le coût + assumé du chiffrement : le cycle a besoin du clair, et `.vault-pass` n'est + pas sur la forge, comme dans aucun catalogue de la collection. Les deux + étapes concernées se déclarent non jouées par un avertissement visible, au + lieu de passer pour un succès ou de rendre un rouge permanent. + + L'opposabilité de `validation-labs.json` ne tient donc plus à cette CI mais + à la campagne locale qui l'a produit. L'autre voie, confier le secret à un + secret de dépôt, a été écartée en connaissance. + ## [0.2.1] - 2026-10-19 ### Modifié diff --git a/CHANGELOG.md b/CHANGELOG.md index f79c56a..1aba995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,41 @@ backwards, and 0.2.0 under v1.2.0 would have lied about the order of things. --- +## [0.2.2] - 2026-10-19 + +### Security + +- **The reference solution is encrypted with ansible-vault, and `meta.yml` no + longer declares its solutions as plaintext.** The workflow of + `fondations-premier-workflow` was committed in plaintext in a public + repository: anyone cloning had the answer at hand. + + The `solutions: plaintext` declaration justified that by saying an encrypted + workflow "would no longer be read back". That was true of the repository's + TOOLS, not a given: two readers consumed the file in plaintext, and they + only had to learn to decrypt. + + `scripts/valider-labs.py` lays the solution over the working directory: it + decrypts as it lays. `tests/test_solutions_exemplaires.py` checks that the + solution pins its actions to a SHA and declares its `permissions`: its three + reads now decrypt. Without that second change, the check would have kept its + fifteen greens while having no `uses:` left to examine, which is exactly the + fault this repository hunts for elsewhere. + + Verified on behaviour: the check sees the workflow's 62 lines and its 2 + pinned `uses:`; without the secret, the read raises and the tests declare + themselves not run. + +- **The "0 before, 100 after" replay no longer runs in CI.** That is the + accepted cost of encryption: the cycle needs the plaintext, and + `.vault-pass` is not on the forge, as in no catalogue of the collection. + Both affected steps declare themselves not run through a visible warning, + instead of passing for a success or turning permanently red. + + The standing of `validation-labs.json` therefore no longer rests on this CI + but on the local campaign that produced it. The other option, entrusting the + secret to a repository secret, was knowingly set aside. + ## [0.2.1] - 2026-10-19 ### Changed diff --git a/labs/fondations-premier-workflow/solution/.github/workflows/ci.yml b/labs/fondations-premier-workflow/solution/.github/workflows/ci.yml index 0d9ff1c..3055f76 100644 --- a/labs/fondations-premier-workflow/solution/.github/workflows/ci.yml +++ b/labs/fondations-premier-workflow/solution/.github/workflows/ci.yml @@ -1,62 +1,130 @@ -# Solution de reference. -# -# Le nom du fichier est libre : GitHub joue TOUT fichier `.yml` ou `.yaml` de -# `.github/workflows/`. Ce qui n'est pas libre, c'est l'emplacement : un -# workflow pose ailleurs n'est jamais lu, et rien ne le signale. -name: CI - -# Deux evenements, et les filtres de branche avec. -# -# act les ignore : il joue l'evenement qu'on lui nomme, quelle que soit la -# branche. Ce n'est pas une raison de les omettre, parce que sur GitHub ils -# comptent, et un workflow eprouve sans eux se comporte autrement une fois -# pousse. -on: - push: - branches: [main] - pull_request: - branches: [main] - -# Les permissions se declarent au niveau du workflow, et au plus juste. -# -# Sans ce bloc, le jeton prend les permissions par defaut du depot, qui peuvent -# etre en ecriture sur tout. Ici le job ne fait que lire le code et lancer des -# tests : `contents: read` suffit, et c'est ce que Scorecard attend. -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-24.04 - steps: - # Chaque action est epinglee sur le SHA COMPLET de son commit, avec la - # version en commentaire. Un tag est deplacable : `@v7` peut designer un - # autre code demain, y compris du code hostile si le depot est compromis. - # Un SHA ne bouge pas. - # - # Le commentaire n'est pas decoratif : c'est lui que Dependabot lit pour - # proposer la montee de version, et lui qui rend la ligne relisible. - - name: Recuperer le code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # Sans cette ligne, checkout laisse le jeton du job dans - # `.git/config`, ou tout step suivant peut le lire, et ou il finit - # dans un artefact si quelqu'un archive le repertoire. C'est le - # constat `artipacked` de zizmor, et il tombe sur ce workflow tant - # que la ligne manque. - # - # Ce job ne pousse rien : il n'a aucun usage du jeton apres le clone. - persist-credentials: false - - - name: Installer Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.12' - - - name: Installer les dependances - run: python -m pip install -r requirements.txt - - # `-v` nomme les neuf tests un par un. C'est ce que la verification lit : - # un pipeline qui affiche « tests OK » sans les avoir lances ne passe pas. - - name: Lancer les tests - run: python -m pytest -v +$ANSIBLE_VAULT;1.1;AES256 +38613662626433386238396564616438653534323237396265333931663232616531643130313937 +3463313135343037623563383631333639393332633965340a336230363331396461363966303732 +61653264333065656230376535636561353838393635383832626630373930353734316336633463 +3136346663323362320a663632356130383431653861313433653934666633323930653037613334 +37313637303265373662623733303731326639326133653734666135353764313335646239376138 +38366566316566343536373862396130643663376430643264383465323736633330633736306431 +62396265313665646535336638633265363638663234613235663434653661623265653837303666 +36623836346338646164623466386130663064356633383837336165326137636663336531333863 +31373430613030653666663131363339626437306165353664663737346362356161633735343637 +34353565393035616237376633323538323063376339323832643761373932616438633964366234 +35343033633535346633623863333538663063623639626435636533336434626634396531366365 +34653633333039366134376662363764616639643335376561333339393233663265373734306362 +36316362636133616233343036643063613431363663333039633138393062633838396231353633 +65396166306230396163326430323534393137623061313632326464336361653137313636656438 +66643939333464643164616137633834636130346433386165333561306439383036366536636634 +36363333313439363635303731616566376664376364303035353835386137326338326532323962 +61346463336433336238353239313438313933633466323962346166626561346338653964313861 +32306337396336623938323830653362306330336264636562373935306534303733366166356661 +34653939343162303062613937636635373737643932633637656436623234633735346362363431 +33383935373163643534653265326638616462306463333439323935353263326163353936616332 +63333332316534333166393165613430386231393139613038343833656166373231326631373463 +38666138386434613538613362383364333963623737663434393632646365333738393435396662 +35356433353931643665303431626339636633343332313634663230633962373031346134396238 +63323232353234356539633163363662636339383066653465626238646334646631376662633830 +39383562333839363939313263353131643635346166626564373238666337333933313061653663 +33303635366662633162623830613366616431646637333434626233643766663435393137343266 +65666133303636316630663832303835656133396632366164643939396233393162626337623765 +30623633333063393236616331363633333462333934383138643930616538333334303662383666 +30636162656431613261353566393562383432363662353735343566386564373631356439643964 +39306232646139383830656462393832363037663762383033626338336438376163643361343632 +38633535356666653239643033653065623637663836376161323266396462393462616534633734 +62386337316435333437353835653139613932343461343839353462386261613635333439616530 +31373730363432646162383633386637386535343738373762643961363033323661383835656238 +31646461616539346262633735313237346565353238313038353762646361356162643337333066 +37616232383930326261323231313834323165363837313263653264373266303565646466663231 +62353964336130656530626530356561643437393432336663313535626635316564373964666332 +63396463643765393964623461373365306434383136643538343930363066333036646637386363 +64396431383237333032613763633837356439633832633266666233383334386235373563353637 +62626233623964653238393661633938326637313635636263356165366164306134646332386536 +32643033396164363036616639653538363633383462303132376636386162626631643864613466 +35306532316435663265643166633631616631323435653332663437323561303163396632373864 +38383062393836643134353962323431666135343937623563613163383738356431623165663765 +30666261653436626632326465383435313963613263313531653263393137306165393231656331 +38356464383862613061376230313266303462363332313864363339303032333131613737326436 +30653761386134656635643931316666366534373965663565633238373838643261343735623737 +63353133383934373931646333386164653637343264323033623936356465396363366338393330 +30383264376366396334303438633566326632383130646361326631643062643438646439333163 +38613432313537653339363537626332633633363262636262386434636637363466366537643733 +38643238346164386236626538656166636236383863376161353563326466363064393334343562 +34326538353539633363316132323735303737626664613265646232366231333035313537626539 +33383337643338363634313439643038363761643138613538333363306135303739643263323433 +38303534656236613338353034646437656538313731333730346639333835636635373837653137 +33643063353537393763656266633834383232643735393930303335313130303265346430613638 +31633538626437663333373338663730636564323239643635383231613061316637633637303037 +35306439396234653030353137363438636365333730616534333634323062336664303266373438 +38653362623039326262376363313762633630303532303933356433616163393639363731353663 +39363334353162313937393830353266623935373533343034363032383462663836336635396531 +66313330303965613839316633323161396234396537653462636462663834313264613466656533 +31303937623261666235333330323030323263363162303432383866356263656537386439336666 +38633939663264666535623363636266313935326566376131343365363861306661623135313463 +61396163653833393937663733623331666537316662326133643465336333323531653661366239 +30666334373833656630363932383637373836303464316531663239333836383532376135323963 +61636138623231333436316439393535353964356139323238633962613565656233636438613330 +66326665346338343538353239653734323663626331613931636465363264336330623830323661 +34303436363037636564393431383162313034626439323536633832666336646166656134393538 +62356531643039386463336361656561626130363764336135313039626264313537393539626163 +31303732323465636336623533383238346430343834303134326565643562376564653331656433 +38613066363637346335633335613934333763663235646334663830356135633332653336316336 +39663835373866633862383561643962396434383037356631623939373435323764353032356163 +33326661303837333832376162363266343565313133373065323633356663663561646664353263 +64373864333962376161616431393961303639626661363661393334333638663533323931393065 +35386538373630306532636164383032353261373035643337356330363364306563383435343664 +62663239616263366663356139383531663132396230363763386334666230646436646438656631 +37346462356537633439376333616533306661303563306363303132343830666433343731363335 +30346362636134303866666338626561366430646134613838666361303936663164366237373035 +66313965623638626663396163643537333864393164633732623532373535356262303034346438 +31343961306435346635316464323439303532646231663732343564313136353938323838353530 +30616166373463666533333334306335306135346139323966643563626163656263643265303664 +62633662373365633434356662393335346163366464333462343862343934313535383633303938 +35663864306463323436633966356261386133656535626132376636666133643239663365373937 +38383231643462346632376135366464343137306130663134353134613039633561356263346236 +38306334363430343134313666303964323463336638303762663736353039356630303137343062 +32323762613530313366336661646333613938323434393862316636303263306638313635386535 +38613630643330646130306239313038656265396630636637363731306430353131633235366666 +39623839383463313236396638663938326533353032373662356263366430333739393534386534 +32633563383232373637353064626330353666393964343964666561633731313665363365306563 +37613538656663616630613735353965383235613366653961343037356438616137336239646538 +30616566336537353937663333666666613539353464646464613337363332363962346438333861 +35303062393533313239306630306364343737376330363661643865646237303365313862363266 +65663836653161653030343864623131633737626338393132333832663463306565366462323435 +37633164396639396132353462366439323262306337613535333330666264623131336664323366 +30633862653362383931623266353931323435613734353765363637323530643862333532393530 +31396561373662396530303331616466363430653666333764623130366334333230353965376465 +38346430386136353534323361643361373533653538373966383030383065326531623733393761 +34616439633762613366393464333033633861623035313832313637376139653536356263653164 +38363763386138386565393336656633356538393034636436396261326236383231363637376265 +31346366376134653532383430313832363361326465316138613234353166353464343533623864 +31336333643434313937646635333766656532623233356336393864656530336164353363323933 +36663765376364623638613532383134643238613937323935356265633234643730363539636663 +62653362616238306330333135353238393162623036366133363239613333663339396138393735 +34346239386365323366656237383862666362306361643431333935646665623161643831366265 +36663535656163356364383763393062363232303332396334623034316536343935373561306237 +61653737346336623464363437313439383233373864653032363965336532363138313566656338 +36323432633463366335316364313362386633623064333934383433633737353536663963656135 +39643363643931306632373061663433623065323133666161356261366666313461323664653563 +33343530633666666230653435366366376465393633643339316432383563356536376364313565 +39353561653164353538306165326137616330343761303262626637643831363561343538656162 +33363237666636643632376463383933326236323135366535326565663061376362366332663336 +36373036373564363238303264613864623661363731333436393735303063636430356161363636 +30316135373563323536623139373832653234633434383464306365376633343031313566366464 +64663532333439333831313339346131313766643466383034363361613265363034643236323663 +62613738633766663364306364316234663036356666333962393937663432333237646137613331 +62343338393735326637333266326366663664646631613566326262653830333333363561396465 +38393565626466306131396339656432353936323134643933646165616234336464316130393033 +31626534663265366636303431656234373033323939626166633464616463626631393162316461 +63396437623538366131366330373634623732336432306232623966306266656665373766306432 +31373238626565376564396237636662333230616462626531373161626662386331333036376665 +65363465323837653039633337313463656362636361356461653836303431333732313465643438 +61626230323261356332373139646165663037323035636363633566373732653234626632646337 +30633964336234633135373632343764303334623235646534646536643665333765303130363966 +32393438323132356532643932376366353230323561646230376233646364656136393061643764 +32633530326436336561333532643138393437353761386361393064633037353061353734366261 +62653162386364373936353833636434663361313934323433333765616164653833623039646632 +39323861376564336335626330383635613639656333626636333634353763643965633338306330 +39316666363932353330636331646239633361653861383232363433343834646464356439313136 +62393939623964326439646333633463336663643165666532303662663037366436636364313761 +66383836653133333666353866346663616136313433306135353539353334373935393232393563 +65386661386430313365353662393532643765306131643231623038373662633939373561623461 +37306264336636316633326635663265653936363961626338663535343865643061 diff --git a/meta.yml b/meta.yml index 580cdc4..0234bdd 100644 --- a/meta.yml +++ b/meta.yml @@ -15,14 +15,18 @@ repo: id: github-actions-training - # La solution de référence est un workflow GitHub Actions, rangé dans le lab - # et EN CLAIR : ce dépôt n'a pas de vault, et un workflow chiffré ne se - # relirait pas. + # La solution de référence est un workflow GitHub Actions rangé dans le lab, + # et elle est CHIFFRÉE par ansible-vault depuis la 0.2.2. Ce paragraphe + # déclarait auparavant `solutions: plaintext`, au motif qu'un workflow + # chiffré ne se relirait pas : c'était vrai des OUTILS du dépôt, pas une + # fatalité. Ils déchiffrent maintenant par `scripts/lecture_solution.py`, + # et l'apprenant qui clone n'a plus la réponse sous la main. # - # Sans cette déclaration, `validate-structure` le signale, à juste titre : - # son défaut était de ne regarder que `solution/` à la racine du dépôt, donc - # de ne jamais voir `labs//solution/`. Il regarde depuis dsoxlab 0.5.0. - solutions: plaintext + # Ce que ça coûte, écrit ici plutôt que découvert : le rejeu du cycle et le + # contrôle d'exemplarité des workflows ne se jouent plus en CI, où + # `.vault-pass` n'existe pas. Ils se jouent chez le formateur, et c'est le + # `validation-labs.json` commité qui porte la preuve, comme dans les onze + # autres catalogues de la collection. category: github-actions title: "GitHub Actions Training: verifiable labs" blog_url: "https://www.stephane-robert.info/docs/pipeline-cicd/github/parcours/" diff --git a/pyproject.toml b/pyproject.toml index bae25f1..e5f4ed7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "github-actions-training" -version = "0.2.1" +version = "0.2.2" description = "Labs vérifiables de la formation GitHub Actions du blog, joués avec act" requires-python = ">=3.12" dependencies = [ diff --git a/scripts/lecture_solution.py b/scripts/lecture_solution.py new file mode 100644 index 0000000..0e34e16 --- /dev/null +++ b/scripts/lecture_solution.py @@ -0,0 +1,81 @@ +"""Lire une solution de formateur, qu'elle soit chiffrée ou non. + +Les solutions sont chiffrées par ansible-vault : l'apprenant ne doit pas +pouvoir les lire dans le dépôt qu'il clone. Tout ce qui les CONSOMME passe +donc par ici, plutôt que par un ``read_text`` qui rendrait le blob chiffré. + +Le piège qu'évite ce module est silencieux, et c'est pour ça qu'il mérite un +module plutôt qu'une ligne recopiée deux fois. Un lecteur qui ne déchiffre pas +ne lève pas : il lit ``$ANSIBLE_VAULT;1.1;AES256`` suivi d'un pavé +hexadécimal, et conclut sur ce pavé. Le contrôle des ``ssh`` sans ``-n`` +passerait ainsi au vert sur les soixante-quatre solutions, faute d'y voir une +seule ligne de code, et le piège qu'il garde a déjà coûté un cycle de cluster. + +D'où le choix de lever plutôt que de rendre une chaîne vide quand le +déchiffrement est impossible : un appelant qui reçoit "" en tire une +conclusion, un appelant qui reçoit une exception doit décider quoi en dire. +C'est la règle du dépôt : un contrôle qui n'a pas pu regarder ne conclut +jamais au vert. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent + +#: Jamais commité, par construction : c'est tout l'objet du chiffrement. +VAULT_PASS = RACINE / ".vault-pass" + +#: L'en-tête qu'ansible-vault écrit en première ligne d'un fichier chiffré. +MARQUEUR = "$ANSIBLE_VAULT" + + +class SolutionIllisible(RuntimeError): + """La solution est chiffrée et rien ici ne permet de la déchiffrer.""" + + +def est_chiffree(chemin: Path) -> bool: + """Le fichier porte-t-il l'en-tête d'ansible-vault ?""" + return chemin.read_text(encoding="utf-8", errors="replace").lstrip().startswith( + MARQUEUR + ) + + +def lire_solution(chemin: Path) -> str: + """Le texte de la solution, déchiffré si besoin. + + Lève :class:`SolutionIllisible` si le fichier est chiffré et que le secret + manque ou ne convient pas. L'appelant choisit alors ce qu'il en dit : un + script de validation s'arrête, un test se déclare non joué. + """ + brut = chemin.read_text(encoding="utf-8", errors="replace") + if not brut.lstrip().startswith(MARQUEUR): + return brut + + if not VAULT_PASS.is_file(): + raise SolutionIllisible( + f"{chemin.name} est chiffrée et .vault-pass est absent de la " + "racine du dépôt. Le secret n'est jamais commité : récupère-le " + "avant de rejouer ou d'analyser une solution." + ) + + vu = subprocess.run( + [ + "ansible-vault", + "view", + "--vault-password-file", + str(VAULT_PASS), + str(chemin), + ], + capture_output=True, + text=True, + check=False, + ) + if vu.returncode != 0: + raise SolutionIllisible( + f"ansible-vault n'a pas pu lire {chemin.name} " + f"(code {vu.returncode}) : {(vu.stderr or vu.stdout).strip()[-300:]}" + ) + return vu.stdout diff --git a/scripts/valider-labs.py b/scripts/valider-labs.py index d4e37b4..53a7b8a 100755 --- a/scripts/valider-labs.py +++ b/scripts/valider-labs.py @@ -54,6 +54,7 @@ # sur `sys.path` parce que c'est le répertoire de ce fichier. import verdict_check import yaml +from lecture_solution import est_chiffree, lire_solution RACINE = Path(__file__).resolve().parent.parent LABS = RACINE / "labs" @@ -248,7 +249,13 @@ def poser_solution(lab: Path, journal) -> None: for fichier in fichiers: destination = cible / fichier.relative_to(source) destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(fichier, destination) + if est_chiffree(fichier): + # Posée DÉCHIFFRÉE : un workflow chiffré n'est pas un workflow, + # et l'erreur parlerait de YAML invalide, pas de chiffrement. + destination.write_text(lire_solution(fichier), encoding="utf-8") + shutil.copystat(fichier, destination) + else: + shutil.copy2(fichier, destination) journal.write(f"solution posée : {len(fichiers)} fichier(s)\n") for fichier in fichiers: diff --git a/tests/test_solutions_exemplaires.py b/tests/test_solutions_exemplaires.py index 11eb57a..23d7fda 100644 --- a/tests/test_solutions_exemplaires.py +++ b/tests/test_solutions_exemplaires.py @@ -24,6 +24,7 @@ from __future__ import annotations import re +import sys from pathlib import Path import pytest @@ -32,6 +33,28 @@ REPO = Path(__file__).resolve().parent.parent LABS = REPO / "labs" +# Les solutions sont chiffrées : la lecture passe par le helper qui déchiffre, +# comme `scripts/valider-labs.py`. +sys.path.insert(0, str(REPO / "scripts")) +from lecture_solution import SolutionIllisible, lire_solution # noqa: E402 + + +def _texte(chemin: Path) -> str: + """Le contenu du workflow, déchiffré si la solution est sous vault. + + Un workflow chiffré ne contient aucun `uses:`, aucun bloc `permissions` + et aucune expression : les contrôles de ce module passeraient donc au vert + sans avoir rien vu, ce qui est exactement le défaut que ce dépôt traque + chez les autres. Sans le secret, ils se déclarent non joués. + + C'est le cas en CI, où `.vault-pass` n'existe pas : l'exemplarité des + solutions se contrôle chez le formateur, qui a le secret. + """ + try: + return lire_solution(chemin) + except SolutionIllisible as illisible: + pytest.skip(f"solution non analysable : {illisible}") + # `uses: owner/repo@ref # commentaire`, lu dans le TEXTE et non dans le YAML # analysé : le commentaire de version disparaît à l'analyse, et c'est lui qu'on # veut voir. @@ -77,7 +100,7 @@ def test_le_depot_a_des_workflows_a_controler() -> None: @pytest.mark.parametrize("chemin", CIBLES, ids=identifiant) def test_chaque_action_est_epinglee_avec_sa_version_en_commentaire(chemin: Path) -> None: - for numero, ligne in enumerate(chemin.read_text(encoding="utf-8").splitlines(), 1): + for numero, ligne in enumerate(_texte(chemin).splitlines(), 1): correspondance = RE_USES.match(ligne) if not correspondance: continue @@ -102,7 +125,7 @@ def test_chaque_action_est_epinglee_avec_sa_version_en_commentaire(chemin: Path) @pytest.mark.parametrize("chemin", CIBLES, ids=identifiant) def test_aucun_job_ne_demande_plus_que_la_lecture(chemin: Path) -> None: - workflow = yaml.safe_load(chemin.read_text(encoding="utf-8")) + workflow = yaml.safe_load(_texte(chemin)) assert isinstance(workflow, dict), f"{identifiant(chemin)} n'est pas un workflow." racine = workflow.get("permissions") @@ -134,7 +157,7 @@ def test_aucun_job_ne_demande_plus_que_la_lecture(chemin: Path) -> None: ecritures = {p: d for p, d in valeur.items() if d not in LECTURES} if not ecritures: continue - texte = chemin.read_text(encoding="utf-8") + texte = _texte(chemin) for portee_nom in ecritures: motif = re.compile(rf"(#[^\n]*\n\s*)?{re.escape(portee_nom)}:\s*write[^\n]*(#[^\n]*)?") trouve = motif.search(texte) diff --git a/uv.lock b/uv.lock index 2bc3ff1..c8ecbcf 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "github-actions-training" -version = "0.2.1" +version = "0.2.2" source = { virtual = "." } dependencies = [ { name = "pytest" }, From 89ba905dc0178f032f71b9f5bbd6100de570f954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Mon, 19 Oct 2026 16:00:16 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(ci):=20les=20workflows=20de=20solution?= =?UTF-8?q?=20chiffr=C3=A9s=20sortent=20des=20cibles=20d'analyse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un troisième lecteur que mon inventaire avait manqué, et c'est la CI qui l'a trouvé : actionlint et zizmor analysent « le dépôt ET les solutions », par `scripts/hooks/cibles-workflows.sh`. Sur un fichier chiffré, actionlint rend « "on" section is missing » et « "jobs" section is missing » : un échec qui ne parle pas de chiffrement et qu'on ne peut pas corriger côté workflow. J'avais cherché les lecteurs dans `scripts/` et `tests/`, pas dans les workflows ni les hooks. Le bon périmètre était tout ce qui NOMME un chemin de solution, et c'est ce que ce correctif couvre : les trois appelants passent par le même script, donc un seul endroit à reprendre. Un répertoire de solution dont tous les workflows sont chiffrés est désormais écarté, et l'avertissement part sur STDERR : stdout est la liste des cibles, un message y deviendrait un chemin à analyser. Ce qui reste couvert, et par quoi : l'exemplarité des solutions (actions épinglées sur un SHA, bloc `permissions` déclaré) est contrôlée par `tests/test_solutions_exemplaires.py`, qui déchiffre. C'est là que vit la valeur pédagogique du contrôle. La syntaxe du workflow de solution est, elle, éprouvée autrement et mieux : le cycle local le fait jouer par `act`. Vérifié localement : actionlint analyse les 6 workflows du dépôt et sort en 0, zizmor ne rend aucune trouvaille, et le script annonce le répertoire écarté. Co-Authored-By: Claude Opus 5 --- scripts/hooks/cibles-workflows.sh | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/scripts/hooks/cibles-workflows.sh b/scripts/hooks/cibles-workflows.sh index 3006873..5d549c4 100755 --- a/scripts/hooks/cibles-workflows.sh +++ b/scripts/hooks/cibles-workflows.sh @@ -14,13 +14,47 @@ # # `challenge/work` est le répertoire de travail de l'apprenant, jamais versionné # et toujours en chantier : il n'est jamais contrôlé. +# +# UN WORKFLOW DE SOLUTION CHIFFRÉ N'EST PAS ANALYSABLE +# +# Les solutions sont chiffrées par ansible-vault. actionlint et zizmor +# liraient « $ANSIBLE_VAULT;1.1;AES256 » et rendraient « "on" section is +# missing » : un échec qui ne parle pas de chiffrement et qu'on ne peut pas +# corriger côté workflow. Ces répertoires sont donc écartés, et le dire sur +# STDERR est important : stdout est la liste des cibles, un avertissement y +# deviendrait un chemin à analyser. +# +# Ce qui reste couvert, et par quoi : l'exemplarité des solutions (actions +# épinglées sur un SHA, bloc `permissions` déclaré) est contrôlée par +# `tests/test_solutions_exemplaires.py`, qui DÉCHIFFRE. C'est là que vit la +# valeur pédagogique du contrôle. Et la syntaxe du workflow de solution est +# éprouvée autrement, et mieux : le cycle local le fait jouer par `act`. set -euo pipefail cibles=() [ -d .github/workflows ] && cibles+=(.github/workflows) +ecartes=0 while IFS= read -r -d '' dossier; do + chiffres=0 + lisibles=0 + for workflow in "$dossier"/*.y*ml; do + [ -e "$workflow" ] || continue + if head -c 14 "$workflow" | grep -q 'ANSIBLE_VAULT'; then + chiffres=$((chiffres + 1)) + else + lisibles=$((lisibles + 1)) + fi + done + if [ "$lisibles" -eq 0 ] && [ "$chiffres" -gt 0 ]; then + ecartes=$((ecartes + 1)) + continue + fi cibles+=("$dossier") done < <(find labs -type d -path '*/solution/.github/workflows' -print0 2>/dev/null | sort -z) +if [ "$ecartes" -gt 0 ]; then + printf '%s répertoire(s) de solution écarté(s) : workflows chiffrés, donc non analysables. Leur exemplarité est contrôlée par tests/test_solutions_exemplaires.py, qui déchiffre.\n' "$ecartes" >&2 +fi + printf '%s\n' "${cibles[@]+"${cibles[@]}"}"