Audits · août 2026Audit — pilier commerce-systems

Audit — pilier commerce-systems

Sixieme tour de la rotation (docs/team/roster.md, @codebase-auditor), apres data-platform, security-identity, billing, ai-platform et integrations. Lecture seule sur le code. Ce document et quatre items de backlog sont…

Sixieme tour de la rotation (docs/team/roster.md, @codebase-auditor), apres data-platform, security-identity, billing, ai-platform et integrations.

Lecture seule sur le code. Ce document et quatre items de backlog sont la sortie complete.

Perimetre : src/features/aeo|cro|vitals|tracking|pixels|systems|store-runtime|commerce|aov|action-plan|reports|notes/**, src/app/api/tracking|pixels|vitals|preview/**, src/app/(minimal)/scan|setup/**, src/app/(dashboard)/[orgSlug]/[storeSlug]/systems/**, src/services/preview-meta/**.

Etat du depot : origin/main a a462f8fb, 26 aout 2026.

Motif du tour, ecrit la veille : c'est le pilier qui installe du code chez le client — le seul dont le rayon de souffle sort de notre infrastructure. C'est bien la qu'est le constat le plus lourd, mais pas pour la raison attendue.

Ce qui a ete verifie

SondeResultat
Le scan tracking public reste dans (minimal)oui — invariant respecte
Repertoires du perimetre absents du disque0 sur 12
Imports croises features/X → features/Y (tout le depot)31, sur 11 aretes
… dont un cycle de valeuroui — ai ↔ store-runtime
Garde (CI, eslint, script) verifiant l'autonomie des featuresaucune
Verification HMAC des beacons annoncee par la doc de la routeoui
… reellement executeenon
Appelants de verifySignature hors tests0
Tests verts sur verifySignature6
originMatchesStoreDomain : copies dans le depot2
… comportement identique (matrice de 15 cas)oui, formatage different
Champ enregistrant qu'un Store.domain a ete verifieaucun
Sous-systemes dependant de VITALS_BEACON_SECRET4, dont 1 nomme d'apres lui

Le pilier est le mieux teste de ceux vus jusqu'ici : vitals porte 8 fichiers de test sur 29, la derivation de sous-cle par store est correcte, le hachage de sessionId empeche deliberement la correlation d'un visiteur entre deux boutiques, et le modele de confiance retenu pour les beacons est defendable et argumente. Le probleme n'est pas la conception. C'est que trois documents sur quatre decrivent une autre conception que celle qui tourne.


Constat 1 — l'invariant d'autonomie des features a onze violations, un cycle, et aucune garde

Severite : moyenne. → backlog/commerce-systems/0064

docs/team/roster.md:221 :

« Chaque systeme reste autonome : pas de dependance croisee entre features/* sans passer par src/lib/ ou src/services/. »

Denombrement sur les 18 repertoires de src/features/ : 31 imports croises, 11 aretes.

AreteImportsDont valeur
ai → store-runtime139
ai → shopify5—
ai → connectors3—
pixels → vitals22
store-runtime → ai21
action-plan → aov / cro / vitals32
ai → cro / action-plan22
tracking → shopify11

Aucune garde ne verifie cet invariant, verifie : pas de regle eslint import/no-cycle ou boundaries, pas de madge ni de dependency-cruiser dans package.json, rien dans scripts/. pnpm fleet:scope verifie a quel pilier appartient un diff, pas qui importe qui.

Le cycle est reel, et il est en valeur des deux cotes

ai/conversation/execute-signals.ts:27  →  store-runtime/knowledge/indexer
store-runtime/knowledge/indexer.ts:22  →  ai/memory/rag

Ce ne sont pas des imports de type. Et le detail le rend concret : dans execute-signals.ts, les lignes 26 et 27 sont voisines —

import { ingestKnowledgeSource } from "@/features/ai/memory/rag"
import { indexShopifyEntity } from "@/features/store-runtime/knowledge/indexer"

— alors que indexShopifyEntity est un habillage de ingestKnowledgeSource, importe depuis le meme module. ai traverse store-runtime pour rejoindre sa propre primitive. L'habillage a une valeur reelle (il ajoute les metadonnees de kind), c'est son adresse qui cree le cycle : src/lib/ ou src/services/, ce que l'invariant prescrit, le dissoudrait.

Une arete que l'invariant, applique a la lettre, casserait

tracking/scan/clients/shopify-admin.ts:3 importe @/features/shopify/sdk/version — la constante de version de l'API Shopify. C'est une violation au sens strict. C'est aussi exactement ce qu'il faut faire : l'audit integrations de la veille a ouvert backlog/integrations/0060 parce que cette version est decidee a cinq endroits au lieu d'un.

Appliquer l'invariant a la lettre ici pousserait ce fichier a dupliquer la constante, c'est-a-dire a produire le defaut que l'item d'a cote demande de corriger. Un invariant qui, applique, cree la dette d'un autre pilier a besoin de son exception ecrite, pas d'une garde plus stricte.


Constat 2 — trois documents annoncent une verification HMAC que le code ne fait pas

Severite : haute (le risque est documentaire, pas exploitable, voir plus bas). → backlog/commerce-systems/0065

POST /api/vitals/ingest est l'entree publique des beacons RUM. Sa doc d'en-tete (route.ts:20-23) :

« Trust model : The collector embeds a per-store HMAC sub-secret at extension publish time. Every beacon is signed; the route verifies the sig in constant time before touching the DB. »

signature.ts:5-9 dit la meme chose :

« The server re-derives the same subkey and checks the signature in constant time before accepting the row. »

Et beacon-handler.ts:85, en commentaire inline :

« Parse JSON before signature verification: if the body isn't valid JSON the signature check is meaningless. »

Aucune verification de signature n'a lieu. beacon-handler.ts:55 n'importe qu'une seule chose de signature.ts :

import { hashSessionId } from "./signature"

verifySignature et signPayload (ceux de vitals) n'ont aucun appelant en production : verifie sur tout src/. Ils sont exportes publiquement par features/vitals/index.ts:39 et couverts par six tests verts dans signature.test.ts, ce qui acheve de les faire passer pour vivants.

Le modele reel est different, et il est correct

Le quatrieme document — l'en-tete de beacon-handler.ts, dit la verite, et argumente :

« Trust model — Origin-based verification. […] this is the same model Plausible / Datafast use, and is the only honest option for a script that's by definition public (any client-embedded HMAC secret can be extracted by an attacker — it would be security theatre). For server-to-server relays (not yet wired) we keep the HMAC helpers in signature.ts. »

C'est juste. Un secret embarque dans du JS de storefront public est extractible ; le controle d'Origin est ce que font les concurrents cites. pixels/handler.ts documente le meme modele, correctement.

Le constat n'est donc pas « le modele est faux ». Il est : trois textes sur quatre, dont celui de la route elle-meme, promettent une garantie plus forte que celle qui existe, et les fonctions qui l'implementeraient dorment a cote, testees, exportees, appelees par personne.

Le cout est precis. Un relecteur, un auditeur de securite ou un operateur qui ouvre le fichier de la route conclut que la forge de beacons est empechee cryptographiquement. Elle ne l'est pas : Origin n'est infalsifiable que depuis un navigateur. Depuis curl, il s'ecrit. Ce qui protege reellement, c'est que forger demande de connaitre le storeId et le domaine de la boutique : un cout, pas un mur. C'est un risque accepte et bien raisonne dans beacon-handler.ts ; c'est un risque invisible pour qui lit les trois autres.


Constat 3 — la verification de domaine ne persiste rien, et deux handlers parlent du « verified domain »

Severite : moyenne. → backlog/commerce-systems/0066

Les deux handlers d'ingestion fondent leur controle d'acces sur store.domain, en le qualifiant de verifie :

beacon-handler.ts:13 — « We require Origin to match the store's verified domain. » beacon-handler.ts:227 — « True when the Origin header points to the store's verified domain. »

Le mot revient six fois dans ce seul fichier (l. 13, 24, 99, 112, 227, 254).

POST /api/stores/[storeId]/verify-domain existe et fait un vrai controle DNS TXT. Mais en cas de succes :

if (verified) {
  await prisma.store.update({
    where: { id: storeId },
    data: { domain: store.domain },   // ← reecrit la valeur par elle-meme
  })
}

Le resultat part dans la reponse JSON et n'est ecrit nulle part. Verifie : le modele Store n'a aucun champ de verification (grep -i verif sur le bloc du modele : rien ; les trois verifiedAt du schema appartiennent a KycVerification, ContentRender et IntelligenceOptOut).

Et POST /api/stores (route.ts:61) ecrit domain directement depuis l'entree utilisateur, sans passer par quoi que ce soit.

Donc Store.domain est ce que le marchand a tape. L'unicite globale (@unique) empeche deux organisations de revendiquer le meme domaine : c'est la vraie protection, et elle tient. Mais le mot « verified », repete dans le fichier qui decide d'accepter ou non un beacon, ne designe aucun etat que le systeme possede.


Constat 4 — VITALS_BEACON_SECRET porte quatre usages sans rapport, et sa rotation reecrit les identites de clients

Severite : moyenne. → backlog/commerce-systems/0067

ConsommateurUsage
features/shopify/ingester/lib/email-hash.ts:26HMAC des emails clients — l'identite « meme client entre deux commandes »
app/api/sidekick/data/route.ts:36secret de signature Sidekick
app/api/sidekick/action/route.ts:31idem
features/vitals/collector/signature.ts:28le seul usage que son nom decrit — et c'est le chemin mort du constat 2

Les trois premiers ecrivent serverEnv.VITALS_BEACON_SECRET || serverEnv.AUTH_SECRET.

Consequence concrete : faire tourner cette cle (le geste normal apres une fuite, et ce que signature.ts promet explicitement (« We can rotate a single store's subkey… »)) change tous les hash d'emails clients. Les lignes deja en base gardent l'ancien hash, les nouvelles portent le nouveau, et la jointure « meme client » se scinde silencieusement en deux. Aucune erreur, aucune alerte : juste des clients qui deviennent deux clients.

L'entree du catalogue operateur (services/env-audit.ts:487) ne le dit pas :

« HMAC-SHA256 secret for web-vitals beacons from theme extension. »

Elle decrit le seul usage qui n'a pas lieu, et tait les trois qui ont lieu. C'est cette entree que /admin/operations/env-audit presente a l'operateur qui se demande s'il peut y toucher.

Le commentaire de email-hash.ts:10 assume le partage, « the same secret is reused for forward secrecy », mais la formule se contredit : reutiliser un secret entre sous-systemes est le contraire de la forward secrecy. Ce n'est pas le fond du constat, c'est le signe qu'il n'a pas ete relu depuis.


Releve, sans item

  • Le commentaire dit « exactly one subdomain level », le code n'en limite aucun. originMatchesStoreDomain documente « Allow exactly one subdomain level (e.g. shop.boutique.com) » et implemente cleanOrigin.endsWith(\.${cleanDomain}`), qui accepte n'importe quelle profondeur. Verifie a l'execution : a.b.boutique.comcontre le domaineboutique.com→true`. Ce n'est pas un defaut de securite — tout sous-domaine d'un domaine verifie est sous controle du marchand, mais le commentaire decrit une borne qui n'existe pas.
  • originMatchesStoreDomain existe en deux copies, vitals/collector/beacon-handler.ts et pixels/handler.ts, au comportement identique, verifie sur une matrice de 15 entrees (domaines nus, www., sous-domaines, .myshopify.com, suffixes trompeurs comme boutique.com.evil.io, URL invalide, domaine nul, casse melangee). Le formatage differe (chainage multi-ligne vs une ligne, accolades presentes ou non), donc un grep sur la chaine exacte ne les apparie pas. sanitiseCountry et sanitiseUserAgent sont dupliquees a l'identique. C'est la consequence mecanique du constat 1 : faute de foyer partage, pixels importe de vitals ce qu'il peut (hashSessionId, normalisePathnameToTemplate, deux violations de l'invariant) et copie ce qu'il ne peut pas. Le morceau copie est le controle de securite. A traiter avec l'item 0064.
  • /api/vitals/ingest documente cinq issues et pas de bad_signature. Coherent avec le code ; incoherent avec sa propre section « Trust model » trois lignes plus haut. Se corrige avec l'item 0065.
  • features/reports et features/notes tiennent en un actions.ts chacun (76 et 122 lignes, 4 imports au total). Petits, mais reels et utilises : a ne pas confondre avec les repertoires vides du constat 1 de l'audit ai-platform. Verifie fichier par fichier, pas au compteur.

Ce que cet audit n'a pas couvert

  • src/features/tracking — 97 fichiers, 10 922 lignes, la plus grosse surface du pilier. Seul scan/clients/shopify-admin.ts a ete ouvert, pour la sonde d'imports croises. Le scan lui-meme n'a pas ete audite : ni la surface SSRF (il va chercher des domaines fournis par l'utilisateur), ni le rate-limit, ni api/tracking/artifacts/[scanId]/[name] qui sert des fichiers par nom. C'est le plus gros trou de ce tour.
  • aeo, cro, aov, commerce, systems, action-plan : non ouverts.
  • Les algorithmes de vitals (regression-detector, third-party-blame, budget-check, template-impact) : non exerces ; aucune verification que leurs sorties correspondent a ce que le dashboard en dit.
  • api/preview/** et services/preview-meta/** — dans le perimetre, non ouverts.
  • La chaine reelle du Theme App Extension, storefront-bundle.ts a ete vu, mais aucun bundle produit n'a ete inspecte : je ne peux pas dire ce que le JS livre chez le marchand contient reellement, seulement ce que le code qui le fabrique dit vouloir y mettre.

Un auditeur qui ne borne pas sa portee laisse croire qu'il a tout vu.


Prochain pilier de la rotation : intelligence. Motif : il porte trois invariants ecrits en dur dans le roster, noindex,nofollow sur les pages par-store, liens sortants sans UTM et en rel="nofollow", et « pas de claim d'edge sans ledger PredictionAccuracy ». C'est le pilier dont les invariants sont les plus mecaniquement verifiables du depot, et les deux derniers tours ont montre ou se loge l'ecart : sur integrations, deux des quatre invariants declares nommaient des choses introuvables ; ici, celui qui restait n'avait aucune garde et onze violations. Un invariant ecrit est une promesse verifiable, c'est la seule raison qui rende ces tours rentables.