perf sentinelperf sentineldocs
FRENGitHub
Documentation / Divulgation

Rapport public périodique

perf-sentinel disclose produit un document JSON unique qui agrège les findings collectés sur une période calendaire (typiquement un trimestre) dans une forme adaptée à la transparence publique. La sortie est vérifiable par hash, versionnée par schéma, et distincte du JSON Report par batch consommé par le dashboard HTML.

La sous-commande est ajoutée en v0.6.x et remplace les recettes de divulgation ad hoc antérieures.

Quel intent choisir

intentvalidationpubliableusage typique
internalaucunenonbrouillons de dev, tests à blanc
officialstricteouipublication trimestrielle de transparence
auditedréservépas encoreréservé pour une release future

audited est réservé pour une release future. Le schéma JSON accepte la valeur pour la compatibilité ascendante, mais la CLI sort avec le code 2 ("audited intent is reserved for a future release, use 'internal' or 'official' instead") et le daemon refuse de démarrer avec intent = "audited" configuré.

Pour l'intent official, le validateur refuse également les rapports sous 75% de couverture de calibration runtime. Le dénominateur est runtime_windows_count + fallback_windows_count : chaque fenêtre de scoring archivée par le daemon dans la période demandée est classée runtime (attribution d'énergie par service présente) ou fallback (part I/O du proxy utilisée comme substitut). Une couverture sous 75% signifie que plus d'un quart des fenêtres de la période ne portaient pas d'attribution par service, donc la part proxy commence à dominer les totaux et la revendication "official" perd une couverture par service significative. La justification empirique du seuil exact 75% (versus 50% ou 90%) est documentée dans 08 · Divulgation périodique.

Couverture temporelle et autres warnings (v1.2)

disclose rapporte aggregate.temporal_coverage : la fraction des jours calendaires de la période déclarée qui ont porté des mesures (observed_days / days_in_period). Quand elle tombe sous un seuil informatif, la CLI affiche un warning sur stderr et ajoute un disclaimer au rapport, mais elle ne bloque jamais un rapport official. L'archivage est déclenché par le trafic (une fenêtre sans trafic n'écrit rien), donc un chiffre bas peut être une période légitimement calme plutôt qu'un trou de mesure. À traiter comme une borne basse de l'activité, pas comme une garantie de disponibilité du daemon. Il existe pour qu'un lecteur distingue un trimestre mesuré en continu d'un trimestre où le daemon n'a tourné que quelques jours, le signal interne au binaire le plus proche de "l'opérateur a arrêté de mesurer une partie de la période". La non-participation totale (ne jamais lancer l'outil) ne laisse aucun rapport et sort du périmètre de toute vérification interne au binaire, voir 08 · Divulgation périodique.

Deux vérifications supplémentaires resserrent la cohérence. Le validateur rejette en dur un days_covered qui ne vaut pas (to_date - from_date) + 1 (un rapport produit par disclose le satisfait toujours, donc seul un fichier édité à la main le déclenche) et un requests_measured qui dépasse un total_requests_in_period déclaré par l'opérateur. Pour l'intent official, omettre total_requests_in_period à la fois dans l'org-config et dans --total-requests-in-period émet un warning, puisque coverage_percentage est alors absent du rapport.

L'archive doit remonter au début de la période (0.28.0)

disclose vérifie aussi que l'archive remonte au premier jour de la période : la plus ancienne fenêtre de tous les fichiers d'entrée, dans la période ou hors d'elle, doit tomber ce jour-là ou avant. Quand les entrées contiennent des lignes chaînées, seules les lignes dont la chaîne de hash se vérifie comptent, donc une ligne sans hash placée devant une archive chaînée ne peut pas tenir lieu de fenêtres élaguées. Une archive écrite avant le chaînage n'a aucune ligne vérifiée, et sa plus ancienne ligne compte encore. L'archive du daemon tourne à max_size_mb et élague à max_files par nombre de fichiers, pas par ancienneté, et un daemon chargé écrit une fenêtre toutes les trace_ttl_ms / 2 (15 secondes par défaut). Ses 12 fichiers de 100 Mio par défaut ne tiennent donc même pas une journée : une mesure au labo sous charge synthétique a relevé des lignes de fenêtre de 350 à 900 Kio, qui les remplissaient en 6 à 16 heures, et les fichiers élagués ne laissent aucune trace dans la chaîne de hash, qui repart à chaque fichier. Quand l'archive commence plus tard, un disclose official sort en code 2 sans écrire de rapport, et un internal avertit. Les deux messages donnent le premier jour de l'archive, pour que --period-type custom --from <ce jour> publie ce que l'archive couvre encore. Un début de période calme ne déclenche pas ce contrôle tant que des lignes plus anciennes précèdent la période. Depuis la 0.28.0, les cumuls quotidiens décrits ci-dessous tiennent un trimestre avec les réglages par défaut. Sans eux, dimensionner l'archive pour la période publiée, ou publier des périodes plus courtes.

Les cumuls quotidiens tiennent une période (0.28.0)

Quand [daemon.archive] est configuré, le daemon écrit aussi des cumuls quotidiens : une ligne NDJSON par jour UTC, dans un fichier par mois nommé <stem>-daily-YYYY-MM.ndjson, dans le répertoire de [daemon.archive] path. Avec path = "/var/lib/perf-sentinel/reports.ndjson", octobre 2026 atterrit dans /var/lib/perf-sentinel/reports-daily-2026-10.ndjson. Une ligne de cumul porte l'état partiel de l'agrégateur lui-même pour les fenêtres de ce jour, le même calcul que celui que disclose applique aux lignes de fenêtre, si bien qu'un jour coûte quelques dizaines de Kio et un trimestre quelques Mio au plus. Ses champs sont format ("perf-sentinel-rollup/v1"), ts (la première fenêtre du jour), day, last_ts, state, prev, seq, drops et hash. hash est le même hash SHA-256 de chaîne d'archive que sur les lignes de fenêtre, calculé sur la ligne privée de son champ hash.

Un jour se clôt quand arrive la première fenêtre d'un autre jour UTC, d'après l'horodatage de cette fenêtre et non d'après l'horloge murale, ainsi qu'à un arrêt propre. Un daemon redémarré le même jour écrit donc une deuxième ligne pour ce jour, et une horloge reculée au-delà de minuit écrit une deuxième ligne pour le jour précédent. Les lignes d'un même jour s'additionnent, rien n'est écrasé. Toutes les 20 fenêtres (environ cinq minutes avec la fenêtre par défaut), le jour ouvert est aussi sauvegardé dans <stem>-daily.partial.json, écrit dans un fichier temporaire puis renommé, lisible par son seul propriétaire. Au démarrage, le daemon ajoute cet instantané une seule fois comme ligne, sauf si une ligne sur disque part déjà de la même première fenêtre, puis le supprime. Un instantané derrière un lien symbolique est refusé. Un arrêt brutal (SIGKILL, OOM kill) coûte donc aux cumuls au plus leurs 20 dernières fenêtres, jamais le jour, et ne compte jamais un jour deux fois. Quand les lignes de fenêtre sont conservées, disclose compte encore ces fenêtres depuis elles, voir plus bas. Une ligne que le répertoire refuse, sur un disque plein par exemple, est gardée en mémoire et retentée à la clôture de jour suivante. À l'arrêt, le jour ouvert part dans l'instantané à la place.

Deux clés de [daemon.archive] les accompagnent :

  • rollup_retention_days (défaut 400, un peu plus d'un an, doit valoir >= 1). Un fichier mensuel est supprimé en entier dès que son dernier jour précède de plus de ce nombre de jours le jour clos le plus récent, ou la date du jour quand une fenêtre datée dans le futur a clos un jour ultérieur. Le contrôle a lieu à chaque clôture de jour.
  • windows (défaut true). false arrête les lignes par fenêtre et ne garde que les cumuls. Les lignes de fenêtre sont le détail fenêtre par fenêtre qu'interroge le RUNBOOK. Ne les couper que si ce détail est inutile.

--input accepte des fichiers de fenêtres, des fichiers de cumuls ou les deux, ainsi qu'un répertoire qui contient les deux. Un fichier de cumuls est reconnu à son contenu, l'étiquette de format sur sa première ligne lisible, donc un fichier de cumuls renommé ou copié est encore lu comme tel. Un répertoire ne fournit jamais l'instantané .json. Les fichiers de cumuls sont lus en premier. Une ligne de cumul couvre les fenêtres comprises entre sa première et sa dernière fenêtre (ts et last_ts) pour l'archive à côté de laquelle elle se trouve, le fichier de fenêtres de même radical dans le même répertoire. Les lignes de fenêtre dans cet intervalle n'alimentent plus que la vérification de chaîne et le compteur de pertes, donc aucune fenêtre ne compte deux fois. Les lignes de fenêtre hors de tout intervalle comptent encore : celles archivées avant la mise à jour le jour même, et celles qu'un arrêt brutal a fait perdre au cumul après son dernier instantané. Les hôtes restent séparés, donc les cumuls d'un hôte ne masquent jamais les fenêtres d'un autre. Un fichier de cumuls renommé hors du radical de son archive est lu comme une source à part entière, ne le passez donc pas avec les lignes de fenêtre de cette archive.

Les chiffres tirés des cumuls correspondent à ceux tirés des mêmes fenêtres à l'arrondi flottant près, parce qu'un cumul additionne d'abord son jour et que les additions se font dans un autre ordre. La même période publiée depuis les cumuls et depuis les fenêtres peut donc différer sur ses derniers chiffres, et par conséquent sur son content_hash. Publier deux fois les mêmes entrées reste reproductible. Le schéma du rapport publié ne change pas : les cumuls sont uniquement un format d'entrée.

Le daemon écrit les cumuls sans appliquer --strict-attribution, qui reste le choix du lecteur. Avec cette option, disclose refuse un cumul qui a intégré une fenêtre non attribuée et nomme la plus ancienne de ces fenêtres. Une ligne de cumul est lue depuis un fichier, donc sa fusion applique chaque garde-fou du chemin des fenêtres : le plafond de 4096 services, les seuls noms d'anti-patterns connus, les chiffres non finis ou négatifs ramenés à zéro, la validation des versions de binaire et des étiquettes de modèle d'énergie. Une ligne de cumul ne répond en outre que de son propre jour.

Toutes les lignes de cumul d'une archive forment une seule chaîne qui ne repart jamais de zéro. Elle traverse les fichiers mensuels et les redémarrages, l'écriture reprenant à la dernière ligne du fichier le plus récent. Un jour supprimé ou modifié apparaît comme une rupture de chaîne. La rétention supprime des mois entiers par l'avant, et le lecteur adopte la plus ancienne ligne restante comme début de chaîne, donc la rétention n'est pas une rupture. Dans integrity.trace_integrity_chain, une ligne de cumul vérifiée compte comme vérifiées les fenêtres qu'elle contient, et une ligne de cumul rompue compte une rupture. Chaque ligne porte aussi le compteur cumulatif de pertes du daemon, donc windows_dropped est publié depuis les cumuls comme depuis les lignes de fenêtre.

Le contrôle du début de période ci-dessus prend le plus ancien ts vérifié parmi les lignes de cumul et de fenêtre. Une divulgation trimestrielle officielle le passe donc avec les réglages par défaut dès que le daemon archive depuis le premier jour du trimestre, même après l'élagage des fichiers de fenêtres de ce jour. Pour voir jusqu'où remontent les cumuls, lire disclosure_archive dans /api/status (oldest_day, days, last_closed_day, absent tant qu'aucun jour n'est clos), ou les jauges perf_sentinel_archive_oldest_day_timestamp_seconds, perf_sentinel_archive_rollup_days et perf_sentinel_archive_last_closed_day_timestamp_seconds, qui valent 0 jusque-là.

Granularité

Perf Sentinel publie les rapports à deux niveaux de granularité, contrôlés par --confidentiality. Le validateur refuse de publier un rapport confidentiality = public qui contiendrait des entrées G1, et inversement.

  • G1 (Granularity level 1, "détail interne"). Activé par --confidentiality internal. Chaque entrée applications[*] porte un tableau anti_patterns: [...] complet ventilant chaque type d'anti-pattern détecté sur ce service avec occurrences, énergie gaspillée estimée et carbone gaspillé. À utiliser pour les décisions d'optimisation internes, pas pour la publication publique : le détail par pattern expose des signaux de performance internes qu'un opérateur peut ne pas vouloir diffuser.
  • G2 (Granularity level 2, "agrégat public"). Activé par --confidentiality public. Chaque entrée applications[*] porte les mêmes totaux par service (énergie, carbone, score d'efficacité) mais remplace le tableau par un seul entier anti_patterns_detected_count. Adapté à la publication sur l'URL de transparence d'une organisation.

Flags CLI

perf-sentinel disclose accepte les flags suivants :

  • --intent <internal|official|audited> (requis). audited est réservé pour une release future, la CLI le refuse aujourd'hui avec le code de sortie 2.
  • --confidentiality <internal|public> (requis). Détermine la granularité G1 ou G2, voir ci-dessus.
  • --period-type <calendar-quarter|calendar-month|calendar-year|custom> (requis). Indique la sémantique de la période aux consommateurs en aval. custom utilise --from et --to tels quels, choix correct pour des fenêtres non-alignées (par exemple un pilote de 6 semaines).
  • --from <YYYY-MM-DD> et --to <YYYY-MM-DD> (requis, inclusifs). Dates calendaires UTC.
  • --input <PATH> (requis, répétable). Chaque chemin peut être un fichier .ndjson unique, un répertoire dont les fichiers *.ndjson sont réunis (triés par nom), ou un glob développé par le shell. Perf Sentinel ne développe pas les globs lui-même, donc --input archive/2026Q1/*.ndjson marche en shell mais échoue en exec direct sans expansion par le shell. Dans les runners CI qui exécutent le binaire directement, préférer un répertoire ou un fichier unique.
  • --output <PATH> (requis). Où écrire perf-sentinel-report.json.
  • --org-config <PATH> (requis pour intent = "official"). Le TOML statique organisation / méthodologie / scope décrit plus bas dans la section TOML org-config.
  • --emit-attestation <PATH> (optionnel). Quand fixé, écrit aussi le sidecar statement in-toto v1 à ce chemin. Nécessaire pour le workflow de signature.
  • --strict-attribution (optionnel). Par défaut, Perf Sentinel range les spans sans attribution service.name dans un service synthétique _unattributed. Ce service synthétique contribue aux totaux agrégés mais est exclu de la ventilation par service. Avec --strict-attribution, l'appel disclose refuse de produire un rapport si une fenêtre porte des spans non-attribués, en listant les horodatages en cause dans le message d'erreur. À utiliser pour une divulgation officielle quand on veut affirmer que 100% des opérations mesurées ont été correctement attribuées.
  • --total-requests-in-period <N> (optionnel). Nombre total de requêtes des applications du périmètre sur la période, le dénominateur de coverage_percentage. Prime sur scope_manifest.total_requests_in_period de l'org-config. Ce total n'est connu qu'une fois la période close, le passer à chaque exécution garde l'org-config statique. La prévisualisation --tui l'applique aussi et le reprend dans la commande qu'elle affiche.
  • --tui (optionnel, nécessite la feature de build tui). Ouvre une prévisualisation en lecture seule au lieu d'écrire un rapport, voir ci-dessous. Elle rend --intent, --confidentiality, --period-type, --from, --to et --output optionnels, puisqu'on les règle dans l'interface. Incompatible avec --emit-attestation.

Prévisualisation interactive (--tui)

perf-sentinel disclose --tui ouvre une prévisualisation en lecture seule au lieu de produire un rapport. Elle relit la même archive NDJSON froide que la commande canonique, donc les chiffres correspondent exactement, mais elle ne hache et n'écrit jamais rien. Utilisez-la pour régler une période et vérifier la couverture avant de lancer la commande reproductible en CLI ou en CI.

bash
perf-sentinel disclose --tui \
  --input /var/lib/perf-sentinel/reports.ndjson \
  --org-config /etc/perf-sentinel/org.toml

Seuls --input et --org-config restent requis. La période, l'intent et la confidentialité se règlent en direct :

  • g fait défiler la granularité (mois, trimestre, année, custom). Pour les trois premières, from et to se calent sur les bornes calendaires. custom permet d'éditer chaque borne à la main.
  • ← / → (ou h / l) déplacent la période d'une unité à la fois. En custom elles déplacent la borne active d'un jour, [ et ] la déplacent d'un mois, et Tab bascule entre la borne from et la borne to.
  • i bascule l'intent (internal ou official), c bascule la confidentialité (G1 internal ou G2 public).
  • Le résumé indique le nombre de fenêtres, la couverture de période face au seuil officiel, les services mesurés et exclus, les totaux (requêtes, carbone, énergie, ratio de gaspillage), et le verdict du validateur officiel quand l'intent est official.
  • Sous Totals, une ligne Database: et une ligne Broker: donnent le périmètre de chaque hôte : les kWh d'hôte inclus et combien des fenêtres portant un chiffre les ont comptés (1.2500 kWh in the totals (3 of 5 windows)), ou outside the totals. Voir Périmètre des hôtes.
  • Le pied de page affiche la commande disclose exacte pour les réglages courants. Copiez-la pour produire le rapport haché.

La prévisualisation nécessite un terminal interactif. Rediriger sa sortie (pas de TTY) se termine sur une erreur claire au lieu d'afficher quoi que ce soit.

Gaspillage évitable : canonique versus opérationnel (1.1+)

Un rapport porte l'énergie et le carbone opérationnels totaux de la charge (dérivés des spans, non réglables) plus deux niveaux de gaspillage évitable :

  • Canonique (aggregate.canonical_waste). Calculé à un seuil N+1 fixe épinglé dans le binaire (2), indépendamment du [detection] n_plus_one_min_occurrences de l'opérateur. C'est le chiffre non manipulable : relever votre propre seuil ne peut pas le réduire. C'est le chiffre évitable de référence, et au seuil opérateur par défaut de 5 il est typiquement plus grand que ce que le tableau de bord de l'opérateur affiche.
  • Opérationnel (aggregate.operational_waste). Calculé au seuil configuré par l'opérateur et enregistré avec ce seuil.

Publier les deux permet à un lecteur de les comparer et de voir combien de gaspillage évitable un seuil opérateur relâché masquerait. Pour intent = official, le validateur refuse un rapport dont le canonical_waste.n_plus_one_threshold n'est pas la valeur canonique du binaire.

Périmètre des hôtes (v1.8)

total_energy_kwh et total_carbon_kgco2eq couvrent les services applicatifs, plus les hôtes de base de données et de broker dans les fenêtres qui les ont comptés. Une fenêtre compte un hôte quand un chiffre mesuré (alumet_rapl), modélisé (cloud_specpower) ou déclaré (database_specpower, broker_specpower) portait une conversion carbone. Le chiffre estimé est la part applicative des spans SQL ou de publication et reste dans les chiffres des applications. Un chiffre d'hôte archivé avant que l'énergie des hôtes n'entre dans les totaux, ou sans carbone, reste à côté d'eux sous carbon_breakdown.*_kgco2eq_out_of_total.

Chaque rapport énonce ce périmètre trois fois :

  • notes.disclaimers se termine par une ligne par sous-système, base de données puis broker, ajoutée après les avertissements personnalisés ou par défaut. Elle dit que les hôtes sont dans les totaux sur les N fenêtres qui portaient un chiffre ou sur K fenêtres sur N (en nommant les fenêtres estimées et celles gardées à côté des totaux), avec les modèles et placements vus sur la période, ou que les totaux ne contiennent aucune énergie des hôtes de ce sous-système. Quand un hôte est inclus, la ligne ajoute que son carbone est dans total_carbon_kgco2eq et dans les chiffres d'aucune application.
  • La page "At a glance" du PDF nomme les hôtes de base de données et de broker dont le in_totals_windows n'est pas nul, le test de notes.disclaimers, avec leur in_totals_energy_kwh (et in K of N windows quand seule une partie des fenêtres les a comptés), et dit que les autres sont hors des totaux. Le tableau des applications ajoute une ligne Database hosts et une ligne Broker hosts pour ces mêmes hôtes, si bien que ses lignes s'additionnent jusqu'à la ligne Total.
  • carbon_breakdown.database_kgco2eq et messaging_kgco2eq portent le carbone des hôtes inclus, database_waste.in_totals_energy_kwh et messaging_waste.in_totals_energy_kwh leur énergie.

Pour intent = official, le validateur contrôle ce périmètre : les deux termes carbone des hôtes sont finis et >= 0, les cinq termes de la ventilation s'additionnent en total_carbon_kgco2eq à 1e-6 près en relatif (contrôle sauté quand carbon_methodologies contient l'étiquette ancienne sci_v1_numerator), et le energy_kwh des applications plus les deux parts d'hôtes incluses tiennent dans total_energy_kwh.

Correspondance ESRS E1 (v1.3)

Chaque rapport porte methodology.standard_crosswalk, une correspondance interprétative de ses chiffres vers la norme européenne de reporting climat ESRS E1 (règlement délégué (UE) 2023/5303) :

  • aggregate.total_energy_kwh alimente E1-5 (consommation et mix énergétiques), à convertir en MWh. Perf Sentinel ne ventile pas ce chiffre par source fossile, nucléaire ou renouvelable. Il inclut l'énergie des hôtes de base de données et de broker quand le in_totals_windows correspondant n'est pas nul.
  • les termes carbone opérationnel, base de données et broker alimentent E1-6 Scope 2 en base location-based. ESRS exige aussi un Scope 2 market-based, que SCI exclut, c'est donc une entrée partielle.
  • le carbone embarqué (le terme SCI M, agrégat seulement) alimente E1-6 Scope 3 (catégories 1 et 2). ESRS admet les estimations et données proxy pour le Scope 3.

C'est une aide à la correspondance, pas une certification. Elle ne transforme pas un rapport en déclaration CSRD : les chiffres gardent leur intervalle d'incertitude directionnel 2x, le périmètre est l'IT compute uniquement, et un inventaire audité par un organisme qualifié reste requis. Les mêmes mises en garde figurent dans le rapport lui-même, sous standard_crosswalk.caveats et dans notes.disclaimers.

Entrées

Archives daemon exclusivement. Un rapport batch analyze --format json ne peut pas alimenter disclose, pour trois raisons indépendantes : une entrée de type répertoire ne collecte que les *.ndjson, chaque ligne doit se désérialiser en l'enveloppe {"ts", "report"} ci-dessous ou en ligne de cumul quotidien, et un rapport batch ne porte pas de disclosure_waste (les niveaux canoniques de gaspillage évitable sont calculés au moment de l'archivage). Les rapports batch restent exclus parce qu'une divulgation publique repose sur une observation continue de la période déclarée, pas sur des exécutions ponctuelles. Utilisez perf-sentinel watch avec l'archivage activé pour tout ce que vous comptez publier.

Une période qui mêle des archives antérieures à la divulgation canonique et des archives plus récentes passe la validation --intent official, car le seuil canonique est le maximum sur les fenêtres. Le niveau canonique omet alors le gaspillage des fenêtres anciennes, c'est pourquoi disclose indique combien de fenêtres ne portaient pas de chiffre canonique. Régénérez sur des fenêtres postérieures à la montée de version pour obtenir un chiffre complet.

L'agrégateur lit des fichiers NDJSON que le daemon archive à raison d'une enveloppe par fenêtre de scoring :

json
{"ts":"2026-01-15T14:30:00Z","report":{ ...full Report... }}

Configurer l'archive daemon via :

toml
[daemon.archive]
path = "/var/lib/perf-sentinel/reports.ndjson"
max_size_mb = 100
max_files = 12
windows = true
rollup_retention_days = 400

Quand le fichier actif dépasse max_size_mb, Perf Sentinel le renomme en reports-<utc-timestamp>.ndjson et ouvre un nouveau fichier. Les anciens fichiers tournés au-delà de max_files sont élagués par date de modification. La rotation des fenêtres ne touche jamais les fichiers de cumuls quotidiens, que rollup_retention_days élague par ancienneté, voir Les cumuls quotidiens tiennent une période.

Le stdout du daemon porte des findings, pas ces enveloppes : --input prend donc les fichiers de l'archive ou un autre fichier de lignes {ts, report}, jamais une capture de stdout.

TOML org-config

Les champs statiques organisation/méthodologie/scope vivent dans un fichier TOML que vous versionnez dans votre dépôt d'infrastructure à côté du reste de la config Perf Sentinel. Un exemple complet est dans docs/examples/perf-sentinel-org.toml. Le même fichier est référencé par [reporting] org_config_path quand le daemon doit valider les rapports publiables au démarrage.

methodology.calibration.specpower_table_version est optionnel. Omis, disclose le remplit avec la date de la table SPECpower que le binaire embarque, la seule qu'il puisse utiliser, si bien qu'une montée de version qui change la table ne demande aucune modification. Déclaré, il doit égaler cette date : disclose --intent official refuse un écart, tout comme le garde-fou de démarrage du daemon ci-dessous, pour que l'écart apparaisse au déploiement plutôt qu'à la production du rapport.

Exemple : brouillon interne (G1)

bash
perf-sentinel disclose \
  --intent internal \
  --confidentiality internal \
  --period-type calendar-quarter \
  --from 2026-01-01 --to 2026-03-31 \
  --input /var/lib/perf-sentinel/reports.ndjson \
  --output /tmp/perf-sentinel-report.json \
  --org-config /etc/perf-sentinel/org.toml

La sortie passe uniquement les vérifications structurelles (pas de validateur). integrity.content_hash est calculé et stable, mais integrity.binary_hash est le SHA-256 du binaire local, pas nécessairement une release publiée.

Exemple : publication officielle (G2)

bash
perf-sentinel disclose \
  --intent official \
  --confidentiality public \
  --period-type calendar-quarter \
  --from 2026-01-01 --to 2026-03-31 \
  --input /var/lib/perf-sentinel/reports.ndjson \
  --output /var/www/transparency/perf-sentinel-report.json \
  --org-config /etc/perf-sentinel/org.toml

Le validateur tourne sur l'ensemble du document. Si un champ requis manque ou sort de la plage, la CLI affiche tous les champs en cause et sort en 2. Corriger l'org-config (ou les données sous-jacentes) puis relancer.

Le chemin de publication recommandé est la racine de votre domaine de transparence :

https://transparency.example.fr/perf-sentinel-report.json

L'URL de schéma dans notes.reference_urls.schema indique quelle version de schéma un consommateur doit récupérer pour valider le fichier.

Garde-fou côté daemon

Lorsque le daemon est configuré avec [reporting] intent = "official", il refuse de démarrer si le TOML org-config est absent ou ne passe pas le validateur des champs statiques. Le message d'erreur liste tous les champs manquants ou invalides pour que l'opérateur les corrige en une seule passe.

toml
[reporting]
intent = "official"
confidentiality_level = "public"
org_config_path = "/etc/perf-sentinel/org.toml"
# Réservé aux divulgations périodiques déclenchées par le daemon, pas
# encore implémentées : un no-op. Renseigner ce champ aujourd'hui
# émet un warning au démarrage. Les rapports sont produits
# exclusivement via `perf-sentinel disclose --output`.
disclose_output_path = "/var/lib/perf-sentinel/last-disclosure.json"
disclose_period = "calendar-quarter"

intent = "internal" (ou l'absence de section) laisse le daemon en mode surveillance sans la barrière de rapport publiable.

Restreindre qui peut publier un rapport officiel

Produire une divulgation officielle est une action tournée vers l'extérieur et difficile à annuler : le fichier atterrit à une URL de transparence publique et est signé sous l'identité de votre organisation. La CLI elle-même n'a pas de couche d'autorisation. Quiconque peut exécuter le binaire avec l'org-config et les données d'entrée peut en produire une. Le contrôle appartient donc au pipeline qui publie, pas à perf-sentinel, le même découpage que pour les chemins d'écriture du daemon (voir API de query).

En CI, gardez le job qui lance disclose --intent official derrière un environnement GitHub avec reviewers requis. Un développeur peut toujours ouvrir la PR ou déclencher le workflow, mais le job se met en pause jusqu'à ce qu'un reviewer nommé (un architecte ou un responsable DevOps) approuve.

À configurer une fois sous Settings -> Environments -> official-disclosure :

  • Required reviewers : l'équipe d'architectes ou DevOps qui valide les rapports officiels.
  • Deployment branches and tags : restreindre à votre branche protégée ou aux tags de release pour que le job ne puisse pas tourner depuis une branche arbitraire.

Puis ciblez cet environnement depuis le workflow :

yaml
# .github/workflows/perf-sentinel-disclosure.yml
name: official disclosure

on:
  workflow_dispatch:
    inputs:
      from: { description: "period start (YYYY-MM-DD)", required: true }
      to:   { description: "period end (YYYY-MM-DD)",   required: true }

permissions:
  contents: read
  id-token: write   # signature keyless Sigstore via le token OIDC du workflow

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: official-disclosure   # les reviewers requis gardent ce job
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
      - name: Produce and sign the official disclosure
        run: |
          perf-sentinel disclose \
            --intent official \
            --confidentiality public \
            --period-type calendar-quarter \
            --from "${{ inputs.from }}" --to "${{ inputs.to }}" \
            --input /var/lib/perf-sentinel/reports.ndjson \
            --output perf-sentinel-report.json \
            --org-config /etc/perf-sentinel/org.toml \
            --emit-attestation attestation.intoto.jsonl
          # cosign sign-blob ... puis publier, voir "Signer votre divulgation" plus bas.

La permission id-token: write est un contrôle à part entière : la signature keyless lie la divulgation à l'identité du workflow, enregistrée dans integrity.signature.signer_identity. Une exécution depuis un chemin non approuvé produit un fichier dont l'identité signataire ne correspond pas au workflow qu'un vérifieur attend, voir Vérification d'identité.

Signer votre divulgation

Introduction à Sigstore

Si vous n'avez jamais utilisé Sigstore, cette introduction courte est un préalable pour comprendre les commandes qui suivent.

Pourquoi Sigstore. Sigstore est une boîte à outils open source hébergée par l'Open Source Security Foundation (OpenSSF), maintenue par Google, Red Hat, Chainguard, GitHub et la Linux Foundation. C'est le standard de facto pour les signatures d'artefacts vérifiables dans l'écosystème cloud-native (Kubernetes, Helm, la provenance npm, les attestations PyPI s'appuient toutes dessus). Le choix retenu pour les divulgations Perf Sentinel tient à trois propriétés :

  1. Signature sans clé permanente, aucune clé privée longue durée à gérer ou risquer de divulguer côté signataire.
  2. Un journal public où toute altération est détectable (Rekor), si bien qu'un tiers peut vérifier de façon indépendante qu'une signature existait à un instant donné.
  3. Libre, open source, auto-hébergeable, pas de verrouillage propriétaire ni de facturation à la signature.

Les trois composants.

  • Cosign est l'outil CLI exécuté localement. Il ouvre un flux OIDC dans le navigateur (ou consomme un token de workflow en CI), signe le fichier, et envoie la signature à Sigstore.
  • Fulcio est l'autorité de certification. Il consomme le token OIDC obtenu par cosign (preuve d'identité : email, URL d'un workflow GitHub, ...) et émet un certificat X.509 à durée courte (10 minutes) lié à cette identité. Fulcio ne voit jamais la clé privée du signataire.
  • Rekor est le journal de transparence public. Il enregistre la signature à côté du certificat Fulcio, retourne une preuve d'inclusion, et expose l'entrée à un index de journal stable. Les entrées passées ne peuvent pas être réécrites silencieusement.

Qui signe avec quelle clé. Cosign génère un nouveau couple de clés éphémère juste avant la signature. Fulcio émet un certificat de 10 minutes qui lie la moitié publique de ce couple à l'identité OIDC. Une fois la signature envoyée à Rekor, le couple de clés est détruit. Il ne reste que la signature, le certificat et l'entrée Rekor, ce dont un vérifieur a besoin.

L'identité OIDC est le sujet du certificat Fulcio (et finit comme integrity.signature.signer_identity + signer_issuer dans la divulgation). Pour un individu qui signe avec un compte Google, l'identité est l'adresse email et l'issuer est https://accounts.google.com. Pour un workflow GitHub Actions, l'identité est l'URL du workflow et l'issuer est https://token.actions.githubusercontent.com.

Limite connue : migration de fournisseur OIDC. L'URL de l'issuer est inscrite dans le certificat, donc enregistrée dans Rekor et dans la divulgation. Si l'organisation change plus tard de fournisseur d'identité (Google Workspace vers Keycloak auto-hébergé, Entra ID vers Okta, ...), les signatures passées restent valides mais les nouvelles signatures porteront une valeur signer_issuer différente. Les vérifieurs qui épinglent un issuer spécifique dans leur politique de vérification devront être mis à jour, sinon ils rejetteront les nouvelles signatures comme non fiables. Anticiper la politique d'épinglage en prévision des migrations de fournisseur.

Termes connexes que vous rencontrerez dans le workflow. Une ligne par terme seulement, les définitions complètes sont dans les spécifications liées.

  • OIDC (OpenID Connect) est un protocole d'identité posé sur OAuth 2.0. Dans ce workflow, c'est la manière dont cosign prouve "ce signataire est user@example.org" à Fulcio. Cosign ouvre un onglet navigateur, vous vous connectez à votre IdP (Google, GitHub, ...), l'IdP retourne un token signé, cosign le transmet à Fulcio. Spec.
  • in-toto v1 statement est une spécification OpenSSF ouverte pour les attestations de chaîne d'approvisionnement logicielle. Une enveloppe JSON qui apparie le hash d'un artefact avec une claim typée sur celui-ci. --emit-attestation produit un statement de ce type, où l'artefact est votre report.json et le type de claim est perf-sentinel-disclosure/v1. Cosign signe le statement, pas le rapport directement. Les vérifieurs peuvent ainsi chaîner la confiance depuis le hash du rapport, via le statement in-toto puis la signature cosign sur ce statement, jusqu'au certificat Fulcio qui lie la signature à une identité OIDC. Spec.
  • Bundle (bundle.sig) est le fichier que cosign écrit au moment de la signature. Il rassemble la signature, le certificat Fulcio et la preuve d'inclusion Rekor dans un seul JSON. Publier ce bundle à côté du rapport permet une vérification totalement hors ligne : un consommateur valide la signature contre la clé publique Rekor sans avoir à réinterroger Rekor en direct.
  • Provenance SLSA est un cadre OpenSSF séparé (Supply-chain Levels for Software Artifacts) qui décrit comment un artefact a été construit (quel commit source, quel builder, quel workflow). Les binaires de release perf-sentinel portent une provenance SLSA (Build niveau 2) produite pendant le workflow GitHub Actions de release. Le champ integrity.binary_attestation de la divulgation est rempli avec cette provenance quand elle est présente, et report_metadata.integrity_level passe de signed à signed-with-attestation. Spec.

La même introduction est mise en miroir dans Chaîne d’appro., qui est l'emplacement canonique pour la chaîne d'approvisionnement logicielle couvrant binaire, chart Helm et signature de divulgation.

Workflow

Les divulgations intent = "official" devraient être signées via Sigstore pour qu'un consommateur puisse vérifier que le fichier a été publié par votre organisation et n'a pas été modifié. Le pipeline s'active explicitement : passer --emit-attestation <path> à disclose pour obtenir un statement in-toto v1 sidecar, puis signer ce statement avec cosign.

bash
# 1. Produire le rapport et l'attestation in-toto.
perf-sentinel disclose \
    --intent official \
    --confidentiality public \
    --period-type calendar-quarter \
    --from 2026-01-01 --to 2026-03-31 \
    --input archive/2026Q1/*.ndjson \
    --output report.json \
    --emit-attestation attestation.intoto.jsonl \
    --org-config org.toml

# 2. Signer l'attestation avec cosign contre Sigstore public. Le
#    fichier produit à l'étape 1 est déjà un Statement in-toto v1
#    complet, donc on le signe directement avec `cosign sign-blob`.
#    L'issuer OIDC (flux navigateur ou token GitHub Actions)
#    enregistre l'identité signataire. Le bundle inclut la preuve
#    d'inclusion Rekor.
#    Ne PAS utiliser `cosign attest-blob --predicate attestation.intoto.jsonl` :
#    cette commande traite son entrée comme un predicate brut et
#    l'enveloppe dans un nouveau Statement, produisant une entrée
#    permanente doublement enveloppée dans le journal Rekor public.
cosign sign-blob \
    --bundle bundle.sig \
    --new-bundle-format \
    attestation.intoto.jsonl

# 3. Renseigner integrity.signature dans report.json pour que les
#    vérifieurs trouvent le bundle et l'entrée Rekor (voir
#    "Édition de integrity.signature" plus bas pour le schéma et
#    le helper jq). Puis passer report_metadata.integrity_level
#    de "hash-only" à "signed" (ou "signed-with-attestation" si le
#    binaire producteur porte une provenance SLSA). Une future
#    sous-commande `perf-sentinel sign` automatisera cette étape.
#    L'attestation désigne le content hash canonique du rapport, qui
#    laisse ces deux champs de côté, donc cette modification ne rompt
#    pas le lien entre la signature et les chiffres.

# 4. Publier report.json, attestation.intoto.jsonl, bundle.sig à
#    votre URL de transparence.

Édition de integrity.signature

Après que l'étape 2 réussit, report.json a toujours integrity.signature = null. Un consommateur qui lance verify-hash verrait "Signature: not provided" et traiterait le rapport comme PARTIAL. L'étape 3 remplit les champs de localisation pour que le consommateur trouve le bundle et le vérifie.

Les sept champs et la source de chaque valeur :

ChampOù lire la valeur
formatconstante "sigstore-cosign-intoto-v1" pour ce schéma
bundle_urlURL où vous publierez bundle.sig à l'étape 4
signer_identityle sujet OIDC du certificat de signature contenu dans bundle.sig (cosign sign-blob 3.0+ ne l'affiche pas toujours sur stdout). Lisible via `cosign verify-blob --certificate-identity-regexp '.*' ... 2>&1 \grep identity`
signer_issuermême source que signer_identity, l'URL OIDC issuer enregistrée à côté
rekor_urll'instance Rekor utilisée (https://rekor.sigstore.dev pour Sigstore public, ou la valeur de [reporting.sigstore] rekor_url pour une instance privée)
rekor_log_indexbundle.sig à .verificationMaterial.tlogEntries[0].logIndex (cosign sign-blob 3.0+ n'affiche plus tlog entry created with index: X). Ou la récupérer via curl <rekor_url>/api/v1/log/entries?logIndex=X pour confirmer
signed_athorodatage de l'entrée Rekor, ISO 8601 UTC

Exemple avant / après sur une divulgation fraîche :

json
// Avant l'étape 2 (état immédiatement après disclose --emit-attestation)
"integrity": {
  "content_hash": "sha256:abc123...",
  "binary_hash": "sha256:def456...",
  "binary_verification_url": "https://github.com/Institut-du-Numerique-Responsable/perf-sentinel/releases/tag/v0.7.0",
  "trace_integrity_chain": null,
  "signature": null,
  "binary_attestation": null
}
json
// Après l'étape 3 (cosign sign-blob a réussi et l'opérateur a collé les champs de localisation)
"integrity": {
  "content_hash": "sha256:abc123...",
  "binary_hash": "sha256:def456...",
  "binary_verification_url": "https://github.com/Institut-du-Numerique-Responsable/perf-sentinel/releases/tag/v0.7.0",
  "trace_integrity_chain": null,
  "signature": {
    "format": "sigstore-cosign-intoto-v1",
    "bundle_url": "https://transparency.example.fr/bundle.sig",
    "signer_identity": "robin.trassard@example.fr",
    "signer_issuer": "https://accounts.google.com",
    "rekor_url": "https://rekor.sigstore.dev",
    "rekor_log_index": 123456789,
    "signed_at": "2026-05-15T09:00:00Z"
  },
  "binary_attestation": null
}

Et dans report_metadata :

diff
-  "integrity_level": "hash-only"
+  "integrity_level": "signed"

(Utiliser "signed-with-attestation" au lieu de "signed" quand le binaire producteur porte aussi une provenance SLSA.)

Ce que le hash prouve, et où il s'arrête

Le content_hash, la signature cosign et la provenance SLSA lient le document publié. Depuis la 0.9.25, le daemon chaîne aussi par hachage chaque fenêtre archivée au moment où il l'écrit. disclose parcourt cette chaîne pendant l'agrégation et publie le résultat dans integrity.trace_integrity_chain : fenêtres vérifiées, fenêtres écrites avant l'existence du chaînage, ruptures trouvées dans la période et ruptures trouvées hors d'elle dans les mêmes fichiers. Les ruptures sont ainsi séparées parce qu'une archive glissante peut couvrir des années et qu'un rapport ne répond que de sa propre période. Le chaînage ferme l'écart où une fenêtre pouvait être éditée entre la mesure et la publication sans que rien ne le montre. Une rupture n'arrête pas le rapport. Elle est publiée comme un compteur, si bien qu'une archive tronquée donne encore une divulgation partielle.

Depuis la v1.7, chaque ligne d'archive porte aussi le compteur cumulatif de pertes du daemon. Le même bloc publie windows_dropped, les fenêtres que le daemon a produites mais n'a pas pu archiver sur la période. Il publie aussi drop_counter_resets, le nombre de fois où le compteur a reculé (une par redémarrage du daemon, chacune faisant du chiffre une borne basse sur l'intervalle qu'elle couvre). C'est le jumeau signé de la métrique perf_sentinel_archive_windows_dropped_total : le scrape voit les pertes en direct, le rapport en rend compte après coup. Cela ne dit rien des fenêtres jamais produites (un daemon à l'arrêt ne laisse aucun compteur à lire), ce que borne aggregate.temporal_coverage. Les deux champs sont omis sur les archives écrites avant l'existence du compteur, pour qu'une archive ancienne ne se lise jamais comme zéro perte.

Une modification que la chaîne ne voit pas est une coupe nette de la fin d'une archive : ce qui reste est une chaîne plus courte qui se vérifie selon ses propres termes, et aucun champ interne au fichier ne peut la contredire. La détecter demande une trace de la tête de chaîne conservée hors du contrôle de l'opérateur, ce à quoi integrity.cross_period_log reste réservé.

Rien de tout cela ne prouve la sincérité de la mesure à la source. Un opérateur qui contrôle le daemon et ses fichiers peut régénérer une chaîne cohérente, exactement comme il peut choisir un coefficient défavorable. Ces paramètres sont désormais publiés eux aussi, voir carbon_methodologies, scoring_coefficients et les champs du carbone embarqué dans methodology.calibration_inputs, ce qui les rend contestables plutôt qu'invisibles. La garantie permet de détecter une altération après coup, mais reste auto-déclarée à la source.

Le content hash reste valide

Le content_hash n'a pas besoin d'être recalculé après l'étape 3. La forme canonique utilisée par compute_content_hash vide quatre champs avant le hachage : integrity.content_hash, integrity.signature, integrity.binary_attestation, et report_metadata.integrity_level. La liste vit dans POST_SIGN_FIELDS (crates/sentinel-core/src/report/periodic/hasher.rs) et l'invariance est garantie par le test hash_is_invariant_under_post_sign_locator_addition. Donc un consommateur qui recalcule le hash sur le rapport post-étape-3 obtient la même valeur que l'opérateur à l'étape 1.

Ne pas recalculer content_hash après modification. Le faire produit un hash frais, casse la forme canonique, et un vérifieur verra un hash qui ne correspond pas.

Helper jq

Avertissement. L'extrait ci-dessous est indicatif, pas canonique. Il diverge du schéma de Édition de integrity.signature sur quatre champs :

  • signer_identity : analyse Successfully signed by ... dans la stdout cosign, qui est la formulation émise par cosign sign pour les images conteneur mais pas toujours par cosign sign-blob (3.0+ l'omet). La valeur canonique est le sujet OIDC embarqué dans le certificat de signature (lire via cosign verify-blob --certificate-identity-regexp '.*', ou inspecter bundle.sig directement avec jq -r '.verificationMaterial.certificate.rawBytes' bundle.sig | base64 -d | openssl x509 -inform DER -noout -ext subjectAltName).
  • signer_issuer : codé en dur à https://accounts.google.com. La valeur canonique est l'URL de l'issuer OIDC inscrite dans le certificat de signature, à aligner sur votre fournisseur (Google, GitHub Actions, OIDC personnalisé).
  • rekor_log_index : analyse tlog entry created with index dans la stdout cosign, que cosign sign-blob 3.0+ n'émet plus. LOG_INDEX est donc vide et l'extrait se rabat sur rekor_log_index: 0. La valeur canonique est dans bundle.sig lui-même à .verificationMaterial.tlogEntries[0].logIndex, sans appel API.
  • signed_at : rempli avec l'horloge locale au moment de l'exécution jq. La valeur canonique est l'integratedTime de l'entrée Rekor, obtenue via https://rekor.sigstore.dev/api/v1/log/entries?logIndex=<idx> et formatée en ISO 8601 UTC.

Pour une publication destinée à un audit tiers, privilégiez la lecture de ces quatre champs depuis le bundle et depuis l'entrée Rekor plutôt que depuis l'état local. Le helper reste utile pour un essai à blanc interne où les quatre valeurs sont inspectées pour leur plausibilité, pas leur provenance.

La démarche est répétitive et facile à scripter. En attendant que perf-sentinel sign arrive, ce workflow jq capture les champs depuis la sortie cosign et met à jour le rapport en une passe :

bash
# Signer et capturer la sortie cosign pour l'analyser
cosign sign-blob \
    --bundle bundle.sig \
    --new-bundle-format \
    attestation.intoto.jsonl 2>&1 | tee cosign.log

# Extraire l'index tlog depuis la sortie cosign. Format :
# "tlog entry created with index: 123456789"
LOG_INDEX=$(grep "tlog entry created with index" cosign.log \
            | awk '{print $NF}')

# Extraire l'identité signataire depuis le log cosign. Format
# dépend de l'issuer : email pour OIDC Google, URL de workflow
# pour GitHub Actions.
SIGNER=$(grep "Successfully signed" cosign.log \
         | sed 's/.*by //' | tr -d '"')

# Choisir l'issuer qui correspond à votre fournisseur OIDC.
ISSUER="https://accounts.google.com"  # ou token.actions.githubusercontent.com

# Renseigner les sept champs de localisation dans report.json et
# relever integrity_level. Ajuster bundle_url à votre hôte de
# transparence.
jq --arg url "https://transparency.example.fr/bundle.sig" \
   --arg sig "$SIGNER" \
   --arg issuer "$ISSUER" \
   --arg idx "$LOG_INDEX" \
   --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
   '.integrity.signature = {
     format: "sigstore-cosign-intoto-v1",
     bundle_url: $url,
     signer_identity: $sig,
     signer_issuer: $issuer,
     rekor_url: "https://rekor.sigstore.dev",
     rekor_log_index: ($idx | tonumber),
     signed_at: $ts
   } | .report_metadata.integrity_level = "signed"' \
   report.json > report-signed.json && mv report-signed.json report.json

Les opérateurs qui font tourner une instance Rekor privée fixent [reporting.sigstore] rekor_url = "..." dans leur config perf-sentinel et passent la même URL à cosign --rekor-url. verify-hash refuse les bundles signés avec cosign sign-blob --no-tlog-upload, parce que de tels bundles n'ont pas de preuve d'inclusion Rekor. Toujours signer sans ce flag pour les rapports destinés à la transparence publique.

verify-hash lit lui-même integrity.signature.rekor_url dans le rapport vérifié, donc un consommateur qui télécharge une divulgation publique n'a besoin d'aucune config locale : l'URL voyage avec le rapport. Pour forcer un Rekor différent au moment de la vérification (par exemple pour une vérification croisée d'une revendication Rekor public contre une archive privée), invoquer cosign directement avec son propre flag --rekor-url plutôt que via verify-hash. Le rapport reste la source de vérité unique pour le journal de transparence qui l'a signé.

Voir 10 · Sigstore & SLSA pour la méthodologie complète, les modes d'échec, et les considérations de confidentialité sur Rekor public.

Rendre un PDF pour les lecteurs (disclose-pdf)

perf-sentinel disclose-pdf rend un rapport en PDF/A-3b qu'un lecteur sans terminal peut suivre : des pages A4 en anglais, cinq sauf quand une section déborde sur la suivante, avec les chiffres, la bande d'incertitude, les applications, la méthode et ses avertissements, et la façon de vérifier le document. Suit une annexe qui imprime chaque champ que le schéma actuel définit, dans son ordre et tel que le report.json joint le porte, avec not reported pour chaque champ que le rapport ne porte pas ou porte vide : ce qu'un rapport laisse de côté se voit au lieu de disparaître. Le rapport, son attestation et son bundle voyagent dans le PDF en pièces jointes, octet pour octet.

bash
perf-sentinel disclose-pdf \
    --report report.json \
    --attestation attestation.intoto.jsonl \
    --bundle bundle.sig \
    --expected-identity release@example.fr \
    --expected-issuer https://accounts.google.com \
    --output report.pdf

Il ne rend que si le content_hash se recalcule et, avec --attestation, si l'attestation nomme ce hash : un PDF rendu vaut donc ces deux contrôles. Il ne vérifie jamais la signature : la page de vérification imprime la commande verify-hash, avec l'identité et l'émetteur passés ici, que le lecteur lance lui-même. Le code du document est le content_hash du rapport, imprimé en entier et groupé par quatre caractères sur la couverture et la page de vérification, et abrégé dans le pied de chaque page. Ajoutez transparency à [notes.reference_urls] dans l'org config et la couverture dessine un QR code vers la page où vous publiez vos rapports. Sans cette clé, aucun QR code n'est dessiné. Comme l'annexe imprime chaque champ, un caractère absent des polices embarquées, où qu'il soit dans le rapport, empêche son rendu, et plus seulement sur les cinq premières pages.

La commande imprime une ligne JSON sur stdout et sort avec un code fixe, un contrat sur lequel s'appuie PerfSentinelHub :

Sortieresultreason
0renderedaucune
1refusedcontent_hash_mismatch, binding_mismatch
3input_errormissing_file, file_too_large, malformed_report, output_unwritable
5unrenderableunsupported_schema_version, unsupported_characters, pdf_export_failed

file_too_large signifie que le rapport dépasse 64 Mio, ou l'attestation ou le bundle 1 Mio.

La sortie 5 signifie que rien ne dit que le rapport est faux, le moteur ne sait simplement pas le dessiner : un schéma plus récent que ce binaire, du texte hors des polices embarquées DM Sans et JetBrains Mono (écritures latines, sans emoji) listé sur stderr, ou un document que l'écriture PDF refuse. Les retours à la ligne et tabulations des chaînes du rapport sont dessinés comme des espaces. Les caractères ne sont jamais remplacés sur un document officiel.

La commande demande la feature Cargo pdf, active dans le binaire publié et dans l'image.

Publier sur un Perf Sentinel Hub

Un Hub dont les divulgations sont actives (PerfSentinelHub 0.7.0 ou plus récent, Hub:Disclosures:ImportKey renseigné) garde une divulgation, rend son PDF avec le moteur qu'il embarque, et la transmet par un écran restreint, des liens de partage qui expirent et des webhooks signés. La dernière étape du job qui produit et signe le rapport envoie les trois fichiers en une seule requête multipart :

bash
curl --fail -H "X-API-Key: $HUB_DISCLOSURES_KEY" \
  -F report=@report.json \
  -F attestation=@attestation.intoto.jsonl \
  -F bundle=@bundle.sig \
  https://hub.example.com/api/import/disclosures

Le Hub lance disclose-pdf avant de garder quoi que ce soit. Un rapport dont le hash ne se recalcule pas, ou dont l'attestation nomme un autre hash, est refusé en 422 avec la raison du moteur. Un rapport non signé est accepté et affiché comme tel, et publier plus tard la copie signée du même rapport la remplace. Le Hub ne vérifie jamais de signature : qui reçoit le PDF lance verify-hash dessus. Voir le API-FR.md du Hub pour les réponses.

Vérifier un rapport publié

Un tiers vérifie un fichier publié en une commande :

bash
# Mode local : les trois fichiers sont déjà téléchargés.
perf-sentinel verify-hash \
    --report report.json \
    --attestation attestation.intoto.jsonl \
    --bundle bundle.sig \
    --expected-identity release@example.fr \
    --expected-issuer https://accounts.google.com

# Mode distant : récupère le rapport et les sidecars par convention HTTPS.
perf-sentinel verify-hash \
    --url https://example.fr/perf-sentinel-report.json \
    --expected-identity release@example.fr \
    --expected-issuer https://accounts.google.com

Les deux exemples passent --expected-identity et --expected-issuer parce que c'est le défaut sûr : sans ces flags, verify-hash refuse d'invoquer cosign et retourne Status::Fail sur le slot signature. Voir Vérification d'identité plus bas pour les trois modes et leur sémantique. Réservez --no-identity-check à une auto-vérification interne avant publication.

verify-hash chaîne quatre vérifications : recalcul déterministe du content hash (Rust pur, toujours lancé), l'attestation qui désigne ce content hash (Rust pur, lancée quand une attestation est fournie), signature Sigstore sur l'attestation (cosign verify-blob), et provenance SLSA du binaire (résumé des métadonnées plus une commande gh attestation verify qui pointe vers le binaire dans integrity.binary_verification_url). La signature porte sur l'attestation, pas sur le rapport : c'est la deuxième vérification qui relie une signature valide aux chiffres en main. Une attestation qui désigne un autre content hash, typiquement après des chiffres modifiés et un hash recalculé, rend le verdict UNTRUSTED. Une attestation écrite par perf-sentinel 0.27.0 ou antérieur fixait plutôt les octets du fichier rapport, et ne reste liée que tant que ces octets n'ont pas bougé. Une fois integrity.signature renseigné, elle plafonne le verdict à PARTIAL.

Codes de sortie :

CodeSignification
0TRUSTED (content hash correspondant, l'attestation le désigne, la signature sur l'attestation vérifiée ok, et l'attestation binaire pas laissée non vérifiée quand le rapport porte des métadonnées d'attestation)
1UNTRUSTED (une vérification a retourné un échec dur : hash non concordant, attestation désignant d'autres chiffres, signature invalide, attestation invalide, identité non-conforme)
2PARTIAL (pas d'échec dur mais au moins une vérification n'a pas pu aboutir : cosign absent, gh CLI absent, métadonnée de signature absente, sidecars manquants)
3INPUT_ERROR (fichier rapport illisible, JSON invalide, ou --report / --url manquant)
4NETWORK_ERROR (mode --url uniquement : récupération HTTP échouée, schéma refusé, corps de réponse au-delà de la limite de taille)

Un gate scripté verify-hash && deploy bloque sur tout code non-zéro et rejette donc PARTIAL aussi. Un script d'encapsulation qui distingue PARTIAL (2) de UNTRUSTED (1) peut différencier un outil manquant d'une tentative de falsification.

Quand un rapport porte des métadonnées d'attestation binaire, verify-hash n'ignore pas ce bloc en silence : sans --verify-binary <path> l'attestation reste non vérifiée et le résultat plafonne à PARTIAL au lieu de TRUSTED. Passez --verify-binary <path> pour lancer gh attestation verify sur le binaire producteur (nécessite le CLI gh et le réseau), afin que la provenance soit contrôlée et que le code 0 le reflète. Le fichier doit d'abord avoir pour empreinte le integrity.binary_hash du rapport, que couvre le hash de contenu : une différence, ou un rapport sans ce champ, fait échouer le contrôle avant que gh ne tourne, pour que la provenance d'une autre release attestée ne soit pas créditée à ce rapport. Le bloc binary_attestation est hors du hash de contenu, son git_commit n'est donc qu'un second contrôle par-dessus.

Vérifier un PDF

verify-hash --report report.pdf lit le rapport, l'attestation et le bundle dans le PDF et lance les mêmes contrôles : un destinataire qui n'a que le PDF peut le vérifier. Une copie réenregistrée par un outil qui garde les pièces jointes se vérifie encore, car la lecture suit la structure du PDF et non la mise en page écrite par le rendu. Certaines visionneuses perdent les pièces jointes en enregistrant, dont Aperçu sur macOS : vérifiez le fichier d'origine. Le contrôle porte sur le rapport joint, pas sur les pages imprimées : verify-hash le dit, et le lecteur compare les chiffres imprimés à ce rapport. --attestation et --bundle passés en ligne de commande l'emportent sur les fichiers joints, ici comme en mode --url. verify-hash --url avec l'adresse d'un PDF fait de même, sans chercher de sidecar. Un rapport dont le schéma est plus récent que le binaire sort en 5 : mettez perf-sentinel à jour.

Convention URL des sidecars en mode --url

verify-hash --url <REPORT_URL> récupère trois fichiers depuis le même répertoire, avec des noms fixes :

https://example.fr/<report-filename>            (le rapport)
https://example.fr/attestation.intoto.jsonl     (sidecar statement in-toto)
https://example.fr/bundle.sig                   (sidecar bundle cosign)

Les noms de sidecars ne sont pas dérivés du nom de fichier du rapport : ils sont littéralement attestation.intoto.jsonl et bundle.sig. Un opérateur qui publie un rapport doit utiliser ces noms exacts sous le même préfixe d'URL pour que verify-hash --url les trouve automatiquement. Une révision future pourrait exposer les URLs dans integrity.signature.bundle_url pour rendre la convention explicite par rapport, mais ce n'est pas le comportement actuel.

Vérification d'identité

verify-hash exige du consommateur qu'il déclare quelle identité aurait dû signer le rapport. Trois modes :

  • --expected-identity <ID> --expected-issuer <URL> : cosign vérifie que le bundle a été émis par exactement cette identité OIDC. Les valeurs viennent de la connaissance préalable qu'a l'auditeur de l'organisation publiante. Le rapport déclare ces valeurs dans integrity.signature.signer_identity / .signer_issuer, mais traiter ces déclarations comme faisant autorité serait de l'autosignature : n'importe quel détenteur d'un compte GitHub ou Google peut publier un bundle revendiquant une identité.
  • --no-identity-check : cosign vérifie l'intégrité cryptographique sans vérifier l'identité. Utile pour une auto-vérification interne avant publication, mais journalisé comme PARTIAL parce que le signataire n'est pas vérifié.
  • Aucun flag passé : verify-hash refuse d'invoquer cosign et retourne Status::Fail sur le slot signature. C'est le défaut sûr et force un consommateur externe à déclarer son intention.

Provenance build du binaire

integrity.binary_hash est le SHA-256 du binaire Perf Sentinel qui a produit le rapport. Pour une divulgation officielle, la valeur devrait correspondre à un binaire de release officiel publié sur les GitHub releases du projet. Les opérateurs qui compilent perf-sentinel depuis les sources peuvent quand même produire des rapports officiels, mais leur binary_hash ne correspondra à aucune release publiée. Dans ce cas integrity.binary_attestation est absent (pas de provenance SLSA pour un build local) et verify-hash affiche [--] Binary attestation: not provided. L'integrity_level est signed, pas signed-with-attestation. Pour un maximum de confiance sur une publication, utiliser le binaire de release qui correspond au tag déclaré dans integrity.binary_verification_url.

Calculer un content hash canonique avec hash-bake (0.7.2+)

Pour les fixtures de test et les workflows de débogage où vous avez besoin d'un rapport dont le content_hash correspond déjà à ce que Perf Sentinel produirait, utilisez hash-bake :

bash
perf-sentinel hash-bake --report input.json --output output.json

hash-bake lit le rapport à --report, calcule le content_hash canonique (en appliquant le vidage des champs POST_SIGN_FIELDS défini pour la version du schéma), écrit le hash dans integrity.content_hash, et sauvegarde le résultat à --output. Le même chemin que --report est accepté pour une écriture en place, avec un temp+rename atomique qui évite toute corruption partielle.

Cette commande est destinée à :

  • Générer des fixtures de test avec un hash canonique valide (par exemple pour des suites qui exercent verify-hash en sortie TRUSTED ou PARTIAL).
  • Déboguer un rapport dont le hash a divergé du canonique (typiquement après des modifications manuelles sur des champs hors POST_SIGN_FIELDS).

Les rapports signés (integrity.signature non-null) sont rejetés par défaut. Relancer hash-bake n'invalide pas la signature, puisque la forme canonique vide la signature de toute façon, mais l'opérateur doit confirmer l'intention via --allow-signed.

hash-bake ne modifie ni integrity.signature, ni integrity.binary_attestation, ni report_metadata.integrity_level. Il n'écrit que integrity.content_hash.

Codes de sortie :

CodeSignification
0Hash canonique calculé, fichier écrit.
1Refusé : le rapport porte une signature et --allow-signed n'a pas été passé. Aucun fichier de sortie écrit.
3Erreur d'entrée : rapport illisible, JSON invalide, ou écriture impossible.

Erreurs courantes

  • Error: audited intent is reserved for a future release, use 'internal' or 'official' instead : basculer --intent sur internal ou official.
  • no archived reports fell within the requested period : l'archive contient des lignes mais aucune ne correspond à la fenêtre --from/--to. Vérifier les horodatages, en particulier autour des changements DST et des frontières de fuseau (l'agrégateur filtre sur les dates UTC).
  • Error: report validation failed suivi d'une liste à puces : chaque ligne nomme le champ fautif. Corriger dans le TOML org-config ou dans l'archive source.
  • strict_attribution activé et une fenêtre sans offenders : retirer le flag ou corriger l'instrumentation par service qui masque les offenders.

Portée et limites

Le rapport est une estimation directionnelle avec un intervalle d'incertitude multiplicatif 2x. Il n'est pas de niveau réglementaire et ne convient pas à un reporting CSRD ou GHG Protocol Scope 3. Voir Méthodologie pour la chaîne de calcul complète et les sources de calibration qui peuvent resserrer l'intervalle (Scaphandre RAPL, cloud SPECpower, Electricity Maps).