From 729c8ba879e0ea6e5f6436d64a3f0bb5f80b41ab Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Thu, 30 Jul 2026 12:03:40 +0200 Subject: [PATCH 1/6] ci(deps): les douze actions tournaient sur des tags mobiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un tag d'action GitHub n'est pas une version, c'est un pointeur : son proprietaire le deplace quand il veut, et une action deplacee s'execute dans nos runners avec ce que porte son depot a cet instant-la. Le depot part en public, et c'est le chemin d'attaque le moins couteux qui existe contre une chaine de construction : personne n'a besoin d'un acces a ce depot-ci pour l'emprunter. softprops/action-gh-release est le cas qui compte. C'est une action TIERCE, et elle tourne dans le seul job du depot qui recoive contents: write et le github.token -- celui qui publie la page Releases dont les postes tirent ensuite leur mise a jour. Un v2 deplace publie sous le nom de la cooperative des octets que internal/update installera en LocalSystem, et le condensat ne dirait rien : SHA256SUMS-archives.txt est produit par le meme workflow. Les quatre actions sont donc epinglees sur un SHA de commit, qui designe des octets qui ne changent plus. Le tag lisible reste en commentaire au bout de la ligne : il est la pour l'humain qui relit, et c'est la seule chose que le SHA ne dit pas. Les majeures ne bougent pas -- setup-node reste en v6 et action-gh-release en v2 -- parce qu'epingler et monter de version sont deux gestes, et qu'un seul etait demande. dependabot.yml arrive DANS LE MEME COMMIT, et ce n'est pas un ajout de confort. Epingler protege la chaine et fige aussi les correctifs des actions : sans une entree github-actions qui fait monter le SHA et son commentaire ensemble, l'epinglage devient une dette que personne ne relit. Les deux tiennent ensemble ou aucun des deux ne tient. La cadence est mensuelle et les demandes sont groupees par ecosysteme. Une cooperative n'a pas de mainteneur a temps plein : une demande par dependance et par semaine, c'est un depot ou l'on cesse de regarder les demandes, donc un depot ou la mise a jour de securite passe inapercue avec les autres. L'entree npm porte un avertissement pour qui relira ces demandes. internal/web/dist est commite et le job front compare ses OCTETS au resultat de npm ci && npm run build : une montee de vite ou de svelte change les empreintes des noms de fichiers, donc l'etape « internal/web/dist est a jour » devient rouge sur une demande par ailleurs correcte. La reponse est celle que le workflow ecrit deja -- lancer make front et committer le dist dans la demande -- jamais retirer la garde, qui a rattrape deux livraisons d'un ecran perime. --- .github/dependabot.yml | 65 +++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 28 ++++++++++----- .github/workflows/release.yml | 14 +++++--- 3 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ec921a5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,65 @@ +# Surveillance des dépendances — docs/02-architecture.md §17.1. +# +# CE FICHIER EXISTE POUR TROIS ÉCOSYSTÈMES, et le troisième est le moins évident : il +# maintient l'épinglage par SHA des actions de `ci.yml` et de `release.yml`. Épingler une +# action sur un commit protège la chaîne de construction, mais fige aussi ses correctifs ; +# sans cette entrée, l'épinglage devient une dette que personne ne relit. Les deux vont +# ensemble ou aucun des deux ne tient. +# +# La cadence est MENSUELLE et les mises à jour sont GROUPÉES en une seule demande par +# écosystème. Une coopérative n'a pas de mainteneur à temps plein : une demande par +# dépendance et par semaine, c'est un dépôt où l'on cesse de regarder les demandes — donc +# un dépôt où la mise à jour de sécurité passe inaperçue avec les autres. +version: 2 +updates: + # --- Go --------------------------------------------------------------------------- + # Six modules directs (§17.1). `modernc.org/sqlite` est celui qui compte : c'est lui qui + # rend le zéro cgo possible, et une montée qui réintroduirait une dépendance C serait + # refusée par le job `build` de la CI avant toute relecture. + - package-ecosystem: gomod + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 3 + groups: + go: + patterns: ["*"] + commit-message: + prefix: build(deps) + + # --- Les actions GitHub ----------------------------------------------------------- + # Ce qui fait vivre l'épinglage par SHA. Dependabot remplace le commit ET le commentaire + # de version au bout de la ligne, donc les deux ne divergent pas. + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + actions: + patterns: ["*"] + commit-message: + prefix: ci(deps) + + # --- L'écran client --------------------------------------------------------------- + # ATTENTION EN RELISANT CES DEMANDES : `internal/web/dist` est commité, et le job `front` + # de la CI compare ses OCTETS au résultat de `npm ci && npm run build`. Une montée de + # vite, de rollup ou de svelte change les empreintes des noms de fichiers, donc l'étape + # « internal/web/dist est à jour » devient rouge sur une demande par ailleurs correcte. + # + # La réponse est celle que le workflow écrit déjà : lancer `make front` et committer + # `internal/web/dist` dans la demande. Jamais retirer la garde — c'est elle qui a + # rattrapé deux livraisons d'un écran périmé. + # + # Tout est en devDependencies : rien de ce bloc ne part sur un poste, seul le bundle + # construit voyage. + - package-ecosystem: npm + directory: /web + schedule: + interval: monthly + open-pull-requests-limit: 3 + versioning-strategy: increase + groups: + front: + patterns: ["*"] + commit-message: + prefix: build(front) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5fea5a..b1927d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,14 +35,26 @@ env: # la chaîne d'outils. GO_VERSION: "1.26.5" +# LES ACTIONS SONT ÉPINGLÉES SUR UN SHA DE COMMIT, jamais sur un tag. +# +# `actions/checkout@v7` est un tag MOBILE : son propriétaire le déplace quand il veut, et +# une action déplacée s'exécute dans nos runners avec ce que porte le dépôt à cet +# instant-là. Un tag n'est donc pas une version, c'est un pointeur — et sur un dépôt +# public, c'est le chemin d'attaque le moins coûteux qui existe contre une chaîne de +# construction. Un SHA de commit, lui, désigne des octets qui ne changent plus. +# +# Le tag lisible reste en commentaire au bout de la ligne : il est là pour l'humain qui +# relit, et c'est la seule chose que le SHA ne dit pas. `dependabot.yml` surveille +# l'écosystème `github-actions` et fait monter les deux ensemble, si bien qu'épingler +# n'immobilise rien. jobs: test: name: Tests et frontières runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ env.GO_VERSION }} check-latest: false @@ -137,9 +149,9 @@ jobs: name: Scripts d'installation, sous Windows PowerShell 5.1 runs-on: windows-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ env.GO_VERSION }} check-latest: false @@ -162,9 +174,9 @@ jobs: - { goos: linux, goarch: amd64 } - { goos: linux, goarch: arm64 } steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ env.GO_VERSION }} check-latest: false @@ -184,9 +196,9 @@ jobs: name: Écran client — types, tests et budget runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: "22" cache: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c9dfb3..abd0200 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,12 @@ on: permissions: # Écrire est ce qui permet de créer la Release et d'y attacher les archives. C'est la # SEULE permission élevée de ce dépôt, et elle ne vaut que pour ce workflow. + # + # C'EST AUSSI POURQUOI LES ACTIONS D'ICI SONT ÉPINGLÉES SUR UN SHA DE COMMIT (voir la + # note de ci.yml). `softprops/action-gh-release` est une action TIERCE, et elle tourne + # dans le seul job du dépôt qui reçoive `contents: write` et le `github.token`. Sur un + # tag mobile, quiconque peut déplacer `v2` publie ce qu'il veut sous le nom de la + # coopérative — et ce que les postes installent ensuite vient de cette page-là. contents: write # Lire les exécutions de l'intégration continue, pour savoir si la révision du tag a # déjà été prouvée. Lecture seule, et c'est la seule chose qu'elle sert à faire. @@ -80,7 +86,7 @@ jobs: fi echo "tag validé : $TAG" - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.inputs.tag || github.ref }} # fetch-depth: 0 EST OBLIGATOIRE ICI, et son absence ne se voit pas tout de @@ -91,12 +97,12 @@ jobs: # que contenait la version publiée. fetch-depth: 0 - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ env.GO_VERSION }} check-latest: false - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: ${{ env.NODE_VERSION }} cache: npm @@ -200,7 +206,7 @@ jobs: echo "version publiée : $version" - name: Publier la Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: tag_name: ${{ github.event.inputs.tag || github.ref_name }} name: OpenScale ${{ github.event.inputs.tag || github.ref_name }} From 24e572ba9640fc248857bd169150d87b7d961bc7 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Thu, 30 Jul 2026 12:04:13 +0200 Subject: [PATCH 2/6] feat(web): aucune reponse ne portait d'en-tete de securite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatre en-tetes, poses par guard -- le middleware le plus externe -- avant toute decision et avant tout handler. Le refus est le cas qui decide de l'endroit : un 403 de l'Origine est une reponse comme une autre, et des en-tetes poses apres avoir tranche laisseraient nue la seule reponse qu'un attaquant obtient. frame-ancestors 'none' est la directive qui compte ici, et c'est la seule qui ferme un ACTE plutot qu'un chargement. Les gestes de depannage de §14.4 repondent sans mot de passe par decision (ADR-033), donc une page ouverte sur le poste pouvait encadrer /admin, le recouvrir, et faire cliquer « Rouleau change » ou un auto-test a un benevole qui croyait cliquer ailleurs. X-Frame-Options: DENY suit, redondant sur tout navigateur qu'un poste fait tourner, garde parce qu'il coute une ligne et couvre le navigateur que personne n'a choisi. nosniff est l'autre moitie d'un controle qui existait deja. La route des images refuse de servir un PNG sous un nom en .jpg (§10.7) -- dix des 181 photos reelles sont dans ce cas -- et un navigateur qui ignorerait le type declare pour renifler les octets defaisait ce controle depuis l'exterieur. script-src 'self' est verifiable sur les fichiers construits et l'a ete : les bundles ne contiennent ni eval, ni new Function, ni insertRule, et les deux pages d'entree ne portent aucun script en ligne. img-src 'self' sans data: est deliberé : le CSV porte ses images en base64, mais l'import les decode et les ecrit par adresse de contenu, donc un data: sur cet ecran signalerait un chemin qui a saute cette etape. style-src garde 'unsafe-inline', et c'est une decision et non un oubli. Le CSS en ligne n'execute rien, et le retirer couterait la page de secours d'un binaire construit sans front, qui porte sa mise en forme dans un attribut style. Une politique qui casse l'ecran le jour ou vite change sa facon d'emettre les styles est une politique qu'on supprime a 7 h du matin au lieu de la reparer. Pas de HSTS et pas d'upgrade-insecure-requests : le service parle HTTP en clair sur la boucle locale, et les deux epingleraient ou reecriraient une adresse qu'un benevole ne pourrait alors plus atteindre du tout. LA POLITIQUE A ETE VERIFIEE SUR LES DEUX ECRANS REELS, pas seulement en test. Poste de demonstration lance, catalogue depose, les deux ecrans ouverts dans un navigateur : zero message de console, grille et photos affichees, police chargee, flux SSE vivant. Le seul motif douteux a ete exerce nommement -- handToBrowser (Station.svelte) cree un blob: par createObjectURL pour l'export de configuration, ce qu'aucune directive CSP ne couvre : le telechargement passe, sans violation. Deux tests gardent le reste, et le second refuse qu'on elargisse script-src : c'est la ligne qu'on relache pour faire remarcher un ecran. --- internal/web/guard.go | 79 ++++++++++++++++++++++++++ internal/web/guard_test.go | 110 +++++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 internal/web/guard_test.go diff --git a/internal/web/guard.go b/internal/web/guard.go index 9d981f3..1e3b580 100644 --- a/internal/web/guard.go +++ b/internal/web/guard.go @@ -19,6 +19,7 @@ import ( // network.admin_on_lan, a real setting that would otherwise be dead. func (s *Server) guard(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + setSecurityHeaders(w) if strings.HasPrefix(r.URL.Path, "/admin") && !s.adminReachable(r) { writeProblem(w, http.StatusForbidden, "", "L'écran d'administration n'est ouvert que sur ce poste (network.admin_on_lan).") @@ -34,6 +35,84 @@ func (s *Server) guard(next http.Handler) http.Handler { }) } +// contentSecurityPolicy is what the station's own pages are allowed to load. +// +// It is written for a front end that is ENTIRELY SELF-CONTAINED: no CDN, no remote font, no +// analytics. That was already a property of §14.1 — the client screen has a weight budget +// measured in CI, and a network the shop cannot reach at 6 a.m. is not where its assets +// live. This header is that property, stated to the browser instead of merely being true. +// +// Directive by directive, and the ones that carry the weight first: +// +// - `script-src 'self'` — the two entry points load one module each from /assets, and +// the bundles contain no `eval` and no `new Function` (checked on the built files). +// - `frame-ancestors 'none'` — nobody frames this station. The repair buttons of §14.4 +// answer without a password ON PURPOSE (ADR-033), so a page open on the poste could +// otherwise frame /admin, cover it, and have a volunteer click « Rouleau changé » or +// an auto-test they never meant to run. This is the one directive that closes an act +// rather than a load. +// - `object-src 'none'`, `base-uri 'none'` — no plugin, and no injected to move +// every relative address of the page somewhere else. +// - `form-action 'none'` — there is not one
in web/src. The export of the journal +// and of the configuration are anchors and a Blob, never a submit. +// - `img-src 'self'` — the product photos come from /images/. and nowhere +// else. `data:` is deliberately ABSENT: the CSV carries its images in base64, but the +// import decodes them and writes them by content address (§10.7), so a data: URI on +// this screen would mean a path that skipped that. +// - `connect-src 'self'` — the SSE stream and every fetch of the administration. +// +// `style-src` keeps `'unsafe-inline'`, and that is a decision rather than an oversight. It +// buys almost nothing to remove — inline CSS executes no script — and it costs the +// placeholder page of a binary built without a front end, which carries its layout in a +// style attribute. A policy that breaks the screen on the day vite changes how it emits +// styles is a policy somebody deletes at 7 a.m. rather than repairs. +// +// There is NO `upgrade-insecure-requests`: the station serves plain HTTP on the loopback, +// and that directive would rewrite its own addresses into https and reach nothing. +const contentSecurityPolicy = "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self'; " + + "font-src 'self'; " + + "connect-src 'self'; " + + "object-src 'none'; " + + "base-uri 'none'; " + + "form-action 'none'; " + + "frame-ancestors 'none'" + +// setSecurityHeaders posts the four headers every answer of this layer carries. +// +// They are set on the OUTERMOST middleware, before any handler and before any refusal, so +// that there is one place to read and no route that can forget them — a 403 from the guard +// is as much a page as an index. +// +// Nothing here replaces the checks below. The Origin and Host rules are what actually stop +// a cross-site write; these four narrow what a browser will do with an answer, which is a +// different job and a cheaper one. +// +// # What each one is for, on THIS machine +// +// `nosniff` — the images route already refuses to serve a PNG under a .jpg name (§10.7), +// and this is the other half: a browser that ignored the declared type and sniffed the +// bytes would undo that check from the outside. +// +// `X-Frame-Options: DENY` — redundant with `frame-ancestors` on every browser a station +// runs, and kept because it costs one line and covers the browser nobody chose. +// +// `Referrer-Policy: no-referrer` — a station has no third party to leak a path to, and the +// administration addresses name nothing worth sending anywhere. There is no case where a +// referrer from this service is useful, so there is no case for sending one. +// +// Not here: HSTS, which needs TLS this service does not serve, and would pin a name a +// volunteer then cannot reach over http. +func setSecurityHeaders(w http.ResponseWriter) { + header := w.Header() + header.Set("Content-Security-Policy", contentSecurityPolicy) + header.Set("X-Content-Type-Options", "nosniff") + header.Set("X-Frame-Options", "DENY") + header.Set("Referrer-Policy", "no-referrer") +} + // adminReachable reports whether the administration surface answers this caller. // // With admin_on_lan false — the shipped value — it answers the loopback and nothing diff --git a/internal/web/guard_test.go b/internal/web/guard_test.go new file mode 100644 index 0000000..c427f10 --- /dev/null +++ b/internal/web/guard_test.go @@ -0,0 +1,110 @@ +package web + +import ( + "net/http" + "strings" + "testing" +) + +// securityHeaders is what every answer of this layer must carry, and the value it must +// carry it under. Spelled out rather than read back from the code: a test that asserts +// `header == constant` proves that the constant is used, never that it says the right +// thing. +var securityHeaders = map[string]string{ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", +} + +// TestEveryAnswerCarriesTheSecurityHeaders, refusals included. +// +// The refusal is the case worth writing. The headers are posted by the OUTERMOST +// middleware, before the Origin check can answer 403 — so a guard that set them after +// deciding would leave the one answer an attacker gets bare. +func TestEveryAnswerCarriesTheSecurityHeaders(t *testing.T) { + b := newBench(t) + + // A document, a JSON payload, a liveness probe, and the front end's own assets: four + // different writers, one middleware. + for _, path := range []string{"/", "/admin", "/healthz", "/api/v1/catalog"} { + response := b.get(path) + response.Body.Close() + for name, want := range securityHeaders { + if got := response.Header.Get(name); got != want { + t.Errorf("GET %s : %s = %q, attendu %q", path, name, got, want) + } + } + if response.Header.Get("Content-Security-Policy") == "" { + t.Errorf("GET %s : aucune Content-Security-Policy", path) + } + } + + // And on a refusal of the guard itself. + refused := b.do(http.MethodPost, "/api/v1/dismiss", `{}`, + http.Header{"Origin": {"http://ailleurs.example"}}) + refused.Body.Close() + if refused.StatusCode != http.StatusForbidden { + t.Fatalf("la requête d'une autre origine a répondu %d, attendu 403", refused.StatusCode) + } + for name, want := range securityHeaders { + if got := refused.Header.Get(name); got != want { + t.Errorf("sur le refus 403 : %s = %q, attendu %q", name, got, want) + } + } + if refused.Header.Get("Content-Security-Policy") == "" { + t.Error("le refus 403 part sans Content-Security-Policy") + } +} + +// TestTheContentSecurityPolicyKeepsWhatItIsFor. +// +// Three directives are checked by NAME because each one closes something this station +// really has, and because a policy is the kind of string somebody widens to make a screen +// work again. `script-src` is the one that must never gain `'unsafe-inline'`: the front end +// is built by vite into module files under /assets, so an inline script on these pages +// would be a script nobody wrote. +func TestTheContentSecurityPolicyKeepsWhatItIsFor(t *testing.T) { + b := newBench(t) + response := b.get("/admin") + response.Body.Close() + policy := response.Header.Get("Content-Security-Policy") + + for _, directive := range []string{ + "script-src 'self'", + // Nobody frames a station: the repair buttons of §14.4 answer without a password, + // and a framed /admin is how they get clicked by somebody else's page. + "frame-ancestors 'none'", + "object-src 'none'", + "base-uri 'none'", + } { + if !strings.Contains(policy, directive) { + t.Errorf("la politique ne porte pas %q : %s", directive, policy) + } + } + + // The relaxations that must not appear. `style-src` carries 'unsafe-inline' on purpose + // and the placeholder page needs it, so the check is on the SCRIPT directive alone. + scriptSrc := directiveOf(policy, "script-src") + for _, forbidden := range []string{"'unsafe-inline'", "'unsafe-eval'", "*"} { + if strings.Contains(scriptSrc, forbidden) { + t.Errorf("script-src a été élargi avec %s : %q", forbidden, scriptSrc) + } + } + + // HSTS on a service that speaks plain HTTP would pin a name a volunteer cannot then + // reach at all. + if got := response.Header.Get("Strict-Transport-Security"); got != "" { + t.Errorf("Strict-Transport-Security = %q sur un service en HTTP", got) + } +} + +// directiveOf returns one directive of a policy, without its name, or the empty string. +func directiveOf(policy, name string) string { + for _, part := range strings.Split(policy, ";") { + part = strings.TrimSpace(part) + if after, found := strings.CutPrefix(part, name+" "); found { + return after + } + } + return "" +} From bfbec17401611632b08e7b63f618d7749d70a7eb Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Thu, 30 Jul 2026 12:04:43 +0200 Subject: [PATCH 3/6] fix(webdav): une redirection pouvait faire tomber le TLS, et le compte partait en clair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La regle de redirection de §10.1 refusait tout saut hors de l'hote declare, et elle ne regardait que l'HOTE. Or net/http conserve l'en-tete Authorization sur une redirection vers le MEME hote -- ce que le controle laissait passer par construction -- si bien qu'un partage repondant « 302 http://meme-hote/... » a une requete https mettait username:password sur le fil, en clair, sur un reseau que quelqu'un croyait protege par TLS. Le trou ressemble a un controle ferme, et c'est ce qui le rend interessant : la verification existait, elle etait juste, et elle ne couvrait pas ce cas. Rien dans le symptome ne l'aurait dit non plus -- le catalogue arrive, le poste le sert, la pastille est verte. Aucun bruit, et le compte du producteur lisible par qui ecoute le reseau du magasin. newClient recoit donc le SCHEMA en plus de l'hote, tous deux tels que catalog.options.url les declare, et refuse un saut de https vers autre chose. Il les recoit plutot que de lire via[0] parce que la valeur declaree est deja ce que cette fonction prend en argument pour l'hote : une regle qui compare a ce qu'un exploitant a tape ne depend alors d'aucun contrat de net/http. Le schema declare est un PLANCHER et non une preference : http vers https reste suivi, et le test le verifie explicitement. Refuser une montee en TLS aurait casse un partage mal configure sans rien proteger, et le sens de la regle est que le compte ne descende jamais, pas que l'adresse ne bouge jamais. Le test exerce la regle a travers le CheckRedirect du client et non a travers un serveur : reproduire le saut de bout en bout demanderait un serveur TLS de test et son certificat auto-signe injecte dans un transport que ce paquet ne laisse deliberement personne configurer -- beaucoup d'echafaudage pour observer une comparaison. Il a ete verifie qu'il tombe sans le correctif. --- internal/catalog/webdav/webdav.go | 23 +++++++++-- internal/catalog/webdav/webdav_test.go | 54 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/internal/catalog/webdav/webdav.go b/internal/catalog/webdav/webdav.go index d8ff929..af554fd 100644 --- a/internal/catalog/webdav/webdav.go +++ b/internal/catalog/webdav/webdav.go @@ -136,7 +136,7 @@ func New(c catalog.SourceConfig) (*Source, error) { username, _ := c.Catalog.Options.Text("username") password, _ := c.Catalog.Options.Text("password") return &Source{ - client: newClient(folder.Host), + client: newClient(folder.Scheme, folder.Host), file: &file, folder: folder, fileName: fileName, @@ -167,18 +167,35 @@ func pathOf(dataDir string) string { // A redirect off the declared host is REFUSED: a catalog that arrives from somewhere // else than the address an operator typed is not the catalog they configured, and // credentials must never follow a redirection to a host nobody vetted (§10.1). -func newClient(host string) *http.Client { +// +// # A redirection may not drop TLS either, and the host check does not cover it +// +// The two arguments are the scheme and the host AS DECLARED, and the scheme is here +// because checking the host alone left a hole that looks closed. net/http keeps the +// Authorization header across a redirection to the SAME host — which is exactly what the +// check above lets through — so a share answering « 302 http://same-host/… » to an https +// request would put `username:password` on the wire in clear, on a network somebody +// believed was TLS-protected. And nothing in the symptom says so: the catalog arrives, the +// station serves it, the light is green. +// +// The declared scheme is a FLOOR and not a preference: http → https is a redirection worth +// following, https → http never is. +func newClient(scheme, host string) *http.Client { return &http.Client{ Transport: &http.Transport{ DialContext: (&net.Dialer{Timeout: connectTimeout}).DialContext, TLSHandshakeTimeout: connectTimeout, ResponseHeaderTimeout: connectTimeout, }, - CheckRedirect: func(request *http.Request, via []*http.Request) error { + CheckRedirect: func(request *http.Request, _ []*http.Request) error { if request.URL.Host != host { return fmt.Errorf("redirection vers %s, hors de l'hôte déclaré %s", request.URL.Host, host) } + if scheme == "https" && request.URL.Scheme != "https" { + return fmt.Errorf("redirection de https vers %s sur %s : le compte du "+ + "partage ne part pas en clair", request.URL.Scheme, request.URL.Host) + } return nil }, } diff --git a/internal/catalog/webdav/webdav_test.go b/internal/catalog/webdav/webdav_test.go index 726ba0e..b5306e5 100644 --- a/internal/catalog/webdav/webdav_test.go +++ b/internal/catalog/webdav/webdav_test.go @@ -446,6 +446,60 @@ func TestARedirectionOffTheDeclaredHostIsRefused(t *testing.T) { } } +// TestARedirectionMayNotDropTLS: the account of an https share never travels in clear. +// +// The rule is exercised THROUGH THE CLIENT'S OWN CheckRedirect and not through a server, +// because reproducing it end to end would mean an httptest TLS server, its self-signed +// certificate injected into a transport this package deliberately does not let anybody +// configure — a lot of scaffolding to observe one comparison. What matters is that the hop +// stays on the DECLARED host, which is what makes it invisible to the check above: net/http +// keeps the Authorization header on a same-host redirection. +func TestARedirectionMayNotDropTLS(t *testing.T) { + const host = "dav.example.org:8001" + client := newClient("https", host) + + hop := func(target string) error { + request, err := http.NewRequest(http.MethodGet, target, nil) + if err != nil { + t.Fatalf("requête %s : %v", target, err) + } + origin, err := http.NewRequest(http.MethodGet, "https://"+host+"/depots/flv_2.csv", nil) + if err != nil { + t.Fatalf("requête d'origine : %v", err) + } + return client.CheckRedirect(request, []*http.Request{origin}) + } + + // The hole this test closes: same host, TLS dropped. + err := hop("http://" + host + "/depots/flv_2.csv") + if err == nil { + t.Fatal("une redirection https → http sur l'hôte déclaré a été acceptée") + } + if !strings.Contains(err.Error(), "en clair") { + t.Errorf("le refus ne dit pas que le compte partirait en clair : %v", err) + } + + // And what must keep working: the same host, still in TLS. + if err := hop("https://" + host + "/autre/flv_2.csv"); err != nil { + t.Errorf("une redirection https → https sur l'hôte déclaré a été refusée : %v", err) + } + + // A share DECLARED in http is not silently upgraded, and not refused either: the + // declared scheme is a floor, so a redirection towards TLS is worth following. + plain := newClient("http", host) + request, err := http.NewRequest(http.MethodGet, "https://"+host+"/depots/flv_2.csv", nil) + if err != nil { + t.Fatalf("requête : %v", err) + } + origin, err := http.NewRequest(http.MethodGet, "http://"+host+"/depots/flv_2.csv", nil) + if err != nil { + t.Fatalf("requête d'origine : %v", err) + } + if err := plain.CheckRedirect(request, []*http.Request{origin}); err != nil { + t.Errorf("une redirection http → https a été refusée : %v", err) + } +} + // TestNextStopsWithItsContext, on the injected clock and with no sleep (§16.4). func TestNextStopsWithItsContext(t *testing.T) { remote := &share{present: false} From 50ee4c58eab71180ccd98e8e454a09357b81aee5 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Thu, 30 Jul 2026 12:05:10 +0200 Subject: [PATCH 4/6] docs(web): quatre commentaires annoncaient ouvertes des routes qui sont fermees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « It is NOT authenticated, and it is the one route of ADR-018 where that deserves a sentence » : c'est ce que dit encore importCatalog, et c'est faux depuis qu'ADR-033 a deplace le critere. La table des routes de server.go garde manual-entry et catalog/import derriere le mot de passe, et un test de session_test.go exige le 401 sur les deux -- les commentaires contredisaient donc un test qui passe. C'est un defaut plus ennuyeux qu'une faute de frappe. Un relecteur qui decide sur la foi d'un commentaire -- « cette route est ouverte, donc le garde n'a rien a voir avec elle » -- se trompe, et il se trompe sur la seule question que le fichier existe pour trancher. Quatre endroits le disaient : - importCatalog et manualEntry, dans internal/web/troubleshooting.go ; - l'interface Troubleshooting de server.go, « the unauthenticated buttons of §14.4 », alors qu'un de ses trois membres ne l'est plus ; - adminTroubleshooting de cmd/openscale/admin.go, « the three unauthenticated switches », le meme mensonge un etage plus bas. Les quatre disent maintenant lequel des trois est ferme, et surtout POURQUOI le raisonnement d'ADR-018 ne suffisait plus : « n'ecrit pas la configuration » n'a jamais ete ce qui rendait ces gestes anodins. La saisie manuelle coupe la balance et laisse le CLIENT taper son propre poids ; un depot de catalogue remplace toute la grille par un fichier que quelqu'un a apporte. Les deux laissent leur trace a la caisse, et c'est plus lourd que ce que le mot de passe gardait avant eux. Ce qui reste vrai est garde : les gardes de §10.4 protegent le catalogue en service, pas un mot de passe. Un mot de passe ne rend pas sur un export ampute, il nomme seulement qui l'a depose. Aucun changement de comportement -- quatre commentaires, et la table des routes reste seule a decider. --- cmd/openscale/admin.go | 9 +++++++-- internal/web/server.go | 13 ++++++++----- internal/web/troubleshooting.go | 24 ++++++++++++++++++------ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/cmd/openscale/admin.go b/cmd/openscale/admin.go index f412beb..686eb57 100644 --- a/cmd/openscale/admin.go +++ b/cmd/openscale/admin.go @@ -139,8 +139,13 @@ func (c adminConfig) Restore(ctx context.Context, version int) (domain.Config, e return c.file.Restore(ctx, version) } -// adminTroubleshooting is the three unauthenticated switches of §14.4, and not one of -// them writes the configuration file — which is the criterion of ADR-018. +// adminTroubleshooting is the three repair switches of §14.4, and not one of them writes +// the configuration file. +// +// That was the criterion of ADR-018 and it no longer decides who may press them: ADR-033 +// asks what an act CHANGES, so ManualEntry sits behind the password while the roll counter +// and the fallback printer stay open. Which is which lives in the route table of +// internal/web, and nowhere else. type adminTroubleshooting struct { station *station.Station printer *livePrinter diff --git a/internal/web/server.go b/internal/web/server.go index bbfa96d..0b00f21 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -260,12 +260,15 @@ type SelfTester interface { SelfTest(ctx context.Context, what string) error } -// Troubleshooting is what the unauthenticated buttons of §14.4 act on and that -// nothing else in this package can reach. +// Troubleshooting is what the repair buttons of §14.4 act on and that nothing else in +// this package can reach. // -// None of the three writes the configuration file — that is the criterion of -// ADR-018. Manual entry is a STATE the station enters, the roll counter is a -// counter, and the fallback printer is a route for the current session. +// None of the three writes the configuration file: manual entry is a STATE the station +// enters, the roll counter is a counter, and the fallback printer is a route for the +// current session. That was the criterion of ADR-018, and it is no longer the one that +// decides the door — ADR-033 asks what an act CHANGES. Two of the three stay open, and +// ManualEntry is authenticated: it cuts the scale out and lets the customer type their own +// weight. The route table below is where that is settled, not this interface. type Troubleshooting interface { // ManualEntry switches the station into, or out of, manual weight entry. ManualEntry(ctx context.Context, on bool) error diff --git a/internal/web/troubleshooting.go b/internal/web/troubleshooting.go index 61f8702..0ec6b9b 100644 --- a/internal/web/troubleshooting.go +++ b/internal/web/troubleshooting.go @@ -124,8 +124,14 @@ func reloadMessage(seen, watched string) string { // // Manual entry is a STATE the station enters, never a driver written into a file // (§11.4): the configuration on disk keeps saying what the operator asked for, and -// the running one says what the station can actually do. That is why this route is -// not authenticated and why it does not touch the file. +// the running one says what the station can actually do. That is why this route does +// not touch the file. +// +// IT IS AUTHENTICATED, unlike the rest of this file. ADR-033 moved the criterion from +// « does it write the configuration? » to « does it change what the station sells, or the +// way it weighs? », and this route cuts the scale out and lets the CUSTOMER type their own +// weight. Writing no file was never what made it harmless. The route table of server.go +// holds the list, and a test of session_test.go asserts the 401. func (s *Server) manualEntry(w http.ResponseWriter, r *http.Request) { var body switchRequest if !decodeJSON(w, r, &body) { @@ -335,10 +341,16 @@ func (s *Server) selfTest(w http.ResponseWriter, r *http.Request, what string) { // importCatalog is POST /admin/api/catalog/import: a CSV dropped on the screen (A4). // -// It is NOT authenticated, and it is the one route of ADR-018 where that deserves a -// sentence: dropping a catalog writes no configuration, it feeds the same watcher, -// the same parser and the same qualification as the file the producer deposits — and -// the guards of §10.4 are what protect the catalog in service, not a password. +// IT IS AUTHENTICATED, and it deserves a sentence because it used not to be. The argument +// for leaving it open was that a drop writes no configuration and feeds the same watcher, +// the same parser and the same qualification as the file the producer deposits — so the +// guards of §10.4, not a password, are what protect the catalog in service. That argument +// survives, and it stopped being the right one: ADR-033 asks what an act CHANGES, and this +// one replaces the whole grid with a file somebody brought in. It leaves its trace at the +// till, which is heavier than anything the password was guarding before. +// +// What is still true is that the guards remain the real protection. A password does not +// make an amputated export safe to put into service; it only names who dropped it. func (s *Server) importCatalog(w http.ResponseWriter, r *http.Request) { if s.catalog == nil { unavailable(w, "aucune source de catalogue n'est configurée") From c6f4e4d35709bd2e5291190370672eba1081b491 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Thu, 30 Jul 2026 12:05:49 +0200 Subject: [PATCH 5/6] docs: SECURITY.md, et les quatre ecarts qu'on laisse en ouvrant le depot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un depot public est lu par d'autres yeux que ceux d'une cooperative, et il recoit des rapports. SECURITY.md dit ou signaler -- le formulaire prive de GitHub, jamais une issue publique -- et ce qu'une equipe benevole peut promettre : accuse de reception sous sept jours, pas de programme de recompense, et des correctifs sur la derniere version publiee seulement, parce qu'il n'y a pas de branche de maintenance pour un parc de quatre postes. Le reste du fichier est le MODELE DE MENACE ASSUME, et c'est la sa valeur. L'acces physique vaut l'acces administrateur (§15.2), les gestes de depannage repondent sans mot de passe par decision (ADR-033), le service parle HTTP en clair sur la boucle locale, le symbole EAN-13 de l'etiquette est tronque par compromis de geometrie, et les mises a jour sont verifiees par condensat et non signees. Sans ces paragraphes, on repond trois fois au meme rapport ; avec eux, celui qui allait l'ecrire trouve la reponse avant. Il dit aussi ce qui EST dans le perimetre, parce qu'une liste de non-failles sans son complement decourage les rapports utiles : tout ce qui s'exploite sans etre devant le poste -- depuis une page ouverte sur la machine, depuis le reseau du magasin, depuis un CSV que le producteur depose, depuis une reponse du partage, depuis une release -- et l'elevation du compte du kiosque vers LocalSystem. L'entree de SUIVI.md porte les quatre correctifs de cette branche ET LES QUATRE ECARTS LAISSES, avec le raisonnement de chacun. C'est cette seconde liste qui justifie l'entree : l'hote reel qui subsiste dans neuf commits de l'historique, la fiche d'installation en clair sur le poste, l'ACL du compte du kiosque sur data\updates, l'URL WebDAV publiee par /admin/api/health, et l'export CSV qui n'echappe pas ses formules. Ils ont ete peses -- poste peu sensible, reseau d'une cooperative, benevoles -- et non ignores. Ecrits nulle part, ils seraient re-signales dans six mois, ou « corriges » par quelqu'un qui ne saurait pas qu'ils ont ete decides. Deux choses y sont dites honnetement plutot que passees. Une execution de la suite complete sur douze a signale un echec jamais reproduit ensuite, et la commande de comptage qui l'a vu n'en a pas garde le nom ; ce n'est pas imputable a cette branche -- les trois tests nouveaux ont tenu cinquante executions chacun, et internal/web dix passes sous -race. Et le compteur d'en-tete n'est PAS remesure ici : la methode qui a produit « 2 572 » n'est pas celle qui donne 2 573 PASS et 7 SKIP sur une passe verte, et remplacer un chiffre par l'autre aurait pretendu a une continuite fausse. Le fichier « $(readlink », vide et suivi a la racine, part avec ce commit. C'est le residu d'une commande shell ratee, et c'est la premiere ligne que voit qui ouvre le depot. --- $(readlink | 0 SECURITY.md | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++ SUIVI.md | 64 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) delete mode 100644 $(readlink create mode 100644 SECURITY.md diff --git a/$(readlink b/$(readlink deleted file mode 100644 index e69de29..0000000 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c74a7dd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,86 @@ +# Signaler une faille de sécurité + +## Comment + +**N'ouvrez pas d'issue publique.** Utilisez le formulaire privé de GitHub : +onglet **Security** du dépôt → **Report a vulnerability**. Il crée une conversation que +seuls les mainteneurs voient, et où un correctif peut être préparé avant d'être annoncé. + +Ce qui aide, dans l'ordre : + +1. la version (`openscale --version`, ou le nom de l'archive téléchargée) et la plateforme ; +2. la route ou la commande concernée, et ce qu'un attaquant obtient ; +3. **d'où il agit** — c'est la question qui décide de la gravité ici : depuis la boucle + locale du poste, depuis le réseau de la coopérative, ou depuis Internet ; +4. de quoi reproduire, si c'est court. Un `curl` vaut mieux qu'une capture d'écran. + +## Ce que vous pouvez attendre + +Le projet est maintenu par une équipe bénévole d'une coopérative alimentaire, pas par une +entreprise avec une astreinte. Concrètement : + +- **accusé de réception sous 7 jours**. Sans réponse au bout de deux semaines, relancez : + ce n'est pas un silence, c'est une boîte que personne n'a ouverte ; +- un avis sur la gravité et une intention de correctif dans le mois ; +- **seule la dernière version publiée reçoit des correctifs.** Il n'y a pas de branche de + maintenance : le parc tient sur quatre postes, et la mise à jour se fait depuis l'écran + d'administration. + +Nous n'avons **pas de programme de récompense** et pas de budget pour en avoir un. Votre +nom figurera dans les notes de version si vous le souhaitez. + +## Le modèle de menace, et ce qui n'est pas une faille + +Un poste OpenScale est une machine en libre-service dans un magasin, sur le réseau local +d'une coopérative, utilisée par des bénévoles et par des adhérents. Le service **n'écoute +que `127.0.0.1`** dans la configuration livrée. Plusieurs décisions découlent de ce +contexte, elles sont documentées, et **les signaler comme des failles nous fera vous +répondre en pointant cette section** : + +- **L'accès physique au poste vaut l'accès administrateur.** Qui peut débrancher + l'imprimante peut aussi arrêter le poste. C'est écrit dans `docs/02-architecture.md` + §15.2, et c'est pourquoi le mot de passe d'ouverture de session Windows est en clair + dans le registre — `deploy/windows/harden.ps1 -AutologonSecret` explique comment le + déplacer vers les secrets LSA pour qui le veut. +- **Les gestes de dépannage ne demandent pas de mot de passe** (ADR-033) : tester la + balance, tester l'imprimante, recharger le catalogue, changer le rouleau, produire + `diagnostic.zip`. Le critère n'est pas la porte mais l'acte — *ce qui change ce que le + poste vend, ou la façon dont il pèse* est protégé, le reste ne l'est pas. Un bénévole + seul devant un poste muet doit pouvoir le diagnostiquer. +- **Le service HTTP est en clair.** Il n'écoute que la boucle locale ; un certificat + auto-signé sur un kiosque coûterait un écran d'avertissement et une échéance à + surveiller, pour rien. Si vous mettez `network.admin_on_lan` à vrai, le mot de passe + d'administration voyage en clair sur le réseau local : c'est un réglage, pas la valeur + livrée, et c'est à vous de décider. +- **Le symbole EAN-13 de l'étiquette est volontairement tronqué.** Ce n'est pas un défaut + de rendu : un symbole conforme n'entre pas sur 40 × 25 mm avec les cinq champs texte, et + la caisse lit le code depuis quinze ans. C'est un compromis assumé, pas un correctif en + attente. +- **Les mises à jour sont vérifiées par condensat, pas par signature.** `SHA256SUMS-archives.txt` + prouve que les octets sont arrivés entiers, jamais qu'ils sont les bons : la racine de + confiance est l'accès en écriture à ce dépôt GitHub. Un système de signature demanderait + à une équipe bénévole de gérer une clé privée pendant des années, et une clé perdue + bloquerait toutes les mises à jour du parc — panne plus probable que la menace qu'elle + écarte. C'est un arbitrage, il est ouvert à la discussion, mais il est délibéré. + +**Est en revanche tout à fait dans le périmètre**, et nous intéresse : tout ce qui +s'exploite **sans être devant le poste** — depuis une page web ouverte sur la machine, +depuis le réseau de la coopérative, depuis un fichier CSV que le producteur dépose, depuis +une réponse du partage WebDAV, depuis une release GitHub. Une élévation de privilèges du +compte du kiosque vers `LocalSystem` nous intéresse aussi, même si elle suppose un clavier. + +## Ce que le dépôt contient et ne contient pas + +`testdata/catalog/flv.csv` et `flv_1.csv` sont des **exports Odoo authentiques** : 508 +produits, leurs prix et 181 photos. Ils sont ici parce qu'ils font foi sur le format contre +toute documentation, et leur présence est une décision du commanditaire. Ils ne portent +aucune donnée personnelle : identifiant, nom, code-barres, prix, catégorie, unité, image. + +Les coordonnées d'infrastructure — hôtes, comptes, adresses — ont été retirées et +remplacées par la réserve `example.org`. `docs/00-donnees-retirees.md` dit lesquelles, où +vivent les vraies, et comment le balayage a été fait. Aucune URL, aucune adresse n'est +compilée dans le binaire : les valeurs d'un site sont un fichier livré (ADR-026). + +Le journal des pesées ne porte **aucun identifiant de client** — produit, poids, prix, +code-barres, palier tarifaire, et rien d'autre. C'est pourquoi son export ne demande pas de +mot de passe. diff --git a/SUIVI.md b/SUIVI.md index f77688f..2fdf569 100644 --- a/SUIVI.md +++ b/SUIVI.md @@ -11,6 +11,70 @@ et `deps`, vertes. Le bundle de l'écran client reconstruit pèse **79 425 octet 70,5 % du budget de 112 640** — 33 215 octets de marge. **La passe `-race` est verte elle aussi**, 31 paquets, 0 échec. +**Passe de sécurité avant l'ouverture du dépôt en public (30/07/2026).** Le dépôt part en +public sous AGPL-3.0, et un dépôt public est lu par d'autres yeux que ceux d'une +coopérative. La revue a porté sur tout le dépôt et pas sur un diff : secrets, historique +Git, surface HTTP, chaîne de mise à jour, scripts d'installation, CI, dépendances. +`govulncheck` ne trouve **aucune vulnérabilité atteignable** et `npm audit` aucune ; +traversée de chemin, injection SQL et XSS n'ont pas de point d'entrée. Quatre correctifs +ont été retenus, quatre écarts ont été **pesés et laissés**, et c'est cette seconde liste +qui vaut d'être écrite : sans elle, ils seront re-signalés ou « corrigés » sans que +personne sache qu'ils ont été décidés. + +| # | Ce qui a changé | État | +|---|---|---| +| 1 | Les douze actions de `ci.yml` et `release.yml` épinglées sur un **SHA de commit**, le tag lisible en commentaire. `release.yml` est le seul job du dépôt qui reçoive `contents: write`, et `softprops/action-gh-release` y était sur un tag mobile | ✅ | +| 2 | `.github/dependabot.yml` : `gomod`, `npm` et **`github-actions`** — c'est cette troisième entrée qui empêche l'épinglage de devenir une dette | ✅ | +| 3 | `SECURITY.md` : où signaler, ce qu'une équipe bénévole peut promettre, et **le modèle de menace assumé** — accès physique, gestes de dépannage ouverts (ADR-033), HTTP en clair sur la boucle locale, symbole EAN-13 tronqué, mises à jour vérifiées par condensat et non signées | ✅ | +| 4 | Quatre en-têtes sur **toutes** les réponses, posés par `guard` avant toute décision : CSP, `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` | ✅ | +| 5 | La règle de redirection WebDAV refuse le **déclassement TLS** : elle ne comparait que l'hôte, or `net/http` conserve l'en-tête `Authorization` sur une redirection vers le même hôte | ✅ | +| 6 | Quatre commentaires qui affirmaient « non authentifié » sur `manual-entry` et `catalog/import`, devenus faux quand ADR-033 les a fait passer derrière le mot de passe — et contredits par un test qui exige le 401 | ✅ | +| 7 | Le fichier `$(readlink`, vide, suivi à la racine — résidu d'une commande shell ratée | ✅ | + +**Pourquoi `frame-ancestors 'none'` est la directive qui compte ici.** Les autres bornent ce +qu'une page **charge** ; celle-là ferme un **acte**. Les boutons de dépannage de §14.4 +répondent sans mot de passe par décision (ADR-033), donc une page ouverte sur le poste +pouvait encadrer `/admin`, le recouvrir, et faire cliquer « Rouleau changé » ou un +auto-test à un bénévole qui croyait cliquer ailleurs. `style-src` garde `'unsafe-inline'` : +le CSS en ligne n'exécute rien, et une politique qui casse l'écran le jour où vite change +sa façon d'émettre les styles est une politique qu'on supprime à 7 h du matin au lieu de la +réparer. + +**La CSP a été vérifiée sur les deux écrans réels, pas seulement en test.** Poste de +démonstration lancé, catalogue déposé, les deux écrans ouverts dans un navigateur : +**zéro message de console**, grille et photos affichées, police chargée, flux SSE vivant. +Et le seul motif douteux a été exercé nommément — `handToBrowser` (`Station.svelte:421`) +crée un `blob:` par `createObjectURL` pour l'export de configuration, ce qu'aucune +directive CSP ne couvre : le téléchargement passe, sans violation. Trois tests Go +nouveaux gardent le reste, dont un qui refuse qu'on élargisse `script-src`. + +**Les quatre écarts laissés, et le raisonnement.** Le contexte est un poste peu sensible, +sur le réseau d'une coopérative, devant des bénévoles : + +| Écart | Pourquoi on le laisse | +|---|---| +| `dav.lacagette-cooperative.fr` subsiste dans 9 commits de `main` (`943a961`→`776f184`, corrigé par `69e2f11`) — la réécriture de `docs/00-donnees-retirees.md` n'a couvert que les deux premiers commits, et l'hôte est revenu en L8 | Le domaine n'existe plus, la valeur est enfouie dans l'historique, et le mot de passe qui l'accompagne est un faux. Réécrire l'historique et forcer la poussée coûterait plus que le risque. **L'écart est connu, pas ignoré** ; ce qui doit ne pas se reproduire, c'est un hôte réel dans une fixture | +| `install-sheet.txt` reste en clair dans `C:\ProgramData\OpenScale`, lisible par le compte du kiosque : mot de passe Windows **et** code de secours | Le code de secours vaut par la feuille papier, et l'accès physique vaut déjà l'accès administrateur (§15.2). À reprendre le jour où l'ACL de `DataRoot` sera revue | +| Le compte du kiosque a `Modify` héritable sur tout `DataRoot`, `data\updates` compris — où LocalSystem exécute ensuite `update.ps1` | Élévation locale réelle, mais elle demande un clavier sur le poste. Resserrer la seule ACL de `updates` est le correctif tenu en réserve ; `start.bat` lance `serve` sous le compte interactif, donc le reste de `data\` doit rester inscriptible | +| `/admin/api/health` publie l'URL WebDAV et le compte sans mot de passe, là où `internal/diag/redact.go` les caviarde de l'archive | Route de boucle locale, illisible en inter-origine faute d'en-têtes CORS. Incohérence assumée entre deux portes | +| L'export CSV du journal n'échappe pas les cellules commençant par `=`, `+`, `-`, `@` | Le fichier vient de l'Odoo de la coopérative, pas d'un tiers | + +**Et ce qui a été examiné puis écarté sans code.** Signer les releases (une clé privée à +tenir des années par une équipe bénévole, et une clé perdue bloque tout le parc — panne +plus probable que la menace), servir le poste en HTTPS (certificat auto-signé sur un +kiosque), et fermer les routes ouvertes d'ADR-033. Les trois sont désormais **écrits dans +`SECURITY.md`**, pour que le rapport suivant qui les soulève trouve la réponse avant de +l'écrire. Un fait qui les renforce, vérifié plutôt que supposé : la table `weighings` ne +porte **aucun identifiant de client** — donc son export ouvert n'expose pas de donnée +personnelle. + +**Une observation restée ouverte.** Sur douze exécutions de la suite complète, **une** a +signalé un échec dans un paquet, jamais reproduit ensuite — et la commande de comptage qui +l'a vu n'en a pas gardé le nom. Ce n'est pas imputable aux ajouts ci-dessus : les trois +tests nouveaux ont tenu 50 exécutions chacun, et `internal/web` dix passes sous `-race`. +`ci.yml` documente déjà une famille de tests sensibles au planificateur (`skipUnderShort`), +et c'est la première piste si le symptôme revient. + **Il y a bien un gcc sur ce poste, et les relevés précédents disaient le contraire.** La passe `-race` exige cgo, donc un compilateur C, et trois entrées de suivi de suite l'ont déclarée injouable ici en la renvoyant à la CI Linux. C'était faux : WinLibs est installé From 99b898b576b45e0f02ad057e9bd19148737ab395 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Thu, 30 Jul 2026 12:17:07 +0200 Subject: [PATCH 6/6] docs(plan): un plan prescrivait un lien de conversation en pied de chaque commit Le depot part en public sous AGPL-3.0 et il est destine a circuler entre cooperatives. Rien n'y a a renvoyer vers une conversation privee, et le plan de la politique de dependances faisait exactement le contraire : sa consigne de style demandait de terminer chaque message par un lien de session d'assistant, et ses sept messages d'exemple le portaient pour montrer comment faire. C'est la SOURCE et pas seulement une occurrence. Une consigne ecrite dans un plan du depot est lue et appliquee par qui reprend le travail : les 62 commits de main qui portent un tel lien viennent de la, et sans ce commit le motif se reproduirait au plan suivant. La consigne reste -- Conventional Commits, sujet en francais sans accents, corps accentue, c'est bien la convention du depot -- et elle dit maintenant l'inverse sur ce point : aucun lien de session ni mention d'outil en pied de message. Les sept pieds des messages d'exemple partent avec elle, la ligne vide qui les precedait comprise, si bien que les exemples montrent desormais ce qu'ils prescrivent. Le controle tient en une ligne, et il doit rester sans reponse : git log -1 --format=%B | grep -i assistant-de-session Rien d'autre ne change dans le plan : ni une tache, ni un critere, ni un chiffre. --- .../plans/2026-07-28-politique-de-dependances.md | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-07-28-politique-de-dependances.md b/docs/superpowers/plans/2026-07-28-politique-de-dependances.md index 8799554..db38911 100644 --- a/docs/superpowers/plans/2026-07-28-politique-de-dependances.md +++ b/docs/superpowers/plans/2026-07-28-politique-de-dependances.md @@ -18,7 +18,7 @@ - **Langue.** Code, identifiants et **commentaires** en anglais ; documentation en français ; messages destinés à un humain qui lit la sortie du CI en français. - **Documentation du code** : `godoc` — commentaire commençant par le nom de l'élément, phrase complète, qui explique le *pourquoi* et jamais le *quoi*. - **Zéro cgo.** `tools/deps` ne fait que lire des fichiers ; il compile sous `CGO_ENABLED=0` comme le reste. -- **Messages de commit** : Conventional Commits, sujet en français **sans accents** (convention du dépôt), corps accentué. Terminer par `Claude-Session: https://claude.ai/code/session_013B8Dhyk96JMESEeXqnqoL3`. +- **Messages de commit** : Conventional Commits, sujet en français **sans accents** (convention du dépôt), corps accentué. **Aucun lien de session ni mention d'outil en pied de message** : ce dépôt est destiné à circuler, et rien n'y renvoie vers une conversation privée. - **Ne jamais faire l'inverse du sens voulu** : c'est la documentation qui rejoint `go.mod`, jamais `go.mod` qui rejoint la documentation. Aucune tâche n'ajoute ni ne retire un module du binaire. --- @@ -260,8 +260,6 @@ Lecture textuelle, sans golang.org/x/mod/modfile : un verificateur de dependances qui ajoute une dependance ne vaut rien. Les lignes portant // indirect sont ignorees -- elles sont la fermeture transitive des six, pas un choix du projet. - -Claude-Session: https://claude.ai/code/session_013B8Dhyk96JMESEeXqnqoL3 EOF ``` @@ -529,8 +527,6 @@ l'intention, l'exigence d'une ligne de separation dit la grammaire. Une table absente est une ERREUR, jamais un inventaire vide : c'est en silence que la promesse du 17.1 s'etait perdue. - -Claude-Session: https://claude.ai/code/session_013B8Dhyk96JMESEeXqnqoL3 EOF ``` @@ -765,8 +761,6 @@ part avec le binaire -- et c'est cette duplication qui autorise la derive. Premiere execution sur le depot : huit ecarts, les quatre modules budgetes et non pris, signales une fois par table. C'est le defaut reel, et l'outil le trouve du premier coup. - -Claude-Session: https://claude.ai/code/session_013B8Dhyk96JMESEeXqnqoL3 EOF ``` @@ -833,8 +827,6 @@ refus est argumente : c'est la base de preuve de l'ADR qui suit. Le renvoi a docs/adr/0018-dependencies.md est supprime -- ce fichier n'a jamais existe. - -Claude-Session: https://claude.ai/code/session_013B8Dhyk96JMESEeXqnqoL3 EOF ``` @@ -940,8 +932,6 @@ a un module qui n'est plus la. go run ./tools/deps passe au vert : 6 dependances directes, declarees a l'identique dans le 17.1 et THIRD-PARTY.md. - -Claude-Session: https://claude.ai/code/session_013B8Dhyk96JMESEeXqnqoL3 EOF ``` @@ -1010,8 +1000,6 @@ une panne au demarrage d'un poste en magasin. sqlc et wire sont nommes recevables sans etre adoptes, et le critere de reouverture est chiffre pour que la decision puisse etre reprise sur des faits. - -Claude-Session: https://claude.ai/code/session_013B8Dhyk96JMESEeXqnqoL3 EOF ``` @@ -1141,8 +1129,6 @@ cote serait exactement la divergence silencieuse que ce chantier corrige. L'etape CI vient en dernier, apres que le 17.1 et THIRD-PARTY.md ont ete remis d'accord avec go.mod : brancher un controle rouge sur la CI casserait la branche pour tout le monde. - -Claude-Session: https://claude.ai/code/session_013B8Dhyk96JMESEeXqnqoL3 EOF ```