Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed $(readlink
Empty file.
65 changes: 65 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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)
28 changes: 20 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
14 changes: 10 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
86 changes: 86 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 64 additions & 0 deletions SUIVI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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é
Expand Down
Loading