Skip to content

Latest commit

 

History

History
1551 lines (1424 loc) · 112 KB

File metadata and controls

1551 lines (1424 loc) · 112 KB

🇬🇧 English · 🇫🇷 Français

Journal des changements

Les changements notables, au format Keep a Changelog, versionnés selon Semantic Versioning.

Ce fichier est lu par le workflow de release : la section correspondant à un tag devient le corps de sa GitHub Release. Une entrée absente d'ici est une entrée qu'aucun lecteur téléchargeant un binaire ne verra jamais.

Deux sortes de changements méritent leur ligne quelle que soit leur taille, parce que c'est sur elles qu'une chaîne de conformité bâtie sur Pépin est jugée : une surface qu'un pipeline consommateur parse (la forme de l'assessment, des findings, du bundle ou de l'OSCAL, un code de sortie, un verbe ou un flag de la CLI), et un verdict qui peut changer sur un tenant inchangé (une règle durcie ou assouplie, un contrôle activé ou retiré, un mapping normatif retrié). La première casse leur parsing ; le second oblige leur utilisateur à expliquer à un auditeur un changement qu'il n'a pas fait, et c'est ici que cette explication commence. Un refactor qui ne change ni l'une ni l'autre appartient au git log.

[Non publié]

Ajouté

  • Scaleway : la politique d'expiration des clés d'API de l'organisation est lue (issue #196). iam_apiaccesspolicy_max_key_expiration n'était actif que pour Outscale. Scaleway a l'équivalent, au niveau de l'organisation, et l'impose — le tenant de qualification l'a découvert en se faisant refuser : « organization security settings require an expiration date for API keys ». Le refus était la mesure. GET /iam/v1alpha1/organizations/{id}/security-settings porte max_api_key_expiration_duration, vérifié contre l'API réelle le 2026-09-11. Le contrôle est désormais actif pour Scaleway et conclut pass sur une organisation imposant 365 jours. C'est un contrôle préventif : lister les clés dit ce qui est vrai aujourd'hui, ce réglage dit ce que l'organisation refusera demain — la distinction que SecNumCloud 9.5 et CIS 5.4 font tous deux. Une règle, inchangée, sur une donnée normalisée (ADR-0001).

  • Un plan Outscale juge les écouteurs d'un répartiteur de charge (issue #203). loadbalancer_ssl_listeners revenait not-evaluated sur la source Terraform alors que le plan écrivait les écouteurs en clair — protocole et port. Un répartiteur exposé à Internet qui n'écoute qu'en HTTP est désormais un fail, et celui en TCP 443 reste inconcluant avec son sujet, comme en live. La région du type est projetée aussi, et ce n'est pas un détail : le contrôle de souveraineté juge l'intersection sur tous les types, si bien qu'ajouter un type sans région retire de la couverture au lieu d'en ajouter — governance_resource_region_in_eu est passé de pass à not-evaluated au premier essai. access_log n'est délibérément pas mappé : il vit sur une ressource distincte que ce plan ne porte pas, donc loadbalancer_logging_enabled reste honnêtement non évalué.

  • Un plan Outscale juge les règles d'accès à l'API (issue #203). iam_apiaccessrule_no_public_cidr revenait not-evaluated sur la source Terraform alors que le plan portait ip_ranges — argument obligatoire de outscale_api_access_rule quand aucun autre critère n'est donné, donc toujours écrit dans le HCL. Pépin ne savait pas conclure sur un 0.0.0.0/0 écrit noir sur blanc. Mesuré sur le plan réel du tenant de qualification et confirmé par un run --plan-only, GO, sans rien créer.

  • Un plan Outscale sait juger la protection contre la suppression (issue #203). compute_instance_deletion_protection revenait not-evaluated sur la source Terraform alors que le plan portait la réponse : outscale_vm.deletion_protection est un argument optionnel, donc écrit en clair quand son auteur l'a écrit. Le mapping ne le projetait pas. Mesuré sur le plan réel du tenant de qualification — le contrôle rend désormais fail sur la VM délibérément non protégée et reste inconcluant sur celle sans environnement lisible — et confirmé par un run --plan-only, GO, sans rien créer ni facturer.

  • Scaleway live : une clé d'API du propriétaire est désormais trouvée (issue #195). iam_no_root_access_key — sévérité high — ne savait conclure sur aucune source. La donnée était dans la liste de clés que le collecteur interroge déjà : user_id, et application_id pour une identité de service. La dérivation naïve que l'issue esquissait — « une clé d'utilisateur est une clé root » — aurait signalé la clé de chaque membre, un faux positif par personne. Le vrai marqueur est le type de l'utilisateur, littéralement owner, vérifié contre l'API réelle le 2026-09-11 ; account_root_user_id, porté par le même enregistrement, désigne autre chose et diffère de l'id du propriétaire. La règle joint donc le propriétaire de la clé à l'utilisateur de type owner, ce que son contrat disait déjà. Les deux champs d'appartenance sont projetés ensemble : une clé appartient à un utilisateur ou à une application, et ne projeter que le premier ferait taire le contrôle sur les clés d'application, dont l'appartenance est pourtant parfaitement observée. Confirmé sur un compte réel — où il trouve un écart véritable — et par un run de qualification, GO avec destruction prouvée. Format d'inventaire v11, ajout pur.

  • Scaleway live : la politique par défaut d'un groupe de sécurité est collectée (issue #195). network_securitygroup_default_deny — sévérité high — revenait not-evaluated sur la source live alors que la même faute était trouvée sur le plan. La lacune n'était pas une API manquante : le collecteur paginait déjà /instance/v1/zones/{zone}/security_groups pour joindre les règles à leur groupe, et cette réponse portait inbound_default_policy depuis toujours. Personne ne la projetait. Le contrat a été vérifié contre l'API réelle le 2026-09-11 avant d'écrire une ligne, et le contrôle conclut désormais sur les deux sources — confirmé par un run de qualification sur un compte réel, GO avec sa destruction prouvée. Le format d'inventaire passe en v10 : un ajout pur, le nouveau type security_group.

Corrigé

  • Un scan de stockage objet plafonné ne se lit plus comme une panne, ni une clé inconnue comme un droit manquant (issue #96). Classify reconnaît les erreurs des SDK tiers par deux interfaces anonymes, HTTPStatusCode() et ErrorCode(). Il lisait le statut en premier — or une erreur de SDK expose toujours son statut, si bien que la première branche rendait toujours une classe et que la seconde n'était jamais atteinte. Le code d'erreur était du code mort.

    Mesuré en faisant passer le XML d'erreur de la spécification S3 par le vrai client du SDK AWS, ce qu'aucun test n'avait jamais fait — l'émulateur ne sert aucune surface de stockage objet, si bien que la seule réponse que cette branche avait vue était un 404 ne portant aucun code :

    Réponse S3 réelle Classait Devait classer
    403 · InvalidAccessKeyId permission_denied unauthenticated
    403 · SignatureDoesNotMatch permission_denied unauthenticated
    503 · SlowDown unavailable rate_limited

    Le troisième est le plus coûteux en exploitation : un scan que l'API plafonnait simplement annonçait un service indisponible, et l'opérateur attendait une panne qui n'existait pas. Les deux premiers l'envoyaient élargir une politique pour une clé que le fournisseur ne reconnaît pas — et ils le faisaient depuis la release précédente, puisque le correctif qui avait déplacé ces deux codes vers unauthenticated avait atterri dans une branche que rien n'atteignait.

    Le code est désormais lu avant le statut : la règle qu'ADR-0023 pose pour les corps HTTP, appliquée au chemin SDK pour la même raison. Un contrat publié l'emporte sur une convention que chaque fournisseur applique à sa façon. Un code que la table ne connaît pas retombe toujours sur le statut, donc rien de ce qui marchait ne cesse de marcher.

    internal/objectstorage/classify_s3_test.go atteint maintenant chaque classe par une vraie erreur S3, de bout en bout — décodage du SDK, reconnaissance par interface, classement. Ce qu'il n'établit pas est écrit : qu'un fournisseur souverain donné émette tel code dans tel cas reste dû à un scan réel.

    Aucun verdict ne bouge sur un tenant inchangé — chacune de ces classes dégradait déjà un contrôle en not-evaluated. Ce qui change, c'est l'erreur que l'opérateur est envoyé corriger.

  • Scaleway : un identifiant inconnu n'est plus présenté comme un droit manquant (issue #250). Scaleway répond 401 quand il ne reconnaît pas une clé, et un 401 seul se rangeait permission_denied — « privilège insuffisant du compte de scan ». Le relevé de canari portait ce classement sur six de ses sept endpoints Scaleway, alors que le canari n'a aucune clé. La phrase envoyait l'opérateur élargir une politique attachée à une identité que l'API ne connaît pas : le défaut d'#91, transposé d'un fournisseur à l'autre.

    Le contrat est celui du SDK officiel. scw/errors.go démultiplexe le champ type de la réponse vers un type d'erreur Go, et il donne aux deux cas deux types distincts — c'est donc une table compilée, pas de la prose :

    Situation du compte de scan HTTP type Classe
    Clé inconnue, mal formée ou expirée 401 denied_authentication unauthenticated
    Clé valide, droit manquant 403 permissions_denied permission_denied

    La première ligne est mesurée (2026-09-21, GET /iam/v1alpha1/users, clé synthétique, aucun compte engagé) ; la seconde vient du SDK. Les deux sont mappées, y compris celle que son statut classait déjà correctement : ADR-0023 pose que la classe se dérive du code documenté quand il existe, et adosser la moitié d'une paire au contrat et l'autre à une convention ferait dépendre la distinction d'un statut qui peut bouger sans que le contrat change.

    Exoscale est délibérément laissé à son statut. Mesuré le même jour, il répond 403 avec {"message":"Invalid key or request signature"} — ni type, ni code, aucun champ structuré. Le seul discriminant serait le texte du message, ce qu'ADR-0023 refuse : une correspondance de chaînes casse au premier changement de formulation. Son refus reste classé largement et vrai plutôt que finement et fragile. Un contre-exemple garde cette décision.

    Aucun verdict ne bouge sur un tenant inchangé — un refus dégradait un contrôle en not-evaluated avant, et le fait toujours. Ce qui change, c'est l'erreur que l'opérateur est envoyé corriger.

  • Un refus n'est plus présenté comme une panne, et une clé inconnue n'est plus présentée comme un droit manquant (issue #91). Outscale répond 400 là où Scaleway répond 401 et Exoscale 403 ; un 4xx qu'aucune classe plus fine ne réclamait retombait sur unavailable, si bien que le relevé de canari rangeait les dix-huit endpoints Outscale en « service indisponible » — alors que tous avaient répondu. L'opérateur qui lit ça va consulter une page d'état pendant que le plan de contrôle fonctionne parfaitement.

    La distinction que l'issue laissait ouverte — un 400, est-ce une signature invalide ou un droit manquant ? — ne pouvait pas se trancher avec des identifiants synthétiques, qui ne produisent jamais que le premier cas. Elle a été mesurée le 2026-09-12 sur eu-west-2, avec une identité EIM créée pour la mesure ne portant que api:ReadVms, puis détruite ; un appel autorisé a été joué d'abord comme témoin, pour qu'un refus ne puisse pas venir de la signature :

    Situation du compte de scan HTTP Type · Code Classe
    Clé d'accès inexistante 400 InvalidParameterValue · 4120 unauthenticated
    Clé existante, signature invalide 401 AccessDenied · 1 unauthenticated
    Clé valide, droit manquant 403 AccessDenied · 4 permission_denied

    Deux classes s'ajoutent à l'état de collecte — unauthenticated (l'API n'a pas reconnu les identifiants) et rejected (l'API a répondu et refusé la requête) — et un 4xx n'est plus jamais unavailable. Le statut seul ne peut pas porter cette distinction : Outscale documente son erreur d'authentification en 400 et son erreur d'autorisation en 401 (table officielle), si bien qu'un classement par statut se trompe sur les deux, dans les deux sens. C'est le code d'erreur qui tranche, et seuls les codes que cette table documente sont mappés — le 403 · 4 mesuré n'en fait pas partie et reste classé par son statut. InvalidAccessKeyId et SignatureDoesNotMatch sur le chemin du stockage objet passent à unauthenticated pour la même raison : élargir une politique ne rend pas connue une clé qui ne l'est pas. Aucun verdict ne bouge sur un tenant inchangé — un refus dégradait un contrôle en not-evaluated avant, et le fait toujours ; ce qui change, c'est la phrase que lit l'opérateur, et l'erreur qu'elle lui envoie corriger.

    pepin-inventory passe en v14 : le schéma ne bouge pas, mais l'éventail de valeurs que error peut prendre, si — et un consommateur qui les énumère doit le savoir (ADR-0003).

  • La collecte live Exoscale écrit la zone qu'elle a scannée (issue #210). governance_resource_region_in_eu rendait not-evaluated sur toute organisation Exoscale : aucune ressource ne portait de région, donc le contrôle ne pouvait jamais conclure sur la localisation de quoi que ce soit. Le collecteur connaissait pourtant la zone depuis toujours — c'est le --region qu'on lui donne et l'hôte auquel il parle — mais le moteur lisait la variable region là où Exoscale déclare zone (region_key), si bien que chaque ressource sortait avec une région vide. La région suit désormais la clé déclarée, pour tous les fournisseurs. Poser la zone scannée n'affirme rien qu'on n'ait observé : l'API Exoscale est zone-scopée par son hôte, donc un scan de de-fra-1 ne rend que des ressources de de-fra-1 — c'est la zone dont l'API a répondu, pas une valeur déduite. Confirmé par un run de qualification sur un compte réel, GO, le contrôle rendant désormais pass sur les instances, les volumes, les instantanés et les clusters.

  • Le tenant de qualification Scaleway refuse de tourner dans le projet par défaut (issue #240). Il applique des ressources délibérément exposées, et sa preuve de destruction repose sur un delta avant/après pour ce qui ne porte pas d'étiquette — que toute ressource tierce qui bouge pendant le run fausse, dans un sens ou dans l'autre. Chez Scaleway, le projet par défaut porte l'ID de l'Organization, ne peut être ni supprimé ni transféré, et c'est là que tout atterrit quand personne n'a choisi. Mesuré le 2026-09-10 : il portait une VM d'un autre travail, lancée deux heures plus tôt ; le run n'a pu avoir lieu qu'après vidage complet du compte, ce qui n'est pas une procédure. Le crochet identity refuse désormais, en donnant les trois commandes qui corrigent. Le projet est vidé, jamais recréé : un projet réutilisé garde son historique de consommation, ne consomme qu'une fois le plafond de 25 projets, et n'a besoin qu'une fois de son VPC (un projet créé depuis le 13 mai 2025 n'en reçoit plus).

  • La règle absent/vide est désormais une porte, plus une habitude (issue #227, seconde moitié). Une règle gouverne chaque attribut qu'une spec de fournisseur mappe — une clé absente de la source ne projette rien, une clé présente et vide projette la valeur vide — et TestNoSpecFabricatesAnAttributeFromNothing la tient en poussant un item vide à travers chaque ressource de chaque descripteur : un seul attribut qui en sort casse le build. Son contre-exemple compte autant : une source qui dit « aucun » doit toujours le projeter, sans quoi « ne jamais fabriquer » se satisferait en ne projetant jamais rien. Écrite une fois et tenue mécaniquement, plutôt qu'annotée sur 138 attributs que personne ne relit — et elle vaut pour les fournisseurs qui n'existent pas encore. La règle est aussi énoncée là où quelqu'un qui ajoute un fournisseur la rencontrera, dans les deux langues.

  • Une ressource qui n'a rien ne fait plus taire le contrôle qui la cherche (issue #227). Le tenant de qualification Exoscale contient, délibérément, une instance sans aucun groupe de sécurité — l'écart même que compute_instance_has_security_group existe pour attraper. Le scan rendait not-evaluated : l'inventaire scellé portait bien security_group_ids: [], mais une liste vide observée comptait comme non collectée, et l'intersection par type retirait alors l'attribut de toutes les instances. La ressource fautive aveuglait son propre contrôle, et ses voisines avec elle.

    Le correctif n'est pas d'apprendre au verrou de capacité à tolérer les vides : cela aurait rouvert l'incident fondateur de l'ADR-0006, puisque IAMPolicyStatements rendait [] aussi bien pour « ce document n'a pas pu être analysé » que pour « cette politique n'accorde rien ». Le bouchon est retiré à la source : le parseur ne rend plus rien quand il n'a pas su lire, les collecteurs omettent alors l'attribut au lieu d'en poser un vide, et c'est seulement là que le verrou répond sur la présence. Une liste vide observée est une énumération complète qui compte zéro ; nil n'établit toujours rien.

    Mesuré : un verdict bouge — compute_instance_has_security_group en live Exoscale, de not-evaluated à pass, et ce pass est prouvé : les quatre instances portent leur attribut, et la seule vide est privée (#212). Confirmé de bout en bout par deux runs de qualification réels, Exoscale et Outscale, tous deux GO.

  • La feuille de route annonçait une version que le projet avait dépassée. ROADMAP.fr.md disait encore « Où en est Pépin, v0.2.0 » deux releases plus tard. Pour la plupart des projets ce serait un détail ; pour celui-ci, dont tout le propos est qu'aucune affirmation ne vaut mieux que ce qui est mesuré, une page qui se trompe sur sa propre version décrédibilise le reste. Le titre est désormais dérivé du CHANGELOG — la même source que TestTheInstallPagePinsTheLatestRelease, parce que deux sources de vérité pour une même question finissent par diverger — et une version illisible laisse le titre sans numéro plutôt que d'en inventer un (ADR-0014).

[0.4.0] - 2026-09-10

Corrigé

  • La porte de release bloquait un tag sur l'absence d'un artefact que ce tag allait créer. L'étape 2 exécute les commandes de conteneur extraites de docs/install.md, et cette page épingle la version qu'on relâcheTestTheInstallPagePinsTheLatestRelease l'exige, à raison : une page d'installation est la première qu'on recopie, elle ne doit pas proposer une version que le dépôt ne publie plus. Mais cette image n'existe pas sur ghcr.io pendant que la porte tourne, puisque c'est le tag qui la crée. Trouvé au premier usage réel de la porte, en coupant la v0.4.0. L'étape 2 vérifie désormais le dernier tag publié — ce qu'elle mesure vraiment, à savoir que la procédure documentée fonctionne contre une release réelle — et écrit la substitution dans sa preuve : une commande documentée réécrite en silence ne prouve plus ce que la page dit.

Ajouté

  • mise run release-gate -- vX.Y.Z — un verdict avant un tag, GO ou NO-GO, et son rapport (issue #178, étapes 1 et 4). release-check et le canari faisaient chacun ce qu'ils annonçaient ; ce que le dépôt ne savait pas produire, c'est un seul verdict, lu depuis ce qu'un utilisateur vit vraiment. Un audit externe d'une journée a trouvé à la main cinq défauts qu'aucune porte ne regardait, et trois d'entre eux étaient des affirmations de la documentation. Au-delà de lancer d'un bloc ce qui existait déjà — préflight, audit, adr-drift, falsify:all, une campagne de fuzzing bornée, les blocs générés —, l'étape 1 mesure donc ces affirmations : aucune version de Pépin épinglée dans docs/install*.md ou examples/ n'est antérieure au minimum que references/release/pinning.yaml déclare sûr (et les deux pages d'installation le citent) ; tout lien relatif de la documentation résout, ancre comprise ; et la phrase d'accueil du binaire nomme exactement les fournisseurs enregistrés, mesurée en le lançant. L'étape 4 ajoute la règle que l'en-tête du CHANGELOG promettait déjà : un expected.yaml qui a bougé depuis le tag précédent oblige un CHANGELOG qui a bougé aussi — un verdict qui change sur un tenant inchangé est ce que son lecteur devra expliquer à un auditeur. Chaque étape écrit release-gate/stageN.json et le run finit sur release-gate/REPORT.md, destiné à être joint à la release. Trois règles non négociables : rien ne dit GO tant qu'une étape est rouge ; une étape sautée porte un motif écrit qui apparaît dans le rapport, et un --skip muet est refusé ; et la porte doit savoir rougir — mise run gate:selftest casse chacune de ces règles et exige un refus, et tourne dans prepush.

  • Porte de release, étape 2 — les artefacts tels qu'un utilisateur les reçoit (issue #178). La CI prouve que les mécanismes fonctionnent, sur des artefacts qu'elle construit elle-même et sert sur une boucle locale. Elle ne dit rien de la chaîne publiée — la signature qui vit chez Sigstore, l'attestation chez GitHub, l'image sur ghcr.io —, laquelle peut cesser de se vérifier sans qu'une ligne du dépôt ait bougé. L'étape 2 reconstruit les binaires avec la ligne de build du workflow (ils doivent porter leur tag et garder leurs codes de sortie), puis, sur les assets réels du tag précédent : le bloc « Verify what you downloaded » du README, le cosign verify et le docker run de docs/install.md, l'installeur de l'action — qui doit accepter le binaire publié et refuser le même altéré d'un octet — et le before_script du template GitLab dans l'alpine:3.21 qu'il déclare. Ces commandes sont extraites de la documentation, jamais recopiées dans la porte : recopier prouverait que la copie marche, extraire prouve que la page marche, ce qui est l'invariant de l'ADR-0016. Une page réorganisée fait rougir la porte au lieu de passer en silence. Chaque contrôle se saute avec un motif écrit quand son outil manque — et une étape dont tout ce qui mesure a été sauté est déclarée SAUTÉE, jamais GO : un vert qui n'a rien mesuré est le défaut que ce produit reproche aux autres.

    La porte écrit aussi release-gate/SUMMARY.md : le verdict seul — nom des contrôles, leur verdict, la date, le commit —, destiné au corps de la GitHub Release. Le rapport détaillé, lui, ne se publie délibérément pas : il porte les chemins du mainteneur, les sorties brutes des outils et, à l'étape 3, les identifiants de ressources d'un compte réel — et il ne pourrait de toute façon pas entrer dans checksums.txt, puisqu'il est produit localement avant le tag quand les sommes sont engendrées en CI (ADR-0016). Le corps d'une release n'est pas un artefact — c'est le même registre que les notes qu'il prolonge, une affirmation humaine, non signée —, donc le verdict n'y ajoute aucune promesse de confiance nouvelle. Et la porte prouve que ce résumé est publiable : elle refuse de le déclarer tel s'il nomme un chemin de cette machine, le dit à l'écran, et coiffe le fichier d'un NON PUBLIABLE.

  • Tenants de qualification Outscale et Exoscale (issue #198). Outscale : 79 ressources Terraform, plus 6 buckets OOS et une politique EIM inline créés par le crochet extra du tenant, appliqués et détruits sur un compte réel — la machine à deux cartes, la clé root, la famille iam_policy_*, les snapshots, une OMI publique, des load balancers ; le runner applique désormais pre_destroy_vars avant de détruire (une VM protégée ne se détruit pas, provider #88) et reliste jusqu'à ce que les suppressions soient effectives. Exoscale : plan seul, 37 ressources, sans compte — la source Terraform est épinglée, la moitié live attend un compte.

  • Tenant de qualification Exoscale, moitié live (issue #198) : 40 ressources dans le plan complet, 39 appliquées (le quota de l'organisation est de quatre instances ; l'instance suisse n'est que sur le plan, par terraform_only_resources), plus 3 buckets SOS créés par le crochet extra (dont un avec Object Lock, par l'API S3), appliqués, scannés en --live dans les cinq formats, scellés, détruits et prouvés détruits sur deux zones (15 familles d'API + buckets, delta avant/après). L'organisation attendue vient de PEPIN_QUAL_EXO_ORG, confirmée par GET /api-key/{key}org-id avant tout apply. Ce que la passe live a trouvé, épinglé tel que mesuré et consigné : SOS accepte PutBucketTagging et ne persiste rien (#208, tout bucket SOS échoue governance_resource_required_tags) ; kubernetes_cluster_audit_logging_enabled passe en live sur un cluster dont l'audit est désactivé (#209, faux vert : l'API rend audit: {} et audit_enabled est dérivé de audit.endpoint) ; region n'est projetée sur aucune ressource live (#210, governance_resource_region_in_eu not-evaluated) ; les labels des instances ne sont pas collectés en live (#211, une instance sans étiquette passe) ; une instance privée n'a pas de groupe de sécurité par construction (#212, compute_instance_has_security_group parle avec une remédiation inexécutable) ; chaque cluster SKS crée un rôle IAM sks-ccm-* qui échoue deux contrôles iam_role_* (#213). Les deux changements de règle de #206 sont épinglés : …_all_ports est not-applicable chez Exoscale, network_securitygroup_unrestricted_egress échoue sur tcp 1-65535.

  • Un tenant de qualification, appliqué et détruit sur un compte réel (issue #178, étape 3). PEPIN_GATE_LIVE=1 mise run qualify applique la stack Terraform committée sous references/qualification/scaleway/ — 40 ressources dans le plan, dont 29 appliquées : les cinq types qu'un scan live collecte ; bases managées, réseaux privés et politiques IAM n'existent que sur le plan, parce qu'aucun scan live ne les verrait —, une faute par ressource, un contre-exemple par contrôle, la scanne en --live dans tous les formats, scelle et vérifie le bundle, scanne le même plan en --terraform, la détruit, prouve la destruction (listing par famille sur le tag du tenant, plus un delta avant/après du compte), et compare les deux assessments à l'expected.yaml committé (contrôle × source × sujet → statut). Le runner refuse de démarrer sur un autre compte que celui qui y est épinglé, et casse une de ses propres attentes à la fin de chaque run pour prouver qu'il sait dire NO-GO. Un geste de mainteneur, avec les seuls identifiants natifs, jamais de CI.

Sécurité

  • Un bundle de preuve partagé ne nomme plus une personne. iam_user.username est mappé depuis email chez Scaleway comme chez Exoscale : le sujet d'un finding MFA était donc une adresse e-mail, et elle voyageait telle quelle dans assessment.json, l'OSCAL, le SARIF et l'input.json scellé. Un bundle est fait pour être remis à un tiers : l'outil y mettait une donnée personnelle sans que personne ne l'ait décidé. --redact, qui est déjà l'interrupteur « pour un tiers », y substitue désormais l'identifiant stable de l'utilisateur — dans l'inventaire, dans l'assessment scellé, et dans la ligne de preuve qui les cite. L'adresse n'est pas supprimée — un exploitant qui corrige un MFA doit savoir qui — et le rapport local ne bouge pas ; seul ce qui quitte le périmètre change. Quel attribut nomme une personne est déclaré au descripteur du fournisseur avec sa source, jamais une liste en dur dans la CLI.

  • Un collecteur ne relit plus une requête dans laquelle il vient d'écrire un secret. CodeQL signalait, en high, une clé secrète atteignant un rapport publié : un collecteur posait son identifiant en en-tête, puis rebâtissait la signature de l'appel depuis ce même objet requête pour l'enregistrer en provenance. L'alerte est un faux positif — seuls la méthode, le schéma, l'hôte et le chemin sont lus, jamais un en-tête ni la requête —, mais l'analyse a raison sur le principe, et une chaîne bâtie avant que l'identifiant ne soit posé ne peut pas devenir fausse un jour. La signature est désormais calculée avant l'authentification, dans un helper partagé par les trois collecteurs qui l'enregistrent. La provenance ne nomme toujours aucun appel qui n'a pas eu lieu : la chaîne est calculée tôt et n'est utilisée qu'après une réponse.

  • verify n'accepte plus un bundle qui se contredit. Réécrire quatre résultats fail en pass puis recalculer l'empreinte du fichier touché produisait un bundle déclaré « cohérent en interne », code 0 — alors que son propre manifest.json annonçait encore "fail": 4. Le bundle portait déjà l'information qui le contredisait, et rien ne comparait les deux. Trois recoupements le font désormais, aucun n'exigeant de clé : le résumé du manifeste est confronté aux statuts réellement présents dans assessment.json, la taille déclarée de chaque artefact est vérifiée à côté de son empreinte, et checksums.txt est lu strictement — une ligne illisible ou dupliquée est un refus, là où un octet ajouté passait inaperçu.

  • verify --require-signature échoue si aucune signature n'a été vérifiée. Un appelant qui script verify recevait 0 pour un bundle que l'outil qualifie lui-même de non opposable : l'avertissement était sur stdout, le code disait « réussi », et l'automatisation lit le code. Opt-in, donc aucune chaîne existante ne change. Surface CLI v5 → v6.

    Ce que cela ne fait pas, et la limite mérite d'être dite : fermer la chaîne d'empreintes. Rien dans un bundle ne peut ancrer checksums.txt, puisque qui réécrit un fichier réécrit aussi l'ancre. Seule la signature détachée cosign le peut. Ces recoupements attrapent la corruption et la contradiction interne, pas un falsificateur déterminé.

Sécurité

  • Une politique tierce pouvait exfiltrer l'inventaire audité, en silence. Les règles chargées à chaud via --policy-dir sont du code tiers, exécuté sur tout ce que le scan a collecté. Le moteur avait retiré http.send, net.lookup_ip_addr et opa.runtime et annonçait le réseau hors de portée ; c'était faux. OPA résout le $ref distant d'un JSON-Schema par HTTP au moment de l'évaluation, sur un chemin qu'aucun builtin ne garde, et une ligne suffisait : json.match_schema(input, {"$ref": sprintf("%s/leak/%s", [attaquant, input.secret])}). Mesuré contre le scankit v0.2.2 épinglé, avec un serveur témoin : la requête partait, et Evaluate ne rendait aucune erreur — le silence était la partie qui comptait. scankit est désormais épinglé en v0.3.1, dont le jeu de capacités porte AllowNet: []string{} (vide et non nul refuse tout hôte), et TestNoThirdPartyPolicyCanReachTheNetwork tend un témoin contre lui pour qu'une régression d'épinglage ne rouvre pas la brèche en silence.

Corrigé

  • Une identité que la plateforme crée, utilise et supprime n'apporte plus deux écarts irréparables par cluster. Le SKS d'Exoscale crée un rôle sks-ccm-<cluster> pour le cloud controller manager du cluster et le supprime avec lui. Il est editable: true, donc le filtre des rôles prédéfinis ne le couvrait pas — et chaque cluster apportait deux findings IAM que personne ne pouvait faire disparaître : borner le rôle à une IP source exigerait de deviner les adresses du plan de contrôle, et ajouter une clause de durée casserait le composant. Dix clusters valaient vingt findings irréparables. Le fait est désormais collecté (provider_managed) et déclaré au descripteur du fournisseur avec sa source, jamais codé en préfixe de nom dans une règle — une règle commune ne connaît aucune convention de nommage, et un préfixe répété dans chacune divergerait au premier changement. Un helper partagé le lit, et le même rôle sans la marque continue de produire les trois findings.

  • Un stockage objet qui accepte les étiquettes et n'en garde aucune ne produit plus un écart sur chaque bucket. Le SOS d'Exoscale répond 200 à PutBucketTagging et ne persiste rien : le GetBucketTagging qui suit rend aussitôt NoSuchTagSet — mesuré à l'AWS CLI puis en SigV4 écrit à la main. Cette même réponse veut dire « aucune étiquette » chez un fournisseur qui les conserve, et « cette API ne les garde pas » ici : projeter [] affirmait donc un choix que l'exploitant n'a pas fait et ne peut pas faire. Le descripteur le déclare désormais (s3.tags_persisted), l'attribut n'est pas projeté, le verrou de capacité rend « non évalué » — ce qui est la vérité — et le contrôle d'étiquetage se tait au lieu de signaler chaque bucket de l'organisation avec une remédiation qui n'aboutit jamais. tags reste une capacité du collecteur, donc la couverture continue de l'annoncer là où elle existe.

  • Une instance privée n'obtient plus un écart critical qu'elle ne peut pas corriger. Là où les groupes de sécurité d'un fournisseur filtrent l'interface publique, une instance qui n'en a pas se voit attacher security-groups: [] par construction — l'API le fait, l'exploitant ne l'a pas choisi, et aucune remédiation n'y change rien. Dix instances privées valaient dix findings critical impossibles à faire disparaître, ce qui est le plus court chemin vers un outil qu'on ignore. Le fait est désormais observé (public_interface, dérivé du public-ip-assignment d'Exoscale), et non déduit d'un public_ip absent — qui ne distinguerait pas « privée » de « non collectée ». Là où un fournisseur ne publie pas ce champ, rien ne change ; et une instance avec interface publique et sans groupe reste un écart critical.

  • Deux pass de plus que rien n'établissait, de la même forme que celui de la règle sans description. Une garde posait par ressource une question qui est par fournisseur, et le verrou de capacité — voyant l'attribut collecté sur le type grâce à un voisin — laissait l'assessment conclure faute de finding. kubernetes_cluster_audit_logging_enabled prenait un audit_enabled absent pour vrai, alors que l'API SKS omet l'objet audit quand l'audit est coupé : un cluster sans audit n'avait pas l'attribut, la règle supposait « activé », et le rapport concluait pass sur le cas même qu'elle existe pour attraper. governance_resource_required_tags sautait en silence toute ressource dont le type ne porte pas tags — mesuré sur un tenant Exoscale où les buckets SOS en portent quand les instances, volumes et clusters n'en portent pas. Les deux questions se posent désormais à l'échelle de l'inventaire, et celle des étiquettes par type : un bucket qui porte des étiquettes ne prouve rien d'une instance, ce sont deux API et deux collectes. Le contre-exemple que chaque garde protégeait tient toujours, avec son propre test : un fournisseur qui n'expose la capacité nulle part ne déclenche rien.

  • Une même base obtenait deux sujets sur un plan Terraform. L'écart dérivé de l'ACL nommait la ressource ACL, ceux dérivés de l'instance nommaient la base : trois écarts, deux noms — et une dérogation écrite sur l'un ratait l'autre, en silence. Il fallait deux choses. Un mapping qui lit son porteur par _parent.<champ> voit désormais ce champ comblé depuis la référence déclarée, comme n'importe quel autre argument (l'ADR-0022 ne comblait que les chemins simples). Et l'adresse ainsi obtenue est ensuite résolue vers l'identité que la ressource visée porte dans l'inventaire — une base qui a un name est nommée par lui, pas par son adresse Terraform. La réécriture est bornée aux attributs réellement comblés depuis une référence : une valeur qui ressemble seulement à une adresse n'est jamais touchée. La même correction fait lire backups-prod au sujet d'un bucket Scaleway au lieu de scaleway_object_bucket.backups, ce qu'un lecteur cherche effectivement.

  • Un contrôle émettait six écarts justes chez un fournisseur pour lequel le référentiel ne le déclarait pas. objectstorage_bucket_default_encryption se déclenche chez Scaleway — le collecteur S3 commun pose default_encryption_enabled sur chaque bucket, et le SSE y est opt-in par bucket, donc un bucket sans configuration écrit ses objets en clair. Les verdicts étaient justes ; c'est la déclaration qui était fausse, et la matrice affichait ✗ pour un fournisseur que l'outil mesurait réellement. Déclaré désormais, l'attribut consigné au contrat du fournisseur avec sa source.

  • Deux contrôles déclarés ✅ chez Exoscale ne pouvaient jamais se déclencher, et les deux cas ne méritaient pas la même réponse. Tous deux exigeaient protocol == "all", qu'une règle de security group Exoscale ne sait pas exprimer (le schéma du provider et l'API v2 n'acceptent que ah, esp, gre, icmp, icmpv6, ipip, tcp, udp). …_to_all_ports y est désormais non applicable, avec la justification sourcée : le mécanisme any/any n'existe pas, et les contrôles de familles de ports couvrent le cas réel. Mais unrestricted_egress a été élargi au lieu d'être écarté — une sortie ouverte EXISTE chez Exoscale, elle s'écrit tcp 1-65535 → 0.0.0.0/0, et la déclarer non mesurable aurait caché un fait de posture réel. La borne est stricte : une sortie limitée à quelques ports est un filtrage, et crier dessus est le faux positif qui fait désactiver un outil.

  • Un plan Terraform localise par la zone, et la région était lue brute. Scaleway écrit fr-par-1 sur un serveur, Outscale eu-west-2a sur une VM ; le mapper prenait la valeur telle quelle, posait donc un nom de zone en guise de région — que nul catalogue ne connaît — et governance_resource_region_in_eu rendait « non évalué » sur tout plan, alors que l'exploitant y avait écrit la localisation en clair. Un transform region_of_zone la dérive désormais, déclaré par mapping. Les schémas de nommage sont ceux publiés, et une garde confronte chaque région dérivée au catalogue de régions du fournisseur : un fournisseur qui changerait de convention la fait rougir plutôt que de laisser poser en silence une région inventée dans un rapport de souveraineté. Ce qui ne se dérive pas ne rend rien, et le verrou de capacité dit « non évalué » — une région fausse y serait pire que son absence.

  • Une règle de security group sans description obtenait pass — du contrôle qui existe pour attraper exactement ça. La règle se gardait par "description" in object.keys(...), censé répondre « ce fournisseur expose-t-il le champ », mais posé par ressource. Sur un plan Terraform, un exploitant qui n'écrit aucune description ne produit tout simplement pas le champ : la règle se taisait — et le silence devenait un pass, le verrou de capacité voyant description collectée sur le type (une autre règle la portait) et laissant l'assessment conclure faute de finding. Un pass que rien n'établissait, sur l'écart même que le contrôle vise. La question se pose désormais à l'échelle de l'inventaire : si une règle porte la clé, le fournisseur expose le champ, et une règle qui n'en a pas n'est pas documentée. Aucune règle ne lit la provenance — l'ADR-0017 l'interdit — et le contre-exemple que la garde d'origine protégeait tient toujours : un fournisseur qui n'expose ce champ nulle part ne déclenche rien.

  • Un sujet fautif par plusieurs voies ne se lit plus comme plusieurs écarts. Trois blocs deny d'objectstorage_bucket_public_access — ACL prédéfinie, grant, politique de bucket — concluent sur le même bucket, et chaque cause est réelle. Mais un bucket public est UN problème, et l'imprimer trois fois faisait paraître le rapport plus gros que ce qu'il mesure. Pire, cela occupait les trois places du panneau « action immédiate », qui annonce les trois écarts les plus graves et en livrait trois fois le même. Corrigé en amont dans scankit 0.3.5 : une ligne par sujet avec ses causes en dessous, et le panneau déduplique par (code, sujet) avant de classer. L'agrégation ne porte que sur l'affichage — le décompte du bloc, les formats analysables et le tableau de sévérité continuent de porter chaque cause séparément.

  • Un document qui n'a pas la forme d'un inventaire est refusé au lieu d'être scanné comme un inventaire vide. Une sortie terraform show -json passée sans --terraform, ou un objet vide, étaient acceptés et évalués comme un inventaire vide : code 3, « rien de mesuré ». Honnête sur ce qui a été mesuré, faux sur la cause — l'appelant n'a pas un périmètre vide, il a donné le mauvais fichier, ce qui est le code 2. Un plan est nommé comme tel (« ce fichier est un plan Terraform : le scanner avec --terraform »), et l'échange inverse était déjà refusé : les deux sens marchent désormais du même pas. Les tests du dépôt s'appuyaient sur le défaut : deux d'entre eux passaient un plan en position d'inventaire.

  • La matrice de couverture mesure ce qu'un plan porte au lieu de déclarer ce que le mapping nomme. compute_instance_public_ip_with_open_securitygroup affichait ✅ pour outscale/terraform alors que public_ip est calculé — Terraform ne le connaît qu'après apply, donc aucun plan réel ne le porte — et les scénarios de véracité confirmaient la cellule sur un plan écrit à la main où l'attribut est un littéral. La couverture est désormais corrigée par ce que les plans des tenants de référence, générés depuis du HCL tiers, produisent réellement. Trois cellules passent de ✅ à ◐, et le motif distingue « le mapping ne le nomme pas » (qui se corrige dans la spec) de « le mapping le nomme mais aucun plan ne le porte » (qui ne s'y corrige pas du tout). Six obligations qui ne pouvaient jamais être tenues quittent le registre de véracité avec elles.

  • Le rapport terminal imprimait le titre et la remédiation d'un contrôle au-dessus des findings d'un AUTRE. Un bloc imprime un code, un titre et une remédiation puis liste des findings dessous : il affirme donc ces trois choses de chacun d'eux — or le regroupement se faisait sur le seul code SCSL, et une exigence couvre souvent plusieurs contrôles. Le titre et la remédiation étaient alors ceux du premier finding. Mesuré sur un tenant réel, la ligne sur laquelle un lecteur agit lui disait de révoquer une clé root pour corriger une autorisation Resource="*". Corrigé en amont dans scankit 0.3.4 : la clé de regroupement est désormais exactement ce que le bloc affirme, donc CLD-IAM-1 rend un bloc par contrôle et le tableau des contrôles cesse d'additionner sévérité et décomptes sur une ligne qui recouvrait deux contrôles. Un contrôle unique sous un code rend à l'identique.

  • Une VM publique par sa carte secondaire, SSH ouvert sur cette carte, ne produisait aucun finding. Le collecteur projetait l'union des IP publiques des cartes, donc la machine comptait pour publique — mais il la confrontait à Vm.SecurityGroups, que l'OAPI documente comme les groupes de la carte primaire. Mesuré sur un tenant réel : SSH répondait sur l'adresse publique et le rapport ne disait rien. L'exposition est une propriété de la carte : une ressource network_interface est désormais collectée par NIC (nic_id, vm_id, public_ip, security_group_ids) et la règle apparie une adresse avec les groupes de la même carte. L'aplatissement est fautif dans les deux sens, et c'est pourquoi le correctif n'est pas une union plus large : une carte publique au groupe fermé plus une carte privée au groupe ouvert se liraient, une fois réunies, comme « publique et ouverte » — un écart que la machine ne porte pas. Là où les cartes ne sont pas collectées (un plan Terraform), la machine reste jugée sur ses propres attributs : un repli muet aurait fait DISPARAÎTRE des écarts.

  • SecNumCloud était déclaré par fournisseur, alors qu'une qualification couvre un périmètre de régions. Celle d'Outscale couvre cloudgouv-eu-west-1 et elle seule ; un tenant en eu-west-2 lisait « SecNumCloud qualifié » dans un pass de souveraineté, ce qui est la première affirmation qu'un auditeur conteste. Le descripteur déclare désormais le périmètre (secnumcloud_regions, sourcé), et l'attribut secnumcloud est rendu pour la région réellement scannée : qualifie dans le périmètre, hors_perimetre en dehors, perimetre_inconnu quand le scan n'a pas de région — un scan qui ne sait pas où il porte ne tranche pas. La qualification ne vaut donc plus immunité extraterritoriale hors de son périmètre, et la page du fournisseur ne peut plus imprimer le statut sans lui. Aucun écart nouveau n'est émis, aucun code de sortie ne bouge.

  • La page d'installation épinglait v0.1.0, la version dont ce dépôt écrit lui-même qu'elle refuse toute installation. examples/github-actions/pepin.yml le dit en toutes lettres : en v0.1.0 et v0.1.1, l'installeur de l'action appelait gh attestation verify sans jeton, ce qui refusait toute installation. La page d'installation est la première qu'on recopie. Chaque épinglage nomme désormais la dernière release, une phrase dit que l'appelant ne passe aucun jeton depuis v0.2.0, et une garde exige que les versions épinglées soient celle que le CHANGELOG déclare — lue dans le dépôt plutôt que dans les tags git, pour qu'un clone de CI sans tags ne puisse pas la faire dériver.

  • Quatre messages de la CLI désignaient la mauvaise chose. --policy-dir /inexistant et provider validate <fichier> rapportaient tous deux . — la racine de la vue de système de fichiers qu'ils venaient de construire — au lieu de l'argument reçu ; un fichier passé là où un dossier est attendu est maintenant refusé comme tel. --kubeconfig sans --live proposait deux sources qui ne sont pas celle que l'appelant venait de nommer ; l'erreur et l'aide du drapeau disent désormais qu'il exige --live.

  • Une --region inconnue est signalée avant que la collecte n'échoue. --region eu-nowhere-9 coûtait une minute de résolutions DNS en échec et dix-sept unités « service indisponible » à lire avant d'en deviner la cause. C'est un avertissement, pas un refus : la liste des régions appartient au fournisseur, et une région ajoutée demain doit rester scannable le jour même — ce qui la sépare d'un --gate inconnu, dont le vocabulaire est celui de Pépin. Le catalogue vit auprès du descripteur du fournisseur, et une garde exige qu'il coïncide avec celui que les règles de souveraineté portent déjà.

  • control explain accepte le code que le rapport imprime. La colonne « Code » du rapport terminal et chaque en-tête de bloc portent l'exigence SCSL (CLD-STO-1), et --format json la porte dans code ; la commande n'acceptait que l'identifiant de check, que le rapport n'imprime jamais. Un lecteur qui venait de lire un verdict devait deviner. Les deux formes fonctionnent désormais, sans distinction de casse, et une exigence couvrant plusieurs contrôles les nomme tous plutôt que d'en choisir un en silence.

  • Une clé d'accès dont l'expiration est dépassée ne compte plus comme conforme. La règle ne refusait qu'une date ABSENTE, si bien qu'une clé encore ACTIVE deux ans après son échéance passait — mesuré sur un tenant réel. Les deux lectures de cet état sont mauvaises, et c'est pourquoi le pass était faux : soit le fournisseur honore encore la clé et l'expiration ne protège rien, soit il ne l'honore plus et une clé morte reste déclarée active. Le scan n'a pas à trancher laquelle pour savoir que « conforme » est faux. L'instant de référence est celui de l'ÉVALUATION, pas l'horloge : le rejeu d'un bundle scellé rend donc le même verdict.

  • Un scan qui n'a rien mesuré ne se rend plus comme conforme. Une coche verte, « Aucun écart sur le périmètre audité » et quatre compteurs à zéro s'imprimaient immédiatement au-dessus d'un verdict INDÉTERMINÉ. Trois signaux littéralement vrais et collectivement trompeurs : « aucun écart trouvé » et « rien n'a été regardé » se rendaient à l'identique. Le code de sortie était déjà 3, donc l'automatisation se comportait correctement — le mode d'échec était la personne qui survole un terminal, ou la capture collée dans un ticket. Le marqueur est désormais neutre, la ligne nomme la cause, et les compteurs sont tus. Un scan réellement conforme garde les trois, et le test le vérifie aussi : rendre les deux cas identiques n'aurait fait que déplacer la confusion.

  • Un groupe de sécurité ouvert à tout Internet par des plages /2 ne produisait aucun finding. is_public_cidr ne jugeait publique qu'une plage de préfixe ≤ 1, si bien que les quatre plages 0.0.0.0/2, 64.0.0.0/2, 128.0.0.0/2, 192.0.0.0/2 — qui couvrent ensemble tout l'IPv4 — passaient inaperçues, pendant que 0.0.0.0/1 du même groupe était attrapé. Mesuré sur un tenant Outscale réel : RDP ouvert à tout Internet, et le rapport muet. Un /3, ou une liste de /8, passaient de même. Une source est désormais non restreinte quand elle est large (préfixe ≤ 8) et non privée — la seconde condition est ce qui garde 10.0.0.0/8 sur le port 22 silencieux, et le réseau d'un partenaire comme 203.0.113.0/24 n'est délibérément pas signalé : « ouvert à Internet » et « ouvert à quelqu'un d'autre » sont deux affirmations différentes.

    L'union est fermée elle aussi : net.cidr_merge fusionne les plages d'une règle avant de les juger, si bien que 512 /9 couvrant l'espace deviennent 0.0.0.0/0 et sont signalées — mesuré. Les entrées brutes restent éprouvées à côté, parce qu'un littéral sans masque (0.0.0.0, *) n'est pas un CIDR valide et que la fusion le perdrait ; et seules les entrées valides sont fusionnées, parce que net.cidr_merge devient indéfini sur une entrée malformée, ce qui rendrait la règle muette sur une donnée de tiers.

    La fusion ne rapproche que le CONTIGU, si bien qu'un damier lui échappait : 256 /9 une sur deux — la moitié d'Internet — donnaient 256 blocs inchangés et restaient muettes. Les adresses publiques qu'une liste de sources ouvre sont désormais comptées, exactement : les blocs fusionnés sont disjoints, et deux CIDR sont soit disjoints soit emboîtés, donc la taille publique d'un bloc est la sienne moins celle des espaces à usage spécial qu'il contient. Un seul finding par ressource nomme l'union — 0.0.0.0/0, ou 256 CIDR → 1848508416 IPv4 — au lieu d'un par plage.

  • La description racine nomme les fournisseurs qui existent. La première phrase qu'un nouvel utilisateur lit annonçait OVH — une entrée de feuille de route, pas un fournisseur — et omettait Kubernetes, qui en est un. La liste est désormais dérivée du registre : une liste recopiée se périme au premier fournisseur ajouté ou retiré, et personne ne relit une phrase d'accueil.

  • pepin scsl --index ne pointe plus par défaut vers la disposition locale d'un mainteneur. Le défaut était un chemin relatif remontant hors du répertoire courant vers un dépôt dont le lecteur n'a jamais entendu parler. L'erreur était juste et ne disait rien : impossible de savoir si framework-scsl était à installer, un sous-module oublié, ou un projet interne. --index est désormais requis, et le message dit ce qu'est le fichier, que la commande est un outil de maintenance dont un scan n'a pas besoin, et donne un exemple.

  • Une valeur inconnue de --format est refusée au lieu de retomber sur la table. Le scan tournait et imprimait le rapport table, avec le code de sortie d'un scan réussi. Le cas dangereux n'est pas l'humain qui tape xml à un prompt et le remarque : c'est l'étape de pipeline écrite --format oscal éditée en --format oscal2, qui publie un tableau colorié là où toute la chaîne en aval croit recevoir de l'OSCAL. Un format inconnu appartient à la famille de l'export illisible et du fournisseur inconnu — une erreur d'invocation, code 2, pas un résultat de scan.

  • Le mode français ne laisse plus l'ossature du rapport en anglais. Titres de section, en-têtes de table et ligne « aucun écart » sortaient en anglais au milieu d'un rapport français — Total deviations: 1 au-dessus d'un finding français, se refermant sur un verdict français. Les chaînes du framework aussi : gabarit d'usage, help for <cmd>, erreurs de nombre d'arguments, drapeau inconnu. Un rapport à moitié traduit se lit comme un travail inachevé plutôt que comme un choix, et le lecteur visé — un auditeur francophone d'un cloud souverain — est précisément celui qui le remarque.

    Une chaîne reste en anglais : le unknown command … Did you mean this? de cobra, construit au fond de Command.Find sans point d'accroche. La traduire exigerait de reconnaître son texte anglais, donc de faire dépendre le comportement de la formulation d'une dépendance. TestTheUntranslatedCobraResidueIsKnown tient ce résidu inventorié et échoue dans les deux sens : il ne peut ni grandir, ni être oublié une fois corrigé en amont.

  • Une sous-commande inconnue ne rend plus 0, et n'exécute plus autre chose. pepin provider inexistant lançait silencieusement provider list ; pepin control list — la supposition naturelle, symétrique de provider list — rendait un écran d'aide et réussissait. Une étape de pipeline écrite pepin control list --json > controls.json écrivait donc l'aide dans le fichier et passait au vert. Chaque commande valide désormais ses arguments et rend 2 sur un argument qu'elle ne comprend pas. Une garde parcourt l'ARBRE des commandes plutôt qu'une liste écrite à la main, et elle a trouvé un cinquième cas que le rapport n'avait pas : provider list ignorait tout argument.

  • Un inventaire dont l'origine contredit le jeu de règles demandé est désormais refusé. Scanner un inventaire Scaleway avec les règles Exoscale était accepté sans un mot et produisait six verdicts pass. Ces pass n'étaient pas faux par accident : ils étaient vides de sens, les formes de ressources se recouvrant juste assez pour que des règles s'évaluent et concluent. Le mode d'échec était silencieux et réaliste — une faute de frappe dans un pipeline, un job copié-collé — et le rapport avait l'air parfaitement normal, avec un code de sortie non nul qui suggérait même que le scan avait travaillé. La déclaration est lue à la racine de l'export et sur ses ressources, et une discordance sort en 2, le code déjà employé pour un export illisible. Un inventaire qui ne déclare rien n'est pas refusé : une origine absente ne s'invente pas.

  • evidence.proves ne voyage plus en ["","",""] sur chaque résultat. omitempty sur un tableau de taille fixe est sans effet, si bien qu'un lecteur ne pouvait pas distinguer « aucune preuve enregistrée » de « trois preuves enregistrées, toutes vides » — y compris dans un bundle scellé archivé pour plus tard. Corrigé en amont dans scankit v0.3.1, et gardé ici, là où les dossiers sont publiés.

Ajouté

  • Le corpus de fuzzing survit à une campagne. Les entrées intéressantes s'accumulaient dans GOCACHE, qui disparaît avec la machine — et un runner de CI démarre toujours à froid. Mesuré ici : 125 entrées intéressantes en vingt-cinq secondes contre deux graines versionnées, si bien que chaque campagne repartait de deux et refaisait le chemin que la précédente avait déjà parcouru. C'est du travail perdu, à chaque exécution. mise run fuzz-promote promeut les entrées vers le corpus versionné selon trois règles écrites : un échantillon plafonné DÉTERMINISTE (un tirage au hasard rendrait le diff illisible et le résultat irreproductible), un plafond qui borne le corpus et non l'exécution (chaque graine est rejouée par chaque go test, donc un corpus qui enfle est un coût permanent qui enfle avec lui), et — celle qui compte — chaque candidate est éprouvée contre l'arbre courant avant d'être acceptée. Une graine qui fait tomber sa cible est écartée et nommée : elle entre avec la correction qu'elle motive, jamais avant. Le workflow de campagne conserve désormais ce qu'il a exploré à chaque exécution, pas seulement à l'échec, et imprime le nombre de graines dont il est parti.

  • iam_accesskey_rotated : la moitié « rotation » de CLD-IAM-2, que rien ne mesurait. L'exigence demande des clés longue durée « assorties d'une expiration ET d'une rotation ». L'expiration se lit sur un champ ; la rotation ne se lit nulle part — elle se déduit de l'âge de la clé, parce qu'une clé jamais remplacée est une clé jamais tournée. Mesuré sur un tenant réel : une clé expirant en 2099 satisfait le contrôle d'expiration sans qu'aucune rotation n'ait eu lieu, et un compte avec MaxAccessKeyExpirationSeconds: 0 n'a aucun plafond pour la borner non plus. La fenêtre est controls.iam.key_max_age_days, 90 jours par défaut ; l'allonger tait des écarts, donc le référentiel l'adosse à l'exigence par au_plus_le_defaut. Schéma d'inventaire v4 → v5 : une access_key porte désormais creation_date (osc-sdk-go v2.24.0 AccessKey.CreationDate, vérifié dans le SDK).

  • scan --gate <all|security|compliance|sovereignty> : un profil pour la porte de CI, qui ne cache rien. Un premier scan doit provoquer « ah oui, ça c'est intéressant », pas « oui, je sais que ma VM de test n'a pas de protection contre la suppression ». Le rapport reste COMPLET dans tous les formats — c'est la règle déjà appliquée aux dérogations et aux constats d'incertitude : le rapport dit tout, seule la porte filtre. Ce qui change est ce qui pèse dans le code de sortie, et chaque scan imprime ce qui a été mis de côté et pourquoi. Aucun profil ne peut faire passer une chaîne de rouge à vert : un 1 devient au pire un 3 — « n'établit pas la conformité », la lecture honnête d'un scan volontairement partiel (ADR-0005, et toujours pas de cinquième code) —, jamais un 0. Le défaut reste all, donc rien ne change sans le drapeau. Surface CLI v5 → v6. Le drapeau ne s'appelle pas --profile : ce nom désigne déjà le profil d'identifiants de la collecte live.

  • Un finding déclare désormais sa CONFIANCE, distincte de sa sévérité. La sévérité dit la conséquence si le problème est réel ; la confiance dit à quel point Pépin est sûr de l'avoir établi. Un volume sans sauvegarde récente et une VM dont SSH est ouvert à Internet étaient tous deux high et indiscernables — le premier est contextual (la règle le documente elle-même : un volume peut être sauvegardé autrement), le second confirmed. labels.confidence vaut confirmed, probable, heuristic ou contextual, déclarée règle par règle, et une règle sans confiance casse la CI.

  • labels.category gagne sovereignty et hygiene. La souveraineté est la raison d'être de ce produit et était rangée sous compliance, où un filtre ne pouvait pas la trouver ; l'hygiène documentaire n'est ni une faille ni un manquement normatif, et la confondre avec l'un des deux est ce qui rend un premier scan irritant. Sept findings passent en sovereignty, trois en hygiene.

  • Le vocabulaire de confiance de la détection de secrets rejoint le vocabulaire commun : high/medium/low deviennent confirmed/probable/heuristic, sans perdre de granularité. Un secrets.min_confidence écrit avec les anciens mots reste accepté, au même rang, et normalisé dans la configuration résolue — une politique committée ne change pas de sens en silence.

  • La carte de qualité publie désormais la précision, dérivée du corpus de contre-exemples. « 42 contrôles » est une phrase que tous les CSPM prononcent. Détecter et se TAIRE sont deux mesures différentes, et elles s'impriment maintenant côte à côte : 42 contrôles high/critical actifs, 18 dont un chemin de détection est prouvé de bout en bout, 16 dont un contre-exemple légitime l'est, 0 faux positif mesuré sur les contre-témoins durcis. Il n'y a délibérément aucune ligne « faux négatifs » : rien dans le dépôt ne les mesure, et un « 0 » publié voudrait dire « nous n'avons pas cherché ». Une garde échoue si ce champ est ajouté, une autre refuse tout chiffre de précision supérieur à son dénominateur.

  • Chaque règle high/critical doit désormais prouver ce sur quoi elle REFUSE de se déclencher. Le contrat de véracité prouvait que Pépin sait produire le verdict attendu ; il ne prouvait jamais qu'il le RETIENT sur une configuration voisine et légitime. Une règle qui se déclenche sur tout est parfaitement sensible et sans précision aucune. Un contre-exemple est un couple sur un même chemin contrôle × fournisseur × source : un cas fail et un cas pass proche. 13 écrits à ce jour, chacun éprouvé dans les deux sens ; les 26 manquants sont comptés dans un registre exact dans les deux sens, et un contrôle high/critical ajouté sans son contre-exemple casse la CI. mise run counterexamples-update le régénère.

  • Les tenants de référence : des configurations tierces, rejouées à chaque build. Une fixture est écrite par l'auteur de la règle : elle prouve que la règle se déclenche, jamais qu'elle a raison sur une configuration que personne n'a conçue pour elle. Six stacks réelles, publiées, sous licence MIT ou Apache (deux par fournisseur souverain) sont désormais épinglées à un commit sous references/tenants/, scannées à travers le binaire à chaque build, et comparées aux verdicts consignés à côté d'elles. Chaque fournisseur porte un tenant exposé et son contre-témoin durci — le seul endroit où un faux positif se voit. Rien n'est provisionné : terraform plan ne crée aucune ressource cloud. scripts/reference-tenant.sh --all re-dérive les six plans depuis leurs amonts à l'octet près, pour qu'un relecteur puisse vérifier que ce ne sont pas des fichiers que Pépin a fini par s'écrire à lui-même. Cf. Les tenants de référence.

  • Le plan committé pour un tenant ne porte que ce que Pépin lit (planned_values et les source de modules), toute valeur que Terraform lui-même marque sensitive étant mise à null. TestNoReferenceTenantPlanCarriesMoreThanPepinReads refuse le reste : variables, provider_config, prior_state et resource_changes sont précisément là où un plan pris sur un tenant réel porterait ses identifiants.

  • Les scans canari à la qualification de release. mise run canary interroge le vrai plan de contrôle de chaque fournisseur cloud et consigne ce qu'il a répondu dans references/canary/<fournisseur>.yaml, committé et daté. Il ne détient aucun identifiant et n'en a pas besoin : il envoie des valeurs synthétiques que le fournisseur refuse, et ce qui se mesure est le refus — un endpoint qui répond 401/403 existe et se résout, un endpoint déplacé répondrait 404, c'est-à-dire la régression qu'un descripteur ne peut pas voir venir. Première mesure, 31 endpoints : tous ont répondu, aucun n'a bougé. Il n'établit pas qu'un droit suffisant rende 200, et ne vaut donc pas validation live d'un contrôle. La complétude est une porte de test (internal/canary) ; la fraîcheur — aucun relevé de plus de 90 jours — est vérifiée par le preflight, qui ne détient jamais de secret. Cf. Publier une release.

  • Une carte de qualité de détection générée, docs/detection-quality.fr.md. Plutôt que d'annoncer « 57 contrôles », elle publie ce qui se vérifie : 63 verdicts prouvés sur 458, 23 chemins entièrement prouvés sur 178, et la ventilation par verdict — fail 10/140, pass 24/140, not-evaluated 18/156, not-applicable 11/22. Chaque chiffre est dérivé du registre de véracité, des tenants de référence et des relevés de canari ; aucun n'est saisi, et TestTheMapNeverExceedsTheLedger refuse que la carte et le registre divergent. Validé en live : 0 % — dérivé, pas écrit : seul un relevé authentifié le ferait monter, et un canari ne détient aucun identifiant.

  • pepin control explain <code> [--provider p] : un nouveau verbe CLI (surface cli v5). Pour un contrôle et un fournisseur, il rend la chaîne qui rend son verdict opposable : les appels d'API qui alimentent la décision (la spec collecte, jointures comprises, plus les collecteurs Go partagés), les attributs décisifs, les conditions exactes d'un pass dans l'ordre où assess.Build les évalue, les tests qui l'éprouvent, et la date de la dernière validation live — qui affiche jamais, et dit pourquoi. Il lit le même instantané committé que la carte : deux calculs divergeraient, et celui qui diverge est celui qu'on lit.

Modifié

  • Cinq verdicts bougent sur un tenant inchangé, et chacun est mesuré (issue #229). Les trois tenants de qualification ont été appliqués, scannés, scellés, détruits et prouvés détruits sur des comptes Scaleway, Outscale et Exoscale réels le 2026-09-10 — 15, 19 et 20 familles de collecte listées, aucune ressource du tenant survivante, aucun delta avant/après, pour 0,072 EUR au total. Ce qui suit change ce qu'un rapport dit d'une infrastructure que vous n'avez pas touchée :

    • compute_instance_has_security_group ne signale plus une instance privée : un groupe de sécurité ne s'attache pas à une instance sans interface publique, donc la remédiation ne pouvait jamais aboutir (#212). En live Exoscale, le contrôle rend désormais not-evaluated plutôt que pass, parce qu'une liste vide observée est comptée comme non collectée — lacune de couverture, épinglée et nommée #227.
    • governance_resource_required_tags ne signale plus un bucket SOS Exoscale : SOS accepte PutBucketTagging et ne persiste rien, donc un tel bucket n'a jamais d'étiquette quoi qu'on fasse (#208). Le contrôle dit qu'il ne peut pas conclure au lieu de crier.
    • database_service_not_open_to_internet nomme la base, et non l'adresse de la règle d'ACL qui l'expose (#193). Une base, un sujet.
    • objectstorage_bucket_default_encryption sur un plan Terraform n'est plus absent de l'assessment : il apparaît en not-evaluated, en nommant l'attribut que le plan ne porte pas. Un contrôle qui disparaît se lit comme un contrôle sans rien à dire.
    • governance_resource_region_in_eu conclut pass sur un plan Outscale, là où il renonçait : la région est désormais dérivée de la zone que le plan porte (placement_subregion_nameregion, #194). Vérifié avant d'être épinglé — un not-evaluated qui devient pass a la forme exacte d'un faux vert, et celui-ci repose sur une valeur observée avec sa formule, pas sur une fabrication.
  • Une unité Outscale refusée nomme désormais le droit qui l'aurait collectée, et la documentation dit qu'un scan complet exige les clés du propriétaire du compte (issue #168). Mesuré le 2026-09-09 sur un tenant eu-west-2 réel, avec un utilisateur EIM ne portant que la politique de lecture publiée par Outscale (api:Read* sur *) : toutes les unités OAPI sont revenues complètes, avec un inventaire identique à celui du compte propriétaire — et OOS comme OKS ont refusé cette identité. OOS répond InvalidAccessKeyId — The AWS access key Id you provided does not exist in our records, c'est-à-dire qu'il ne connaît pas du tout les clés EIM ; OKS répond Forbidden: User type not allowed, refusant l'identité par son type. Aucun des deux n'est un droit manquant, donc aucune politique EIM ne les lève. Ces deux unités portaient un droit vide au descripteur, et le relevé de capacités n'imprime « droit requis » que si le descripteur le déclare : l'opérateur ne lisait que l'erreur S3 brute et allait vérifier une clé parfaitement valide. Les deux droits sont désormais déclarés, donc le relevé de capacités et chaque motif de not-evaluated les nomment. La table des permissions distingue une ligne documentée d'une ligne mesurée et date la seconde (mesure: au descripteur) ; la page du fournisseur dit comment vivre avec les clés propriétaires — une clé dédiée avec une date d'expiration, hors CI, et une dérogation datée sur iam_no_root_access_key plutôt que le silence. Aucun identifiant n'entre en CI : la mesure est un geste de mainteneur, lancé localement et consigné (ADR-0012).

  • La porte de qualification disait GO alors qu'un faux vert connu subsistait. Elle comparait un run à expected.yaml et concluait GO dès qu'ils coïncidaient — un bon contrat de non-régression, mais présenté comme une porte de qualité de release. Un défaut épinglé s'y reproduit à l'identique, la comparaison ne trouve aucune différence, et la porte disait GO : elle vérifiait que le produit ment de la même façon qu'hier. Les deux questions reçoivent désormais deux réponses, côte à côte dans le rapport. Un défaut connu déclare sa classe et s'il bloque une release : un false_green bloque toujours et ne se déroge pas — c'est la promesse sur laquelle le produit est bâti —, un false_positive bloque par défaut et peut se déroger explicitement, et un coverage_gap ne bloque pas, parce qu'un not-evaluated justifié est une limite nommée, ce que le produit exige précisément de lui-même. Une chaîne nue, ou une classe que personne n'a déclarée, bloque : un épinglage qui ne dit pas ce qu'il est ne doit pas valoir laissez-passer.

  • Plans Terraform : une référence déclarée ferme la corrélation qui n'a jamais fonctionné. Un plan ne peut pas connaître l'identifiant d'une ressource qu'il va créer — cet attribut n'est pas résolu, il est absent. Mesuré sur un tenant de référence construit depuis du HCL tiers : vm_id, public_ip et security_group_ids absents sur 5/5 VM, security_group_id sur 14/14 règles — tout ce sur quoi les règles se joignent. Le verdict restait honnête (not-evaluated, jamais un pass), mais aucun plan réel ne corrélait quoi que ce soit ; seuls les plans écrits à la main pour les tests le faisaient, où l'identifiant est un littéral. Le plan porte la relation ailleurs : configuration garde la référence que l'exploitant a écrite, et cette référence est une observation, pas une estimation (ADR-0022). Elle est suivie à travers les frontières de module, parce qu'à l'intérieur d'un module l'argument lit var.x. Une référence ambiguë — une déclaration démultipliée par count — ne résout rien : joindre la VM n° 0 au groupe de sécurité n° 1 serait un écart posé sur une ressource qui ne le porte pas.

  • Un verdict bouge sur le corpus de référence : compute_instance_has_security_group passe de not-evaluated à pass sur outscale/terraform.

  • Le sujet d'un finding peut changer sur la source Terraform. Une adresse résolue devient une identité, donc un descripteur dont l'id: lit un champ désormais comblé nomme la ressource qu'il désigne : sur un plan Scaleway, le sujet d'une ACL de bucket est le bucket qu'elle configure plutôt que la ressource ACL. C'est le sujet que l'on cherche, et il rejoint celui de la source live — mais une dérogation écrite sur l'ancien sujet cesse de correspondre.

  • Les plans des tenants de référence portent désormais les references de leur configuration, et jamais ses constant_value — c'est là que vit un secret en dur. Une garde le tient sur le texte committé.

  • Les deux findings d'un réseau tout juste créé disent maintenant lequel vient de l'exploitant. Sur un Net Outscale neuf, un scan live signalait un high sur le security group « default » — « porte une règle entrante » — et l'exploitant allait chercher une règle qu'il aurait écrite. La règle trouvée est celle que le provider crée avec le réseau : sa seule source admise est le groupe lui-même. L'écart subsiste (deux ressources démarrées sans SG explicite y atterrissent et communiquent librement, ce que CLD-NET-4 refuse), mais il nomme désormais la forme qu'il a trouvée et porte confidence: contextual au lieu de confirmed. La distinction est observée, pas déduite d'un CIDR absent : une règle entrante admet une source par CIDR ou par groupe, et la source par groupe est maintenant collectée (peer_security_group_ids). Là où le champ manque, la règle retombe sur la formulation générale et sur confirmed — ne pas savoir ne doit pas faire sortir un écart d'une porte de CI.

  • La sortie non restreinte est contextual, plus confirmed. L'étiquette venait du constructeur partagé, dont la justification porte sur l'entrée : il n'existe pas de raison légitime d'ouvrir SSH à tout Internet. Cet argument ne vaut pas pour la sortie. Une sortie ouverte est un chemin d'exfiltration réel, mais des architectures défendables la laissent ouverte et filtrent en aval — passerelle, mandataire, pare-feu périmétrique — sur un plan que le scan ne voit pas. Le finding garde son code, sa sévérité medium et sa catégorie security ; il quitte --gate security sans quitter le rapport. Le comportement par défaut ne bouge pas : --gate all rend toujours 1.

  • Les messages d'exposition choisissent leur préposition selon la direction de la règle. Une règle sortante n'accepte rien « depuis » Internet, et un lecteur qui corrige ce que la phrase décrit cherchait au mauvais endroit.

  • La souveraineté se mesure désormais sur les ressources qui hébergent des données, et sur elles seules. Le contrôle de localisation UE concluait depuis une voisine : une seule ressource portant une région ouvrait le pass à toutes les autres, y compris à des types que la règle n'examine jamais — un iam_user en fr-par certifiait une VM dont la région n'avait jamais été collectée. Une région ne compte comme observée que si chaque ressource du type la porte. Verdict déplacé sur un tenant inchangé : un tenant sans ressource localisée (réseaux, sous-réseaux, peering) passe de pass à not-evaluated, et un tenant partiellement localisé aussi.

  • Une jointure rompue ne se lit plus comme un écart. La donnée décisive se déclare par type de ressource, si bien qu'un contrôle corrélant deux types se dégrade quand le lien manque, au lieu de conclure sans lui. volume_id non collecté sur les snapshots faisait paraître tous les volumes sans sauvegarde — un faux positif de masse, en high, causé par une lacune de collecte. Verdict déplacé sur un tenant inchangé : ces écarts deviennent not-evaluated en nommant le champ manquant. Un volume réellement sans snapshot, et un tenant sans aucune snapshot, échouent toujours.

  • Un écart déduit d'une absence que personne n'a cherchée est retiré. Certaines règles concluent d'une absence, et elles ont raison — chez Scaleway un ExpiresAt nul est « aucune expiration ». Mais un champ non mappé est absent lui aussi, et les deux étaient indiscernables : iam_accesskey_expiration_set levait un critical dans les deux cas. L'assessment lit désormais la provenance, qui consigne ce qui a été cherché, et retire l'écart quand le champ n'a jamais été demandé (ADR-0017). Verdict déplacé sur un tenant inchangé, dans un seul sens : une affirmation est retirée, jamais ajoutée. Un inventaire sans provenance — export d'un tiers — n'est pas touché.

  • La dette de véracité passe de 445 à 395 verdicts restant à prouver, et les chemins entièrement prouvés de 5 à 23 sur 178. Les tenants de référence et les scénarios écrits à la main alimentent un seul registre : deux chiffres de couverture divergeraient, et celui qui diverge est celui qu'on lit. Un verdict de tenant ne compte que s'il est substantielfail, pass et not-applicable toujours, not-evaluated seulement si le tenant porte réellement une ressource du type visé. Sans ce filtre, les mêmes six tenants auraient payé 97 obligations au lieu de 50, la moitié par des absences.

  • Le plan d'un tenant ne garde plus que les attributs qu'un mapping lit vraiment. Le réducteur coupait au niveau des sections et gardait tous les attributs de toutes les ressources : un helm_release embarquait donc son blob de valeurs Helm entier, un kubectl_manifest son yaml_body, un kubernetes_secret son contenu — rien de tout cela n'est lu par une règle commune. La réduction est désormais une liste blanche dérivée des descripteurs, descendue jusqu'au champ, et TestNoReferenceTenantPlanCarriesAnAttributeNobodyReads refuse le reste. Aucun verdict ne bouge : internal/tfmap n'a jamais projeté que les champs mappés. Le corpus passe de 54 Kio à 25 Kio.

[0.3.0] - 2026-08-21

Ajouté

  • Une session de collecte enregistrée, et la porte qui la rejoue. Un descripteur de fournisseur déclare des endpoints ; rien ne prouvait que le collecteur les émet, ce qui est la forme exacte de l'incident des politiques EIM inline (règle juste, donnée qui n'arrivait jamais, aucun test Rego capable de le voir). mise run trace enregistre désormais une vraie collecte --live à travers un proxy d'interception, contre un émulateur local et sans aucun identifiant cloud ; l'enregistrement est versé dans internal/genprovider/testdata/transcripts/. Deux portes le rejouent à chaque build : TestTheRecordedCollectionStillHappens (moins d'appels que ce que l'enregistrement a vu, c'est une donnée qui a cessé d'arriver ; plus d'appels, c'est un endpoint déclaré mais jamais mesuré) et TestEveryDeclaredEndpointIsObservedOrDeclaredUnobserved (le registre non_observes est exact dans les deux sens). Le rejeu sert les réponses enregistrées, jamais des réponses dérivées de la spec qu'il éprouve : un harnais qui répondrait « ce que la spec attend » mesurerait sa propre copie de la spec et resterait vert sur un items: faux. Mesuré à la première session : aucun endpoint déclaré ne reste muet, sauf trois jointures enfants d'Outscale et une d'Exoscale dont la liste parente n'est pas servie par l'émulateur ou est revenue vide ; chacune est désormais consignée avec sa raison. Nouveau guide : Tracer les appels réels. Aucune ligne de Pépin n'a changé, donc aucun endpoint de collecte n'est devenu surchargeable et aucune surface d'exfiltration n'a été créée.

  • Un contrat de véracité, et un compteur de dette plutôt qu'une matrice verte. Pour chaque chemin contrôle × fournisseur × source, internal/veracity dérive les verdicts que ce chemin peut réellement atteindre — trois quand il sait conclure, un quand il ne peut pas lever le verrou du pass, un quand le contrat du fournisseur le déclare non applicable — et les compare aux scénarios committés, qui s'exécutent contre le binaire, sur toute la chaîne : des réponses d'API canned servies à la spec de collecte RÉELLE du descripteur, ou un plan Terraform minimal passé à son mapper réel. Ce qui n'est pas prouvé est consigné dans internal/veracity/testdata/debt.txt, une porte dans les deux sens : une obligation non prouvée absente du registre fait échouer la construction — un contrôle ajouté sans ses scénarios ne peut donc pas passer — et une ligne qui n'est plus due la fait échouer aussi. Les compteurs sont publiés dans docs/known-limitations.fr.md. Aujourd'hui : 178 chemins, 5 entièrement prouvés, 458 obligations, 445 restantes. Une matrice de sept cents cas engendrés par gabarit serait verte et ne prouverait rien.

  • Une suite de dégradation avec une seule garantie : jamais un pass. Un endpoint refusé, une jointure enfant refusée, une réponse partielle, un service indisponible, une réponse illisible, un attribut Terraform encore inconnu au stade du plan — chacun produit pour de vrai contre un serveur vivant ou un plan réel, et vérifié sur tous les contrôles qui lisent le type touché, pas sur un témoin choisi.

  • Un finding Terraform porte son origine : fichier, ligne, module. --format json gagne labels.tf_file, labels.tf_line et labels.tf_module ; le résultat SARIF gagne un physicalLocation avec sa region, ce qui fait qu'une forge annote le bloc resource fautif plutôt que le fichier de plan. Le module se lit dans l'adresse de la ressource ; le fichier et la ligne sont mesurés dans les sources .tf posées à côté du plan, parce que terraform show -json ne porte ni l'un ni l'autre — vérifié dans la source de Terraform elle-même, où la représentation configuration d'une ressource contient address, type, name, expressions, et rien sur le document. Quand les sources sont absentes, que le module est distant ou que le même en-tête de bloc apparaît deux fois, l'origine est simplement absente : une ligne fausse envoie corriger le mauvais endroit, et on la croit. Sur une collecte live, la notion n'existe pas et aucun label n'est posé.

  • Les permissions minimales sont déclarées au descripteur, pas seulement en prose. Chaque descripteur de fournisseur porte désormais un bloc permissions:, une entrée par unité de collecte : le droit dans le vocabulaire natif du fournisseur, la source officielle qui l'énonce, et s'il est confirmé ou encore à vérifier. Les pages de fournisseurs rendent ce bloc, si bien que le tableau que suit un lecteur et le droit que le scan nomme dans un motif de not-evaluated ne peuvent pas diverger. Quatre portes refusent l'omission silencieuse : une unité de collecte sans droit déclaré, une entrée orpheline, un état ou une source manquants, un droit non vérifié sans réserve écrite. Rien de tout cela n'est confirmé par un scan lancé avec un rôle délibérément réduit — ce dépôt ne détient aucun identifiant cloud — et chaque page le dit.

  • La complétude de la collecte est enregistrée, et elle déplace le verdict. Chaque collecteur — le moteur déclaratif, le stockage objet, la chaîne des politiques EIM inline et le Kubernetes managé — consigne désormais, unité par unité, s'il a lu tout ce que l'API avait à rendre. Un endpoint refusé n'arrête plus le scan et ne disparaît plus dans un avertissement : l'inventaire porte un bloc collection (attempted, complete, une classe d'erreur stable, le detail du fournisseur), il est scellé dans le bundle de preuve, et il est publié par --format json. Tout contrôle qui lit un type de ressource alimenté par une unité incomplète devient not-evaluated, avec cette unité nommée comme motif — c'est l'assessment qui tranche, jamais une règle — et le scan rend 3, jamais 0. La transition est strictement directionnelle : un pass est retiré, un fail est conservé (un écart observé reste observé), un not-applicable est conservé (il vient du contrat, pas de la collecte). Un verdict peut désormais bouger sur un tenant inchangé : un scan dont les identifiants ne peuvent pas lire une partie du périmètre rendait 0, il rend 3 désormais.

  • Un relevé de capacités, imprimé avant tout verdict. Un scan live annonce ce qu'il a pu et n'a pas pu observer, unité par unité, avec la classe de chaque échec et le nombre de contrôles que cela coûte — ce nombre venant de la même fonction que celle qui dégrade l'assessment, si bien que le relevé ne peut pas promettre ce que le rapport ne tiendra pas. Hors collecte live, il n'apparaît que s'il a quelque chose à dire. Les types de ressources qu'un plan Terraform porte et qu'aucune spec ne projette y figurent aussi : ce n'est pas une incomplétude — aucun contrôle ne les lit, donc aucun verdict n'en dépend, et ils ne bloquent aucune porte — mais ils ne sont plus silencieux.

  • exempted : un cinquième statut d'assessment de premier rang, et des dérogations datées. scan --exceptions <fichier.yaml> lit une politique de dérogations versionnée : control, justification, expires_at, owner, approved_by, les cinq obligatoires et tous validés au chargement. Un fail couvert par une entrée valide devient exempted, jamais pass : le finding reste dans --format json, dans le SARIF et dans le décompte par sévérité, summary.conforme reste faux, et le verdict annonce NON CONFORME sous dérogation. Un nouveau code de sortie, 4, signifie « tout écart critical/high restant est couvert par une dérogation datée et attribuée » : non nul, donc rien ne passe en silence, et distinct, donc un pipeline qui l'accepte doit écrire le chiffre. Une dérogation expirée cesse de s'appliquer et le dit ; une dérogation qui nomme un contrôle ou un sujet inexistant est signalée comme orpheline ; les deux font échouer une porte --strict. Le bundle scelle exemptions.json, si bien que l'empreinte du dossier dépend de ce qu'il a écarté, et verify --re-derive rejoue la politique scellée à l'instant scellé. Un statut et un code de sortie sont deux surfaces qu'un pipeline doit savoir lire.

  • Chaque attribut de l'inventaire normalisé porte sa provenance. À côté d'attributes, un index parallèle provenance dit, pour chaque attribut, d'où vient la valeur : api avec la requête réellement servie, terraform-plan avec le type de ressource du plan, ou derived pour un littéral de descripteur ou une valeur calculée localement, et si la source portait vraiment le champ. C'est un index parallèle et non une enveloppe autour de chaque valeur : les 59 règles Rego lisent attributes.<nom> sans changer, donc aucun verdict ne peut bouger (mesuré sur neuf fixtures × deux formats × deux langues : findings, statuts et codes de sortie identiques). --format assessment expose désormais, pour chaque contrôle ayant un attribut décisif, cet attribut et son attestation dans evidence.attribute / evidence.source. Cela rend visible, sans la changer, la situation de deux contrôles qui franchissent leur garde d'attribut grâce à une constante de descripteur plutôt qu'à une mesure.

  • L'inventaire normalisé est un contrat interne versionné. pepin-inventory/v1, gelé dans cmd/testdata/frozen/inventory.json avec son enveloppe, la forme de sa ressource et le vocabulaire complet des types et attributs communs, dérivé des descripteurs et des collecteurs. La version voyage avec chaque bundle de preuve (manifest.inventory_schema), et une nouvelle page de référence dit ce qui est garanti et ce qui ne l'est pas. Format de bundle /v2 (le manifeste porte le schéma d'inventaire et le résumé des dérogations), surface CLI v3 (--exceptions, code de sortie 4).

  • Vague 3 de la documentation : le catalogue des contrôles est généré, et le projet s'explique. Une page générée par contrôle sous docs/controls/ (ce qu'il conclut, depuis quelle source, avec son motif quand il ne peut pas conclure), un guide de remédiation qui montre le même contrôle passer de fail à pass sur les plans d'exemple du dépôt, une page d'architecture qui argumente le choix central (un seul jeu de règles commun, la source est ce qui change d'un cloud à l'autre) et deux guides de contribution, ajouter un contrôle et ajouter un fournisseur, chacun terminé par une checklist utilisable telle quelle. Une ROADMAP.md publique remplace le document de travail interne qui vivait dans la documentation produit.

  • Exoscale est le premier fournisseur dont les preuves de remédiation déployables sont complètes : 26 sur 26, ce qui porte le dépôt de 4 à 26 sur 95. Vingt modules Terraform autonomes, vérifiés par terraform init -backend=false et terraform validate contre le schéma réel du provider, plus deux notes documentées là où Terraform ne peut pas exprimer la correction (souveraineté du fournisseur, MFA d'un compte). TestExoscaleRemediationCoverageStaysComplete casse désormais la CI quand un contrôle exoscale arrive sans sa preuve ; les autres fournisseurs restent hors de cette garde jusqu'à ce qu'ils atteignent 100 %.

  • Vague 2 de la documentation produit : dix pages, générées partout où elles peuvent l'être. Une référence CLI bâtie depuis la surface gelée et depuis de vraies exécutions de --help, le contrat des codes de sortie montré comme six exécutions avec le code que chacune a rendu, les cinq formats de sortie avec un document réel chacun, le plan contre le live avec deux divergences reproductibles, le cycle de vie du bundle de preuve (sceller, vérifier, re-dériver, altérer, caviarder) capturé de bout en bout, les intégrations GitHub Actions et GitLab CI dont les pipelines complets sont injectés depuis examples/, et une page par cloud souverain avec ses appels d'API et ses permissions minimales en lecture seule. TestEveryPublicCLIFlagIsDocumented échoue désormais quand un drapeau public manque à la référence CLI, dans l'une ou l'autre langue.

  • Les exemples de CI publiés épinglent la v0.2.0 et chaque action par SHA de commit. L'action des v0.1.0 et v0.1.1 n'installait rien du tout (gh attestation verify sans jeton) : ces tags ne doivent être épinglés par personne.

  • Les contrôles deviennent réglables, et un réglage assoupli ne garde pas son badge. Quatre contrôles lisent désormais un fichier de politique : le profil d'étiquetage obligatoire, la fenêtre de fraîcheur et les états acceptés d'une snapshot, le seuil de détection de secrets. Chaque réglage est une poignée qui permet de fabriquer du vert : chaque correspondance normative du référentiel porte donc les contraintes sous lesquelles elle vaut (config_requise, quatre sens interprétables : au_plus_le_defaut, superset_du_defaut, sous_ensemble_du_defaut, au_moins_aussi_strict_que_le_defaut). Une configuration qui sort d'une contrainte fait perdre au contrôle ses references dans l'assessment — il cesse de prétendre couvrir CIS, ISO ou SecNumCloud — et l'assouplissement apparaît en cinq endroits à la fois : le terminal (CONFIGURATION ASSOUPLIE), les labels et la preuve de l'assessment, --format json (config.relaxations), le bandeau de verdict, et le bundle scellé (config.json plus une entrée config au manifeste, tous deux couverts par checksums.txt). Durcir un réglage n'est pas un assouplissement et n'est signalé nulle part. mise run validate refuse une contrainte qui nomme un réglage que le moteur de politique ne sait pas évaluer. Voir docs/guides/control-configuration.fr.md.

  • Un seul fichier de politique : scan --policy. Il porte controls: (les réglages) et exceptions: (les dérogations, format inchangé). --exceptions reste le nom historique du même fichier et lit le même schéma : une invocation existante et un fichier existant continuent de fonctionner. Les deux drapeaux sont mutuellement exclusifs, parce que deux fichiers de politique sont deux fichiers qui divergeront. Surface CLI v4.

  • La détection de secrets porte un niveau de confiance. Chaque finding de compute_instance_no_secrets_in_user_data publie labels.confidence : high pour un bloc PEM de clé privée, medium pour un préfixe reconnu au format attendu (ghp_, AKIA, SCW, EXO, glpat-, JWT), low pour une heuristique générique (password=…, api_key=…). Le seuil de signalement par défaut est low : tout est signalé, exactement comme avant. La valeur détectée n'apparaît toujours jamais, quel que soit le niveau, et cette propriété est désormais testée aux trois niveaux, sur le message et sur la remédiation, dans les deux langues.

  • L'inventaire évalué porte sa configuration. L'enveloppe gagne config, la configuration effective des contrôles, à côté de evaluated_at — l'input.json d'un bundle scellé rejoue donc sous les réglages de son propre jour, et verify --re-derive reste fidèle sans qu'on lui redonne le fichier de politique. --format json publie config.policy_digest et config.effective sur chaque scan, le défaut compris : un lecteur doit pouvoir vérifier qu'un scan a tourné sous les réglages attendus, pas seulement constater qu'il n'a rien dit. Format de bundle v3.

Modifié

  • network_documented vérifie enfin ce qu'il annonce. La règle promettait propriétaire, projet et environnement, et évaluait count(tags) > 0 : un unique foo=bar suffisait à déclarer un réseau documenté — une conformité affirmée sans avoir été mesurée. Elle exige désormais les étiquettes qui documentent réellement (par défaut Owner, Project, Env, configurables), et elle se tait quand l'attribut tags n'a pas été collecté, là où elle signalait un écart. Le code est inchangé : il voyage dans les ruleId SARIF, les assessments archivés et les fichiers de dérogations, où un renommage transformerait du jour au lendemain une dérogation valide en dérogation orpheline et ferait réapparaître l'écart qu'elle couvrait. Titre et description sont réécrits dans les deux langues.

  • La politique d'étiquetage obligatoire est configurable, et la comparaison est indifférente aux conventions. governance_resource_required_tags n'exige plus quatre littéraux figés. La comparaison est insensible à la casse et aux séparateurs (cost-centerCostCenter), et les alias élargissent chaque nom logique (team pour Owner, environment pour Env) : une organisation qui écrit cost-center, application, environment, team n'est plus signalée comme non gouvernée. Les types de ressources visés sont explicites et justifiés, et quatre types facturables entrent dans le périmètre — blockstorage_snapshot, compute_image, managed_database, kubernetes_cluster —, ce qui comble un faux négatif sur des services payants qui en étaient exclus. Le profil livré est documenté comme une recommandation, pas comme une norme.

  • Le contrôle de fraîcheur des snapshots dit ce qu'il mesure, et ce qu'il ne prouve pas. blockstorage_volume_snapshots_exist vérifie désormais l'état natif de la snapshot en plus de sa date : une snapshot en error, pending ou creating ne compte plus comme une sauvegarde. Le délai est configurable (7 jours par défaut). Le titre devient « Absence de snapshot récente et terminée », et la description énonce franchement ce que le contrôle ne prouve pas — restaurabilité, complétude applicative, rétention, existence d'une politique de sauvegarde. Le code est inchangé, pour la même raison que ci-dessus. Ancré sur Outscale Snapshot.State et Exoscale block-storage-snapshot.state, désormais projetés par les collecteurs. L'inventaire normalisé gagne donc un attribut, ce qui est un changement de contrat : schéma d'inventaire v4, dont la note consigne aussi la clé d'enveloppe config ajoutée plus haut.

  • --strict refuse aussi une correspondance normative tombée. Il refusait déjà une couverture nulle, des écarts medium/low restants et un fichier de dérogations périmé ; il rend maintenant 3 quand un réglage assoupli a coûté sa correspondance à un contrôle. Aucun nouveau code de sortie : l'incomplétude et l'assouplissement disent la même chose — ne lisez pas ce scan comme un feu vert — et les deux occupent déjà la place de 3.

[0.2.0] - 2026-08-19

Ajouté

  • Pépin est bilingue, et détecte la langue. Rapports, verdict, aide, erreurs et formats parsables (json, sarif, oscal, assessment) sortent en français ou en anglais. Ordre de résolution : --lang=fr|enPEPIN_LANGLC_ALLLANG → repli en ; la première source non vide décide, et une locale inconnue retombe sur l'anglais sans erreur. Jusqu'ici l'ossature était anglaise et le contenu français : un lecteur recevait un rapport en deux langues dans la même phrase. Le français reste la langue de référence du contenu normatif : le référentiel et les règles s'écrivent en français d'abord, et là où une lecture juridique est en jeu, c'est la formulation française d'un contrôle qui fait foi.

  • Le projet a une marque. docs/assets/brand/ porte l'icône et les verrouillages en SVG et PNG, clair, sombre et monochrome, avec les générateurs qui les produisent (scripts/generer-marque.py, scripts/generer-png-marque.py) et les règles d'usage dans docs/brand.fr.md. Les deux README s'ouvrent dessus.

Modifié

  • Schéma d'inventaire pepin-inventory/v3. Une ressource gagne source (file, line, module), présent seulement là où il a pu être mesuré. Ajout pur.

  • Le code de sortie 3 s'élargit de « rien n'a été mesuré » à « le scan n'établit pas la conformité ». Il se déclenche désormais aussi quand la collecte n'a pas pu lire une partie du périmètre visé. Délibérément pas de cinquième code : un code propre à l'incomplétude ne pourrait jamais primer sur 1 — masquer un écart critique réel au motif que le reste manque serait le faux vert que cette vague existe pour empêcher — il ne s'exprimerait donc que là où 3 s'exprime déjà. Ce qui distingue les situations reste lisible dans le relevé de capacités, dans le motif de chaque contrôle et dans la clé collection. Un code de sortie est une surface que tout pipeline analyse.

  • Schéma d'inventaire pepin-inventory/v2. L'enveloppe gagne collection. Ajout pur — aucun champ existant ne bouge — mais un consommateur qui rejouerait un inventaire sans lire collection conclurait plus fermement que Pépin ne l'a fait, ce qui est exactement ce que ce champ existe pour empêcher. La version voyage dans manifest.inventory_schema.

  • La prose d'un finding change avec la langue, ses clés non. Codes (CLD-*), identifiants de check, sévérités, statuts, sujets et codes de sortie sont identiques dans les deux langues ; titres, messages, remédiations et preuves sont traduits. Un pipeline qui compare le texte d'un rapport d'une exécution à l'autre doit figer PEPIN_LANG. Un pipeline adossé aux codes et aux statuts n'est pas affecté.

  • Un bundle scellé porte la langue du scan qui l'a produit. verify --re-derive rejoue les règles dans les deux langues et accepte la concordance de l'une : vérifier un bundle français depuis un shell anglais n'est plus signalé comme une falsification. À noter : l'empreinte du bundle dépend bien de la langue, puisque la prose de l'assessment fait partie de ce qui est scellé.

  • Surface CLI v1 → v2 : ajout du drapeau persistant --lang. Ajout pur : aucun verbe, aucun autre drapeau ni aucun code de sortie ne bouge.

  • docs/doc-cache-brief.md sort de la documentation produit. C'était un mémo de mainteneur s'adressant à une machine (« déjà téléchargée sur cette machine »), décrivant un cache qu'un clone ne peut pas avoir, lié de nulle part, et portant six chemins absolus vers un répertoire personnel. Ce qu'il contenait de précieux rejoint references/docs/README.md, à côté du sources.yaml qu'il décrit, dont le piège qui compte le plus : la documentation n'est pas le contrat.

Corrigé

  • L'action publiée installe de nouveau. La vérification de provenance ajoutée en 0.1.0 appelait gh attestation verify sans jeton ; gh refuse de tourner dans un workflow sans GH_TOKEN, si bien que l'installateur tenait tout binaire pour invérifiable et le refusait, chez tous les consommateurs, en 0.1.0 comme en 0.1.1. L'action fournit désormais github.token elle-même : personne ne devrait avoir à câbler un jeton pour installer un binaire.

    L'angle mort mérite d'être nommé. Le job de pull request servait install.sh en boucle locale avec la vérification sautée, donc le chemin public n'était jamais exercé avant le job d'après-publication, c'est-à-dire après que le tag existe. Un job appelle maintenant l'action contre une version déjà publiée, à chaque pull request.

[0.1.1] - 2026-08-19

Corrigé

  • Une instance Scaleway dont le groupe de sécurité est créé par le même plan n'est plus rapportée CRITICAL « VM sans groupe de sécurité ». Au stade plan, security_group_id est unknown after apply, donc absent de planned_values ; le transform list fabriquait alors une collection vide, qui satisfaisait la garde de capacité de la règle, celle-là même qui existe pour empêcher ce cas. Un transform de collection ne s'applique désormais que si la clé source existe réellement. « Absent » signifie que la source n'expose pas l'information ; « présent et vide » est une information.

    Cela change un verdict sur un tenant inchangé : sur un plan Terraform, le contrôle passe de fail à non évalué. Sur un plan, une instance réellement dépourvue de groupe de sécurité est indistinguable d'une instance dont le groupe n'est pas encore connu : Pépin le dit maintenant au lieu de deviner. Le chemin live en bénéficie aussi, où une API omettant une clé produisait le même [] fabriqué.

    Trouvé en rejouant quinze stacks Terraform de tiers contre le binaire.

  • La porte de non-dérive de la documentation compile désormais ce qu'elle mesure. Elle réutilisait un ./pepin déjà présent à la racine : un binaire périmé pouvait donc valider des pages périmées, ce qui est arrivé, la porte annonçant « à jour » pendant que la doc affichait encore le finding ci-dessus.

Ajouté

  • Documentation produit, générée plutôt que recopiée. Six pages en anglais avec leur contrepartie française synchronisée : un démarrage en cinq minutes sans compte cloud, le modèle d'assessment (pass / fail / non applicable / non évalué), la matrice de couverture providers × contrôles, les limites connues, la lecture commentée d'un scan réel, et le périmètre exact avec ses non-objectifs. Toute sortie de commande est capturée d'une exécution réelle du binaire, la matrice est calculée depuis le référentiel et les descripteurs de providers, et une porte de CI échoue dès que l'un des deux dérive.

  • Fuzzing des entrées non fiables : FuzzParsePlan et FuzzInventoryWalk, couvrant le plan Terraform et l'export d'inventaire. Il a immédiatement trouvé une ressource au type vide entrant dans le modèle, désormais écartée et conservée en régression.

Sécurité

  • SECURITY.md lie désormais son canal de signalement privé au lieu de seulement le décrire.

[0.1.0] - 2026-08-19

Sécurité

  • Une politique chargée à chaud n'a plus accès au réseau. --policy-dir compilait du Rego tiers avec les capacités par défaut d'OPA, http.send compris : une règle de huit lignes suffisait à POSTer l'inventaire évalué — user-data des instances, documents de politique IAM, policies de bucket — vers un hôte arbitraire, ou à balayer le réseau interne du runner depuis l'intérieur du scanner. Corrigé en amont dans scankit v0.2.2 ; une politique appelant un de ces builtins ne compile plus. L'évaluation reçoit aussi une borne de cinq minutes.
  • Les identifiants fournisseur ne survivent plus à une redirection HTTP. Go ne retire, en cross-domain, que Authorization, Cookie et WWW-Authenticate — ni X-Auth-Token (clé secrète Scaleway) ni AccessKey/SecretKey (Outscale). Une seule 302 vers un hôte contrôlé les livrait. Le client de collecte ne suit plus les redirections.
  • pepin verify ne lit plus hors de son bundle. Les noms d'artefacts venaient du manifeste, fourni par le tiers audité : ../secret faisait de la vérification un oracle d'existence et de contenu.
  • --seal --redact n'emporte plus les clés du tenant. Le caviardage ne couvrait que les documents libres, alors qu'access_key est un attribut à part entière du modèle normalisé et que password/certificate remontent des bases managées.
  • Toolchain en Go 1.26.6, qui annule cinq avis de la bibliothèque standard atteignables depuis ce code (net/url, crypto/tls, encoding/xml, encoding/asn1, net/http).
  • L'action publiée vérifie l'authenticité, pas seulement l'intégrité. Le binaire et checksums.txt viennent de la même origine : qui peut remplacer les assets d'une release remplace les deux. install.sh vérifie désormais la provenance via gh attestation verify.

Corrigé

Chaque point ci-dessous peut changer un verdict sur un tenant inchangé.

  • Un scan qui n'a rien mesuré ne rend plus 0. Identifiants expirés, droits insuffisants ou inventaire tronqué produisaient le même résultat vide qu'un tenant sain, et la porte de CI passait au vert sur un périmètre jamais regardé. Le code 3 le dit maintenant, sans exiger --strict.
  • Quatorze contrôles ne concluent plus pass sans la donnée décisive. Le verrou de capacité gagne treize entrées, et une collection vide ne compte plus comme collectée : le collecteur IAM pose toujours statements, à [] quand un document ne s'analyse pas, si bien que quatre contrôles critical/high concluaient « conforme » sur zéro information.
  • authenticated-read et AuthenticatedUsers sont détectés comme exposition publique : les deux accordent la lecture à tout utilisateur authentifié de la plateforme, donc hors du tenant.
  • Un bucket rendu public par une acl en ligne sur scaleway_object_bucket est enfin collecté ; il produisait auparavant zéro finding et un verdict « conforme ».
  • Les booléens transmis en chaîne sont honorés. Un plan Terraform rend certains attributs de schéma en "true"/"false", et == false est simplement faux pour "false" ; 25 comparaisons dans 16 règles passent désormais par truthy().
  • Une région non cataloguée est signalée au lieu de passer en silence : les tables de classification sont des listes blanches, et leur silence valait « en UE ».
  • Normalisation réseau : -1, any et un protocole vide signifient tous « tout protocole », et un scalaire là où le modèle attend une liste ne rend plus la règle indéfinie — un export portant "cidrs": "0.0.0.0/0" n'était pas signalé.
  • Sévérités de CLD-CHF-2 alignées sur high pour ses trois contrôles : la sévérité pilote la porte de CI, et l'écart n'était pas justifié.

Ajouté

  • La surface publique est gelée par des tests, pas par de la prose. Les verbes, flags et codes de sortie de la CLI, le document findings de --format json, le document assessment et la forme du bundle de preuve ont chacun une fixture committée sous cmd/testdata/frozen/ : l'arbre de champs, jamais une valeur. Une forme qui bouge sans sa fixture fait échouer la CI ; une fixture régénérée sans que la version déclarée bouge aussi. La version du bundle voyage sur le fil comme suffixe /vN du champ format de manifest.json ; un vérificateur qui rencontre une version inconnue doit s'arrêter plutôt que deviner.
  • L'index SCSL est surveillé en dérive. mise run scsl-drift compare l'index vivant de framework-scsl à une baseline committée dans referentiel/scsl-baseline.json et sort en 2 quand une exigence CLD a été ajoutée, retirée ou reformulée en amont sans qu'un humain retrie les mappings. La convention de sortie de l'outillage (0 ok, 1 erreur, 2 dérive) est volontairement distincte de celle de pepin scan (où 2 est l'erreur technique).
  • Une release se refuse avant le tag, pas se regrette après. mise run release-check -- vX.Y.Z rejoue hors ligne tout ce qui doit tenir : arbre propre sur main, tag libre, tests et cohérence du référentiel, zéro dérive SCSL, les codes de sortie répondus par le binaire construit plutôt que lus dans une constante, un bundle scellé qui se vérifie, se re-dérive et se refuse une fois altéré, la version que les Conventional Commits impliquent (.cz.toml), et les deux CHANGELOG portant la section d'où le corps de la release se lit.
  • Un tag construit, atteste et signe la release. .github/workflows/release.yml construit les binaires linux/darwin × amd64/arm64 avec le tag gravé dedans, génère les empreintes SHA-256 et un SBOM CycloneDX, enregistre la provenance de build SLSA, signe les empreintes en Cosign keyless, et publie la GitHub Release avec la section correspondante de ce fichier pour corps.
  • Une image de conteneur (ghcr.io/stephrobert/pepin, un tag par release, pas de latest) : les binaires linux publiés sur une base distroless épinglée par digest, avec les certificats racines pour le TLS de --live, l'utilisateur 65532 et pas de shell. Rien n'est compilé dans le Dockerfile, donc les empreintes, le SBOM et la provenance de la release décrivent aussi le contenu de l'image ; l'image porte sa propre provenance SLSA, sa propre attestation de SBOM et une signature keyless, et la release refuse une image dont le pepin version n'est pas le tag ou dont les codes de sortie ont bougé à travers docker run.
  • Une action GitHub composite (.github/actions/pepin-scan) qui vérifie le SHA-256 du binaire téléchargé contre la liste d'empreintes de la release avant de l'exécuter, scanne un plan Terraform, un inventaire ou l'API live, et traduit les codes de sortie en porte de CI : fail-on-nonconformity: 'false' rétrograde un verdict non conforme (1, ou 3 sous strict) en avertissement, et ne rétrograde jamais une erreur technique (2). Les identifiants ne sont jamais des entrées de l'action ; les variables natives du provider passent par env:. La CI corrompt un octet du téléchargement et exige le refus (entrypoints.yml), et chaque release rejoue l'action contre ses propres artefacts publiés.
  • Un modèle GitLab CI et des exemples de CI (examples/gitlab-ci/, examples/github-actions/) : même téléchargement vérifié, même contrat de codes de sortie, mode rapport via allow_failure: exit_codes: [1, 3], jamais 2. L'installation et la vérification des quatre portes d'entrée sont documentées dans docs/install.md / docs/install.fr.md.