@starterlib/cli 0.11.6 → 0.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -148,3 +148,31 @@ rxjs, tslib, es-module-shims) s'admet dans `package.json`, avec sa raison ecrite
148
148
  }
149
149
  }
150
150
  ```
151
+
152
+ ## Admissions nommees de la garde de securite (0.11.7)
153
+
154
+ Une dependance de production admise qui porte, sans reglage qui l'en retire, un motif que `securite` refuse
155
+ (son etat dans le stockage du navigateur, l'ecriture HTML ou les styles de ses composants d'interface) s'admet
156
+ NOMMEMENT, motif par motif, avec sa raison :
157
+
158
+ ```json
159
+ {
160
+ "starterWeb": {
161
+ "securiteAdmise": {
162
+ "posthog-js": {
163
+ "motifs": ["stockage", "ecriture-html", "style-par-attribut"],
164
+ "raison": "Mesure du comportement apres accord aux temoins, chargee a la demande, hors du premier ecran.",
165
+ "dependances": []
166
+ }
167
+ }
168
+ }
169
+ }
170
+ ```
171
+
172
+ - motifs admissibles : `stockage`, `ecriture-html`, `insertion-html`, `style-par-attribut` ; jamais `eval`,
173
+ `Function`, la lecture de cookie, une politique Trusted Types ni un puits propre a un remote ;
174
+ - le paquet doit figurer dans `dependancesAdmises` ; la raison compte au moins 40 caracteres ;
175
+ - l'admission ne vaut que pour un morceau livre dont TOUTES les sources sont du paquet (ou des paquets nommes dans
176
+ `dependances`), lues dans la carte de la construction (`browser-stats.json`, `statsJson: true` sur la cible esbuild) :
177
+ le code de l'application reste juge en entier. Charger le paquet par un import dynamique lui donne son propre morceau ;
178
+ - `verify` nomme chaque morceau admis, et avertit d'une admission qui ne couvre aucun morceau.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starterlib/cli",
3
- "version": "0.11.6",
3
+ "version": "0.11.7",
4
4
  "description": "Chaine de verification et de construction des SPA du socle frontal starter : starter-web verify, construire, sprite, manifeste, precompresser. Jamais en dependance de production.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  * paquets construits.
28
28
  */
29
29
  import { readFileSync } from 'node:fs';
30
- import { join } from 'node:path';
30
+ import { dirname, join } from 'node:path';
31
31
  import { fichiers, lireJson, relatif } from '../commun.mjs';
32
32
  import { natureDesFichiers } from '../livraison.mjs';
33
33
 
@@ -40,7 +40,11 @@ const CREATION_DE_POLITIQUE = /trustedTypes\s*\.\s*createPolicy\(\s*['"]([^'"]*)
40
40
  const MOTIFS = [
41
41
  { motif: /\beval\(/, raison: 'eval : interdit par script-src' },
42
42
  { motif: /new Function\(/, raison: 'new Function : interdit par script-src' },
43
- { motif: /localStorage|sessionStorage|indexedDB/, raison: 'stockage navigateur (ADR 0014)' },
43
+ {
44
+ id: 'stockage',
45
+ motif: /localStorage|sessionStorage|indexedDB/,
46
+ raison: 'stockage navigateur (ADR 0014)',
47
+ },
44
48
  { motif: /document\.cookie/, raison: 'lecture de cookie : le cookie de session est HttpOnly' },
45
49
  ];
46
50
 
@@ -59,11 +63,17 @@ const PUITS_DE_LA_SPA = [
59
63
  "bypassSecurityTrust* : la politique angular#unsafe-bypass n'est pas admise, et le desinfectant est contourne",
60
64
  },
61
65
  {
66
+ id: 'ecriture-html',
62
67
  motif: /\.(?:innerHTML|outerHTML)\s*=(?!=)/,
63
68
  raison: 'ecriture HTML directe : puits Trusted Types, et vecteur XSS',
64
69
  },
65
- { motif: /insertAdjacentHTML\s*\(|document\.write\s*\(/, raison: 'ecriture HTML directe' },
66
70
  {
71
+ id: 'insertion-html',
72
+ motif: /insertAdjacentHTML\s*\(|document\.write\s*\(/,
73
+ raison: 'ecriture HTML directe',
74
+ },
75
+ {
76
+ id: 'style-par-attribut',
67
77
  motif: /setAttribute\(\s*["']style["']/,
68
78
  raison: "style pose par setAttribute : refuse sans 'unsafe-inline'",
69
79
  },
@@ -197,6 +207,86 @@ function jugerUnePage(nom, contenu) {
197
207
  return defauts;
198
208
  }
199
209
 
210
+ /**
211
+ * Les motifs qu'une admission nommee peut lever (0.11.7) : ceux qu'une bibliotheque tierce porte sans qu'on puisse
212
+ * les en retirer (stockage de son etat, ecriture HTML et styles de ses composants d'interface). Jamais l'execution de
213
+ * chaine (`eval`, `Function`), la lecture de cookie, les politiques Trusted Types, ni les puits propres a un remote.
214
+ */
215
+ export const MOTIFS_ADMISSIBLES = [
216
+ 'stockage',
217
+ 'ecriture-html',
218
+ 'insertion-html',
219
+ 'style-par-attribut',
220
+ ];
221
+
222
+ /** Une raison ecrite, assez longue pour dire ce que fait le paquet et ou il se charge. */
223
+ const RAISON_MINIMALE = 40;
224
+
225
+ /** Le paquet d'un fichier source de la carte de construction : le dernier `node_modules/<paquet>/`, ou null. */
226
+ export function paquetDeLEntree(entree) {
227
+ const trouves = [...entree.matchAll(/node_modules\/((?:@[^/]+\/)?[^/]+)\//g)];
228
+ return trouves.length === 0 ? null : trouves[trouves.length - 1][1];
229
+ }
230
+
231
+ /**
232
+ * Les admissions nommees d'un projet (`package.json#starterWeb.securiteAdmise`, 0.11.7) : par paquet, les motifs
233
+ * qu'il porte et sa raison ; `dependances` nomme les paquets qui peuvent partager ses morceaux. Le paquet doit etre une
234
+ * dependance de production admise (`starterWeb.dependancesAdmises`) ; un motif hors de {@link MOTIFS_ADMISSIBLES}, une
235
+ * raison trop courte ou un paquet inconnu est un defaut.
236
+ *
237
+ * @returns {{ admissions: Map<string, { motifs: Set<string>, paquets: Set<string> }>, defauts: string[] }}
238
+ */
239
+ export function admissionsDuProjet(paquet) {
240
+ const declarees = paquet.starterWeb?.securiteAdmise ?? {};
241
+ const dependances = paquet.dependencies ?? {};
242
+ const revues = paquet.starterWeb?.dependancesAdmises ?? {};
243
+ const admissions = new Map();
244
+ const defauts = [];
245
+ for (const [nom, admission] of Object.entries(declarees)) {
246
+ const lieu = `starterWeb.securiteAdmise["${nom}"]`;
247
+ if (!(nom in dependances) || !(nom in revues)) {
248
+ defauts.push(
249
+ `${lieu} : le paquet doit etre une dependance de production admise (starterWeb.dependancesAdmises).`,
250
+ );
251
+ continue;
252
+ }
253
+ const motifs = Array.isArray(admission?.motifs) ? admission.motifs : [];
254
+ const horsListe = motifs.filter((motif) => !MOTIFS_ADMISSIBLES.includes(motif));
255
+ if (motifs.length === 0 || horsListe.length > 0) {
256
+ defauts.push(
257
+ `${lieu} : motifs ${motifs.length === 0 ? 'absents' : `hors liste (${horsListe.join(', ')})`} ; admissibles : ` +
258
+ `${MOTIFS_ADMISSIBLES.join(', ')}.`,
259
+ );
260
+ continue;
261
+ }
262
+ if (typeof admission.raison !== 'string' || admission.raison.trim().length < RAISON_MINIMALE) {
263
+ defauts.push(`${lieu} : une raison ecrite d'au moins ${String(RAISON_MINIMALE)} caracteres.`);
264
+ continue;
265
+ }
266
+ const compagnons = Array.isArray(admission.dependances) ? admission.dependances : [];
267
+ admissions.set(nom, { motifs: new Set(motifs), paquets: new Set([nom, ...compagnons]) });
268
+ }
269
+ return { admissions, defauts };
270
+ }
271
+
272
+ /**
273
+ * Les motifs admis pour un morceau livre : ceux d'une admission dont les paquets couvrent TOUTES les sources du
274
+ * morceau (lues dans la carte de la construction). Un morceau qui mele du code de l'application, ou d'un autre paquet,
275
+ * n'en recoit aucun : le code de l'application reste juge en entier.
276
+ *
277
+ * @returns {{ motifs: Set<string>, paquet: string | null }}
278
+ */
279
+ export function motifsAdmisDuMorceau(entrees, admissions) {
280
+ const paquets = entrees.map(paquetDeLEntree);
281
+ if (paquets.length === 0 || paquets.includes(null)) return { motifs: new Set(), paquet: null };
282
+ for (const [nom, admission] of admissions) {
283
+ if (paquets.every((paquet) => admission.paquets.has(paquet))) {
284
+ return { motifs: admission.motifs, paquet: nom };
285
+ }
286
+ }
287
+ return { motifs: new Set(), paquet: null };
288
+ }
289
+
200
290
  /**
201
291
  * Les defauts d'un fichier livre `nom` (relatif au dossier livre) de contenu `contenu`. `role` :
202
292
  * `remote`, `hote` ou `autonome` ; `partage` : le fichier est du code du cadre, partage par la
@@ -213,6 +303,7 @@ export function jugerUnFichierLivre(
213
303
  declarees = [],
214
304
  cadreNeutralise = false,
215
305
  orchestrateurNeutralise = false,
306
+ admis = new Set(),
216
307
  } = {},
217
308
  ) {
218
309
  if (cadreNeutralise) {
@@ -235,10 +326,12 @@ export function jugerUnFichierLivre(
235
326
  if (!/\.(?:m?js|html|css)$/.test(nom)) return defauts;
236
327
 
237
328
  if (!nom.endsWith('.css')) {
238
- for (const { motif, raison } of MOTIFS) {
239
- const tolere = EXCEPTIONS_DU_CADRE.some(
240
- (exception) => exception.fichier.test(nom) && exception.motif.source === motif.source,
241
- );
329
+ for (const { id, motif, raison } of MOTIFS) {
330
+ const tolere =
331
+ (id !== undefined && admis.has(id)) ||
332
+ EXCEPTIONS_DU_CADRE.some(
333
+ (exception) => exception.fichier.test(nom) && exception.motif.source === motif.source,
334
+ );
242
335
  if (!tolere && motif.test(contenu)) defauts.push(`${nom} : ${raison}.`);
243
336
  }
244
337
  for (const [, politique] of contenu.matchAll(CREATION_DE_POLITIQUE)) {
@@ -259,7 +352,8 @@ export function jugerUnFichierLivre(
259
352
 
260
353
  if (!partage) {
261
354
  const puits = role === 'remote' ? [...PUITS_DE_LA_SPA, ...PUITS_D_UN_REMOTE] : PUITS_DE_LA_SPA;
262
- for (const { motif, raison } of puits) {
355
+ for (const { id, motif, raison } of puits) {
356
+ if (id !== undefined && admis.has(id)) continue;
263
357
  if (motif.test(contenu)) defauts.push(`${nom} : ${raison}.`);
264
358
  }
265
359
  }
@@ -299,6 +393,20 @@ export default {
299
393
  '(PLATFORM_WEB_CSP_TRUSTED_TYPES) : le BFF retire le remote tant qu elle n y est pas.',
300
394
  );
301
395
  const defauts = [];
396
+ // 0.11.7 : les admissions nommees, et la carte de la construction qui dit les sources de chaque morceau.
397
+ const { admissions, defauts: defautsDesAdmissions } = admissionsDuProjet(projet.paquet);
398
+ defauts.push(...defautsDesAdmissions);
399
+ const sorties =
400
+ admissions.size === 0
401
+ ? {}
402
+ : (lireJson(join(dirname(projet.sortie), 'browser-stats.json'))?.outputs ?? null);
403
+ if (sorties === null) {
404
+ defauts.push(
405
+ "starterWeb.securiteAdmise : browser-stats.json absent (statsJson: true sur la cible esbuild d'angular.json) : " +
406
+ 'aucune admission ne se juge sans la carte de la construction.',
407
+ );
408
+ }
409
+ const morceauxAdmis = [];
302
410
  // Application autonome : la lecture de cookie du cadre, une seule fois dans tout le livrable.
303
411
  const autonome = projet.role === 'autonome';
304
412
  let lecturesDuCadre = 0;
@@ -314,6 +422,10 @@ export default {
314
422
  lecturesDuCadre += duCadre;
315
423
  const deLOrchestrateur = /\.m?js$/.test(nom) ? stockagesDeLOrchestrateur(contenu) : [];
316
424
  strategies.push(...deLOrchestrateur);
425
+ const entrees = Object.keys(sorties?.[nom]?.inputs ?? {});
426
+ const { motifs: admis, paquet: admisPour } = motifsAdmisDuMorceau(entrees, admissions);
427
+ if (admisPour !== null)
428
+ morceauxAdmis.push(`${nom} (${admisPour} : ${[...admis].join(', ')})`);
317
429
  defauts.push(
318
430
  ...jugerUnFichierLivre(nom, contenu, {
319
431
  role: projet.role,
@@ -321,6 +433,7 @@ export default {
321
433
  declarees,
322
434
  cadreNeutralise: duCadre === 1,
323
435
  orchestrateurNeutralise: deLOrchestrateur.length > 0,
436
+ admis,
324
437
  }),
325
438
  );
326
439
  }
@@ -339,6 +452,16 @@ export default {
339
452
  );
340
453
  }
341
454
  }
455
+ for (const nom of admissions.keys()) {
456
+ if (!morceauxAdmis.some((morceau) => morceau.includes(`(${nom} :`))) {
457
+ avertissements.push(
458
+ `starterWeb.securiteAdmise["${nom}"] : aucun morceau livre ne contient ce seul paquet ; l'admission ne sert a rien.`,
459
+ );
460
+ }
461
+ }
462
+ if (morceauxAdmis.length > 0) {
463
+ avertissements.push(`admissions nommees : ${morceauxAdmis.join(' ; ')}.`);
464
+ }
342
465
  return {
343
466
  defauts,
344
467
  avertissements,