diff --git a/$(readlink b/$(readlink
deleted file mode 100644
index e69de29..0000000
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 }}
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é
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/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
```
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}
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