@starterlib/cli 0.10.2

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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Etape `jetons` (lot 157, A0921) : chaque `var(--st-*)` SANS repli lue par une feuille de la SPA
3
+ * existe dans les feuilles de `@starterlib/tokens` INSTALLE, ou dans la liste des jetons poses a
4
+ * l'execution. Une reference avec repli (`var(--st-x, 1.5rem)`) est toleree : le repli s'applique
5
+ * quand le jeton manque.
6
+ *
7
+ * Le defaut qu'elle ferme : `font-family: var(--st-police-texte)`, un jeton qui n'a jamais existe.
8
+ * Une declaration `var()` vers une propriete inexistante est invalide au calcul, et la propriete
9
+ * retombe sur sa valeur initiale : le corps de la coquille s'affichait dans la police par defaut du
10
+ * navigateur, sans une erreur nulle part.
11
+ *
12
+ * Sont lues les feuilles `.scss` et `.css` sous `src/`, et les styles EN LIGNE des composants
13
+ * (`styles: \`...\``), que la copie de la coquille ne voyait pas.
14
+ */
15
+ import { readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { fichiers, relatif } from '../commun.mjs';
18
+
19
+ /** Poses a l'execution par un composant du socle, donc absents des feuilles engendrees. */
20
+ const POSES_A_L_EXECUTION = new Set([
21
+ // st-app-shell mesure l'en-tete collant et publie sa hauteur (scroll-padding de la page).
22
+ '--st-hauteur-entete',
23
+ ]);
24
+
25
+ /** Une lecture de jeton : son nom, et le caractere qui suit (`,` : avec repli ; `)` : sans). */
26
+ const LECTURE = /var\(\s*(--st-[A-Za-z0-9-]+)\s*([,)])/g;
27
+ /** Une declaration de jeton dans une feuille du socle. */
28
+ const DECLARATION = /(--st-[A-Za-z0-9-]+)\s*:/g;
29
+ /** Les styles en ligne d'un composant : `styles: \`...\`` ou `styles: [\`...\`]`. */
30
+ const STYLES_EN_LIGNE = /\bstyles\s*:\s*\[?\s*`([^`]*)`/g;
31
+
32
+ /**
33
+ * Les commentaires retires, longueur conservee : un jeton cite dans un commentaire n'est pas lu, et
34
+ * les numeros de ligne restent justes. Un `//` precede de `:` est une adresse, pas un commentaire.
35
+ */
36
+ function sansCommentaires(texte) {
37
+ const blanc = (morceau) => morceau.replace(/[^\n]/g, ' ');
38
+ return texte
39
+ .replace(/\/\*[\s\S]*?\*\//g, blanc)
40
+ .replace(/(^|[^:])(\/\/[^\n]*)/g, (_, avant, commentaire) => avant + blanc(commentaire));
41
+ }
42
+
43
+ function ligne(texte, indice) {
44
+ return texte.slice(0, indice).split('\n').length;
45
+ }
46
+
47
+ /** Les defauts des `feuilles` (`{ nom, texte }`) contre les jetons `definis`. */
48
+ export function jugerLesJetons(feuilles, definis) {
49
+ const defauts = [];
50
+ let lues = 0;
51
+ for (const { nom, texte } of feuilles) {
52
+ const propre = sansCommentaires(texte);
53
+ for (const lecture of propre.matchAll(LECTURE)) {
54
+ const [, jeton, suite] = lecture;
55
+ if (suite === ',') continue;
56
+ lues += 1;
57
+ if (!definis.has(jeton) && !POSES_A_L_EXECUTION.has(jeton)) {
58
+ defauts.push(
59
+ `${nom}:${String(ligne(propre, lecture.index))} : var(${jeton}) sans repli, jeton absent ` +
60
+ 'de @starterlib/tokens. Corriger le nom, ou poser un repli (var(--st-x, valeur)).',
61
+ );
62
+ }
63
+ }
64
+ }
65
+ return { defauts, lues };
66
+ }
67
+
68
+ export default {
69
+ nom: 'jetons',
70
+ roles: ['remote', 'hote'],
71
+ executer(projet) {
72
+ const socle = join(projet.racine, 'node_modules', '@starterlib', 'tokens', 'styles');
73
+ const definis = new Set();
74
+ for (const feuille of fichiers(socle).filter((chemin) => chemin.endsWith('.css'))) {
75
+ for (const [, nom] of readFileSync(feuille, 'utf8').matchAll(DECLARATION)) definis.add(nom);
76
+ }
77
+ if (definis.size === 0) {
78
+ return {
79
+ defauts: [`aucun jeton lu dans ${relatif(projet.racine, socle)} : pnpm install d'abord.`],
80
+ };
81
+ }
82
+
83
+ const sources = join(projet.racine, 'src');
84
+ const feuilles = [];
85
+ for (const chemin of fichiers(sources)) {
86
+ const nom = relatif(projet.racine, chemin);
87
+ if (chemin.endsWith('.scss') || chemin.endsWith('.css')) {
88
+ feuilles.push({ nom, texte: readFileSync(chemin, 'utf8') });
89
+ } else if (chemin.endsWith('.ts') && !chemin.endsWith('.spec.ts')) {
90
+ for (const [, style] of readFileSync(chemin, 'utf8').matchAll(STYLES_EN_LIGNE)) {
91
+ feuilles.push({ nom, texte: style });
92
+ }
93
+ }
94
+ }
95
+ // Une garde qui n'a rien lu ne prouve rien : zero feuille est un defaut, pas un succes.
96
+ if (feuilles.length === 0) {
97
+ return {
98
+ defauts: ['aucune feuille ni style en ligne sous src/ : la garde n a rien analyse.'],
99
+ };
100
+ }
101
+ const { defauts, lues } = jugerLesJetons(feuilles, definis);
102
+ return {
103
+ defauts,
104
+ resume: `${String(lues)} lecture(s) sans repli dans ${String(feuilles.length)} feuille(s), toutes definies.`,
105
+ };
106
+ },
107
+ };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Etape `manifeste` : le manifeste CONSTRUIT respecte le contrat que le BFF verifie a l'agregation
3
+ * — ici, avant la livraison, quand c'est encore gratuit. Un manifeste refuse par le BFF est un
4
+ * remote invisible, sans autre symptome qu'un compteur.
5
+ *
6
+ * Les regles ne sont plus recopiees (lot 177, constats A0817 et A0906) : l'etape applique celles
7
+ * que `@starterlib/core` INSTALLE publie — `validerLeManifesteDeRemote`, miroir de
8
+ * ValidationDuManifeste du socle Java, et le schema JSON, plus strict (un champ inconnu y est une
9
+ * faute de frappe). Un corpus commun (`schemas/corpus-de-manifestes.json`) prouve que le schema,
10
+ * le TypeScript et le Java rendent le meme verdict.
11
+ *
12
+ * S'y ajoute ce qu'aucun des deux ne peut voir : le nom est le meme partout ou il sert de cle
13
+ * (`angular.json`, `dist/<nom>/browser`, `manifest.source.json`), les actifs annonces sont livres,
14
+ * et chaque module de route est expose par `remoteEntry.json`.
15
+ *
16
+ * Pour l'hote, le manifeste de la coquille : ce que le BFF lit pour dire aux remotes quel train il
17
+ * embarque (`CoquilleDeclaree`).
18
+ */
19
+ import { existsSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { lireJson } from '../commun.mjs';
22
+ import { validerSelonLeSchema } from '../schema.mjs';
23
+ import { contratDeFederation } from '../socle-installe.mjs';
24
+
25
+ const VERSION_EXACTE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
26
+ const TRAIN = /^\d+\.\d+$/;
27
+
28
+ const chaine = (valeur) => typeof valeur === 'string' && valeur.length > 0;
29
+
30
+ /**
31
+ * Ce que ni le schema ni le validateur du socle ne voient : les actifs annonces et livres, les
32
+ * modules des routes exposes, `pleinePage` tel qu'attendu. `exposes` : cles de `remoteEntry.json` ;
33
+ * `existe(chemin)` : l'actif est-il livre.
34
+ */
35
+ export function jugerLaLivraison(m, { attendu, exposes, existe }) {
36
+ const defauts = [];
37
+ const prefixe = `/ui/${String(m.nom)}/`;
38
+ if (typeof m.pleinePage === 'boolean' && m.pleinePage !== attendu.pleinePage) {
39
+ defauts.push(`pleinePage ${String(m.pleinePage)}, attendu ${String(attendu.pleinePage)}.`);
40
+ }
41
+ for (const route of Array.isArray(m.routes) ? m.routes : []) {
42
+ if (chaine(route.module) && !exposes.has(route.module)) {
43
+ defauts.push(
44
+ `route ${String(route.prefixe)} : ${route.module} n'est pas expose par remoteEntry.json.`,
45
+ );
46
+ }
47
+ }
48
+ const annonces = [m.remoteEntry, ...Object.values(m.i18n?.catalogues ?? {}), m.sprite].filter(
49
+ chaine,
50
+ );
51
+ for (const url of annonces) {
52
+ if (url.startsWith(prefixe) && !existe(url.slice(prefixe.length))) {
53
+ defauts.push(`fichier annonce absent : ${url}.`);
54
+ }
55
+ }
56
+ return defauts;
57
+ }
58
+
59
+ /** Le nom est le meme partout ou il sert de cle. */
60
+ function jugerLesNoms(projet) {
61
+ const defauts = [];
62
+ const { nom } = projet;
63
+ if (projet.source?.nom !== nom) {
64
+ defauts.push(`manifest.source.json : nom ${String(projet.source?.nom)}, attendu ${nom}.`);
65
+ }
66
+ if (projet.nomDuProjetAngular !== nom) {
67
+ defauts.push(`angular.json : projet ${projet.nomDuProjetAngular}, attendu ${nom}.`);
68
+ }
69
+ if (projet.sortieRelative !== `dist/${nom}/browser`) {
70
+ defauts.push(`angular.json : sortie ${projet.sortieRelative}, attendu dist/${nom}/browser.`);
71
+ }
72
+ return defauts;
73
+ }
74
+
75
+ function manifesteDeLHote(m) {
76
+ const defauts = [];
77
+ if (m.nom !== 'socle') defauts.push(`nom ${String(m.nom)}, attendu socle.`);
78
+ if (!VERSION_EXACTE.test(String(m.version))) defauts.push('version : exacte attendue.');
79
+ if (!VERSION_EXACTE.test(String(m.socle?.construit))) {
80
+ defauts.push('socle.construit : version exacte attendue.');
81
+ }
82
+ if (!TRAIN.test(String(m.angular))) defauts.push('angular : train attendu.');
83
+ return { defauts, resume: `coquille ${String(m.version)}, socle ${String(m.socle?.construit)}.` };
84
+ }
85
+
86
+ export default {
87
+ nom: 'manifeste',
88
+ roles: ['remote', 'hote'],
89
+ async executer(projet) {
90
+ const m = lireJson(join(projet.sortie, 'manifest.json'));
91
+ if (m === null) {
92
+ return { defauts: [`${projet.sortieRelative}/manifest.json absent : construire d'abord.`] };
93
+ }
94
+ if (projet.role === 'hote') return manifesteDeLHote(m);
95
+
96
+ const entree = lireJson(join(projet.sortie, 'remoteEntry.json'));
97
+ if (entree === null) {
98
+ return {
99
+ defauts: [`${projet.sortieRelative}/remoteEntry.json absent : construire d'abord.`],
100
+ };
101
+ }
102
+ const contrat = await contratDeFederation(projet.racine);
103
+ const defauts = [
104
+ ...jugerLesNoms(projet),
105
+ ...contrat.validerLeManifesteDeRemote(m, projet.nom),
106
+ ...validerSelonLeSchema(contrat.schema, m).map((probleme) => `schema : ${probleme}`),
107
+ ...jugerLaLivraison(m, {
108
+ attendu: { pleinePage: projet.pleinePage },
109
+ exposes: new Set((entree.exposes ?? []).map((expose) => expose.key)),
110
+ existe: (chemin) => existsSync(join(projet.sortie, chemin)),
111
+ }),
112
+ ];
113
+ return {
114
+ defauts,
115
+ resume:
116
+ `${String(m.nom)} ${String(m.version)}, ${String(m.routes?.length ?? 0)} route(s), ` +
117
+ `contrat et schema de @starterlib/core ${contrat.version}.`,
118
+ };
119
+ },
120
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Les etapes qui DELEGUENT a l'outillage de la SPA : format, lint, test, contrat.
3
+ *
4
+ * Le CLI ne remplace ni Prettier, ni ESLint, ni le lanceur d'essais : il les appelle dans l'ordre du
5
+ * plan SPA §3, depuis le `node_modules` de la SPA (`pnpm exec`), pour que chacun lise la
6
+ * configuration du depot. Le lint verifie d'abord, lui-meme, le prefixe des selecteurs : c'est la
7
+ * regle de nommage du plan §4 (`<abrege>-*`, jamais `st-`), que chaque configuration ESLint
8
+ * recopiee ecrivait a sa facon jusqu'a `@starterlib/eslint-config` (lot 178).
9
+ */
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { executer, fichiers, relatif } from '../commun.mjs';
13
+
14
+ /** Un prefixe de selecteur : deux a huit minuscules ou chiffres, jamais celui du socle. */
15
+ const PREFIXE = /^[a-z][a-z0-9]{1,7}$/;
16
+ /** Le selecteur d'element d'un composant : `selector: 'ct-produits'`. */
17
+ const SELECTEUR_D_ELEMENT = /\bselector\s*:\s*['"]([a-z][a-z0-9]*-[a-z0-9-]*)['"]/g;
18
+
19
+ /** Les defauts de prefixe : `sources` est une liste de `{ nom, texte }`. */
20
+ export function jugerLesSelecteurs(prefixe, sources) {
21
+ if (prefixe === null || prefixe === undefined) {
22
+ return ['prefixe de selecteur absent : prefix du projet dans angular.json, ou --prefixe.'];
23
+ }
24
+ if (prefixe === 'st') return ['prefixe "st" : reserve aux composants du socle.'];
25
+ if (!PREFIXE.test(prefixe)) return [`prefixe "${prefixe}" : deux a huit minuscules attendues.`];
26
+ const defauts = [];
27
+ for (const { nom, texte } of sources) {
28
+ for (const [, selecteur] of texte.matchAll(SELECTEUR_D_ELEMENT)) {
29
+ if (!selecteur.startsWith(`${prefixe}-`)) {
30
+ defauts.push(`${nom} : selecteur "${selecteur}" hors du prefixe "${prefixe}-".`);
31
+ }
32
+ }
33
+ }
34
+ return defauts;
35
+ }
36
+
37
+ /** Le resultat d'une commande deleguee : `resume` si elle reussit, son code sinon. */
38
+ function deleguer(projet, arguments_, resume) {
39
+ const code = executer('pnpm', arguments_, projet.racine);
40
+ return code === 0
41
+ ? { defauts: [], resume }
42
+ : { defauts: [`pnpm ${arguments_.join(' ')} : code ${String(code)}.`] };
43
+ }
44
+
45
+ export const format = {
46
+ nom: 'format',
47
+ roles: ['remote', 'hote'],
48
+ executer: (projet) =>
49
+ deleguer(projet, ['exec', 'prettier', '--check', '.'], 'prettier --check . conforme.'),
50
+ };
51
+
52
+ /** Les fichiers de configuration que stylelint lit a la racine d'une SPA. */
53
+ const CONFIGURATIONS_STYLELINT = [
54
+ '.stylelintrc.json',
55
+ '.stylelintrc',
56
+ 'stylelint.config.mjs',
57
+ 'stylelint.config.js',
58
+ ];
59
+
60
+ export const lint = {
61
+ nom: 'lint',
62
+ roles: ['remote', 'hote'],
63
+ preparation: 'sprite',
64
+ executer(projet) {
65
+ const sources = fichiers(join(projet.racine, 'src'))
66
+ .filter((chemin) => chemin.endsWith('.ts') && !chemin.endsWith('.spec.ts'))
67
+ .map((chemin) => ({
68
+ nom: relatif(projet.racine, chemin),
69
+ texte: readFileSync(chemin, 'utf8'),
70
+ }));
71
+ const selecteurs = jugerLesSelecteurs(projet.prefixe, sources);
72
+ if (selecteurs.length > 0) return { defauts: selecteurs };
73
+ // Les feuilles sous stylelint (lot 178) : jetons, pixels, plans, ordre du DOM. La configuration
74
+ // etend celle du socle (@starterlib/eslint-config/stylelint) ; sans elle, rien n'est garde.
75
+ if (!CONFIGURATIONS_STYLELINT.some((nom) => existsSync(join(projet.racine, nom)))) {
76
+ return {
77
+ defauts: [
78
+ 'configuration stylelint absente : .stylelintrc.json qui etend @starterlib/eslint-config/stylelint.',
79
+ ],
80
+ };
81
+ }
82
+ const eslint = deleguer(
83
+ projet,
84
+ ['exec', 'eslint', '.'],
85
+ `selecteurs en ${String(projet.prefixe)}-, eslint . conforme.`,
86
+ );
87
+ if (eslint.defauts.length > 0) return eslint;
88
+ return deleguer(
89
+ projet,
90
+ ['exec', 'stylelint', 'src/**/*.{scss,css}', '--allow-empty-input'],
91
+ `selecteurs en ${String(projet.prefixe)}-, eslint . et stylelint conformes.`,
92
+ );
93
+ },
94
+ };
95
+
96
+ export const test = {
97
+ nom: 'test',
98
+ roles: ['remote', 'hote'],
99
+ preparation: 'sprite',
100
+ executer: (projet) => deleguer(projet, ['exec', 'ng', 'test', '--no-watch'], 'ng test conforme.'),
101
+ };
102
+
103
+ /**
104
+ * `contrat` : le client d'API engendre depuis le contrat du service, sans difference (lot 175).
105
+ * Tant que la SPA n'a pas de script `contrat:check`, l'etape est sautee, et le dit.
106
+ */
107
+ export const contrat = {
108
+ nom: 'contrat',
109
+ roles: ['remote', 'hote'],
110
+ executer(projet) {
111
+ if (projet.paquet.scripts?.['contrat:check'] === undefined) {
112
+ return { sautee: 'aucun script contrat:check (client engendre : lot 175).' };
113
+ }
114
+ return deleguer(projet, ['run', 'contrat:check'], 'client engendre sans difference.');
115
+ },
116
+ };
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Etape `securite` : ce qui est LIVRE (`dist/<nom>/browser`), pas les sources : c'est le code
3
+ * construit qui tourne sous la politique de securite du contenu, dependances transitives comprises
4
+ * — la ou le lint ne voit que les sources. Chaque motif ferme un risque nomme (ADR 0029).
5
+ *
6
+ * Pour tout fichier livre, cadre compris :
7
+ * - eval, new Function, stockage du navigateur, lecture de cookie ;
8
+ * - politiques Trusted Types comparees a la liste EXACTE du BFF
9
+ * (`platform.web.headers.csp.trusted-types-policies`) ;
10
+ * - cartes de source (et leurs variantes compressees), script dans un SVG ;
11
+ * - dans une page HTML : balise meta de politique de securite, script ou feuille EN LIGNE.
12
+ *
13
+ * Pour le code de la SPA (tout ce que `remoteEntry.json` ne declare pas partage, lot 177, constats
14
+ * A0812, A0843, A0889) :
15
+ * - bypassSecurityTrust*, ecriture HTML (innerHTML, outerHTML, insertAdjacentHTML,
16
+ * document.write), style pose par setAttribute, Function( et minuterie sur une chaine ;
17
+ * - feuilles et ressources d'un autre domaine (`@import`, `url(//...)`) ;
18
+ * - un remote seulement : fetch et XMLHttpRequest (tout passe par le HttpClient de l'hote, regle
19
+ * 13), `:root` et `!important` (un remote ne redefinit ni ne force la cascade du socle), et les
20
+ * marqueurs de l'hote de developpement (A0840 : fixtures, comptes et backend simule livres).
21
+ *
22
+ * Le code du cadre (Angular, le socle, leurs morceaux internes) ecrit innerHTML, appelle fetch et
23
+ * pose `:root` : il n'est pas juge sur ces puits-la, et la garde de securite du socle juge deja ses
24
+ * paquets construits.
25
+ */
26
+ import { readFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { fichiers, lireJson, relatif } from '../commun.mjs';
29
+ import { natureDesFichiers } from '../livraison.mjs';
30
+
31
+ /** Les politiques que la CSP du BFF laisse creer (application.yml du BFF). */
32
+ export const POLITIQUES_ADMISES = ['angular', 'angular#components', 'es-module-shims', 'nfo'];
33
+
34
+ const CREATION_DE_POLITIQUE = /trustedTypes\s*\.\s*createPolicy\(\s*['"]([^'"]*)['"]/g;
35
+
36
+ /** Pour tout fichier livre, cadre compris. */
37
+ const MOTIFS = [
38
+ { motif: /\beval\(/, raison: 'eval : interdit par script-src' },
39
+ { motif: /new Function\(/, raison: 'new Function : interdit par script-src' },
40
+ { motif: /localStorage|sessionStorage|indexedDB/, raison: 'stockage navigateur (ADR 0014)' },
41
+ { motif: /document\.cookie/, raison: 'lecture de cookie : le cookie de session est HttpOnly' },
42
+ ];
43
+
44
+ /** Un SVG peut porter du script : le sprite est servi en meme origine. */
45
+ const MOTIFS_SVG = [
46
+ { motif: /<script/i, raison: 'script dans un SVG livre' },
47
+ { motif: /\son\w+\s*=/i, raison: "gestionnaire d'evenement en ligne dans un SVG" },
48
+ { motif: /javascript:/i, raison: 'adresse javascript: dans un SVG' },
49
+ ];
50
+
51
+ /** Le code de la SPA, hote ou remote (ADR 0029, regle 6). */
52
+ const PUITS_DE_LA_SPA = [
53
+ {
54
+ motif: /bypassSecurityTrust(?:Html|Style|Script|Url|ResourceUrl)\b/,
55
+ raison:
56
+ "bypassSecurityTrust* : la politique angular#unsafe-bypass n'est pas admise, et le desinfectant est contourne",
57
+ },
58
+ {
59
+ motif: /\.(?:innerHTML|outerHTML)\s*=(?!=)/,
60
+ raison: 'ecriture HTML directe : puits Trusted Types, et vecteur XSS',
61
+ },
62
+ { motif: /insertAdjacentHTML\s*\(|document\.write\s*\(/, raison: 'ecriture HTML directe' },
63
+ {
64
+ motif: /setAttribute\(\s*["']style["']/,
65
+ raison: "style pose par setAttribute : refuse sans 'unsafe-inline'",
66
+ },
67
+ { motif: /(?<![\w$.])Function\s*\(/, raison: 'Function( : execution de chaine, interdite' },
68
+ {
69
+ motif: /\bset(?:Timeout|Interval)\(\s*["'`]/,
70
+ raison: 'minuterie sur une chaine : execution de chaine, interdite',
71
+ },
72
+ {
73
+ motif: /@import\s+(?:url\()?\s*["']?(?:https?:)?\/\//,
74
+ raison: "feuille importee d'un autre domaine",
75
+ },
76
+ {
77
+ // Hors d'un `@import`, deja dit par la regle precedente.
78
+ motif: /(?<!@import\s*)url\(\s*["']?(?:https?:)?\/\//,
79
+ raison: "ressource d'un autre domaine dans une feuille",
80
+ },
81
+ ];
82
+
83
+ /** Le code d'un remote seulement. */
84
+ const PUITS_D_UN_REMOTE = [
85
+ {
86
+ motif: /\bfetch\(|new XMLHttpRequest\(/,
87
+ raison: "appel reseau hors du HttpClient de l'hote (ADR 0029, regle 13)",
88
+ },
89
+ { motif: /:root\b/, raison: ':root : un remote ne redefinit pas les jetons du socle' },
90
+ { motif: /!important/, raison: '!important : un remote ne force pas la cascade du socle' },
91
+ {
92
+ motif: /\bbootstrapApplication\b/,
93
+ raison:
94
+ "un remote ne demarre jamais seul en production : l'hote de developpement est livre (A0840)",
95
+ },
96
+ {
97
+ motif: /selectors:\[\["[a-z][a-z0-9]*-[a-z0-9-]*-dev"/,
98
+ raison: "composant de l'hote de developpement livre en production (A0840)",
99
+ },
100
+ { motif: /\bhote-dev\b/, raison: "marqueur de l'hote de developpement livre (A0840)" },
101
+ ];
102
+
103
+ /**
104
+ * Les politiques que le CADRE declare sans que l'application les cree. `angular#unsafe-bypass` est
105
+ * ecrite dans le code d'Angular (morceau partage `_angular_core`), et n'est creee que si
106
+ * l'application appelle `bypassSecurityTrust*` — ce que le socle interdit : son absence de la liste
107
+ * du BFF fait echouer un tel appel a l'ecran, sous la politique reelle. La trouver ailleurs que dans
108
+ * ce morceau reste un defaut. (Vu au premier passage du CLI sur les quatre SPA, lot 174 : la copie
109
+ * la laissait passer par son filtre de prefixe.)
110
+ */
111
+ const POLITIQUES_DU_CADRE = [{ fichier: /^_angular_core\./, politique: 'angular#unsafe-bypass' }];
112
+
113
+ /**
114
+ * Ce que le CADRE lit, et que l'application ne lit pas. Un morceau partage par la federation
115
+ * (`_angular_*`) contient le code d'Angular tel quel ; chaque exception nomme le fichier ET le motif.
116
+ */
117
+ const EXCEPTIONS_DU_CADRE = [
118
+ {
119
+ fichier: /^_angular_platform_browser\./,
120
+ motif: /document\.cookie/,
121
+ // `DomAdapter.getCookie` : lu par l'extracteur XSRF d'Angular, qui cherche un cookie
122
+ // `XSRF-TOKEN` que le BFF ne pose jamais (le jeton anti-falsification vient de /bff/session).
123
+ raison: 'lecture de cookie par le cadre, sans cookie lisible a lire',
124
+ },
125
+ ];
126
+
127
+ /** Les types d'un script EN LIGNE qui ne s'executent pas : des donnees (`esms-options`, cartes). */
128
+ const SCRIPTS_DE_DONNEES = new Set([
129
+ 'esms-options',
130
+ 'importmap',
131
+ 'importmap-shim',
132
+ 'application/json',
133
+ 'application/ld+json',
134
+ ]);
135
+
136
+ /** Directives qu'une balise meta ignore : les y ecrire laisse croire a une protection absente. */
137
+ const IGNOREES_PAR_UNE_BALISE = ['frame-ancestors', 'report-uri', 'report-to', 'sandbox'];
138
+
139
+ function jugerUnePage(nom, contenu) {
140
+ const defauts = [];
141
+ for (const [, attributs] of contenu.matchAll(/<script\b([^>]*)>/gi)) {
142
+ if (/\bsrc\s*=/i.test(attributs)) continue;
143
+ const type = /\btype\s*=\s*["']?([^"'\s>]+)/i.exec(attributs)?.[1]?.toLowerCase() ?? '';
144
+ if (!SCRIPTS_DE_DONNEES.has(type)) {
145
+ defauts.push(`${nom} : script en ligne (type "${type}") : refuse par script-src sans nonce.`);
146
+ }
147
+ }
148
+ if (/<style\b/i.test(contenu)) {
149
+ defauts.push(`${nom} : feuille en ligne (<style>) : refusee par style-src sans nonce.`);
150
+ }
151
+ for (const [balise] of contenu.matchAll(
152
+ /<meta[^>]+http-equiv=["']Content-Security-Policy[^>]*>/gi,
153
+ )) {
154
+ const ignorees = IGNOREES_PAR_UNE_BALISE.filter((directive) => balise.includes(directive));
155
+ defauts.push(
156
+ `${nom} : politique de securite du contenu en balise meta ecrite dans le livrable (ADR 0015 : ` +
157
+ 'le serveur la pose ; lot 122)' +
158
+ (ignorees.length === 0 ? '.' : ` ; ${ignorees.join(', ')} y est ignore par le navigateur.`),
159
+ );
160
+ }
161
+ return defauts;
162
+ }
163
+
164
+ /**
165
+ * Les defauts d'un fichier livre `nom` (relatif au dossier livre) de contenu `contenu`. `role` :
166
+ * `remote` ou `hote` ; `partage` : le fichier est du code du cadre, partage par la federation.
167
+ */
168
+ export function jugerUnFichierLivre(nom, contenu, { role = 'remote', partage = false } = {}) {
169
+ // Une carte, ou sa variante compressee : `/x.js.map.br` se servirait aussi bien que l'original.
170
+ if (/\.map(?:\.br|\.gz)?$/.test(nom)) {
171
+ return [`${nom} : carte de source livree (sourceMap doit rester false en production).`];
172
+ }
173
+ const defauts = [];
174
+ if (nom.endsWith('.svg')) {
175
+ for (const { motif, raison } of MOTIFS_SVG) {
176
+ if (motif.test(contenu)) defauts.push(`${nom} : ${raison}.`);
177
+ }
178
+ return defauts;
179
+ }
180
+ if (!/\.(?:m?js|html|css)$/.test(nom)) return defauts;
181
+
182
+ if (!nom.endsWith('.css')) {
183
+ for (const { motif, raison } of MOTIFS) {
184
+ const tolere = EXCEPTIONS_DU_CADRE.some(
185
+ (exception) => exception.fichier.test(nom) && exception.motif.source === motif.source,
186
+ );
187
+ if (!tolere && motif.test(contenu)) defauts.push(`${nom} : ${raison}.`);
188
+ }
189
+ for (const [, politique] of contenu.matchAll(CREATION_DE_POLITIQUE)) {
190
+ const duCadre = POLITIQUES_DU_CADRE.some(
191
+ (exception) => exception.fichier.test(nom) && exception.politique === politique,
192
+ );
193
+ if (!POLITIQUES_ADMISES.includes(politique) && !duCadre) {
194
+ defauts.push(
195
+ `${nom} : politique Trusted Types hors liste ("${politique}" ; admises : ` +
196
+ `${POLITIQUES_ADMISES.join(', ')}). La declarer au BFF avant livraison.`,
197
+ );
198
+ }
199
+ }
200
+ }
201
+ if (nom.endsWith('.html')) defauts.push(...jugerUnePage(nom, contenu));
202
+
203
+ if (!partage) {
204
+ const puits = role === 'remote' ? [...PUITS_DE_LA_SPA, ...PUITS_D_UN_REMOTE] : PUITS_DE_LA_SPA;
205
+ for (const { motif, raison } of puits) {
206
+ if (motif.test(contenu)) defauts.push(`${nom} : ${raison}.`);
207
+ }
208
+ }
209
+ return defauts;
210
+ }
211
+
212
+ export default {
213
+ nom: 'securite',
214
+ roles: ['remote', 'hote'],
215
+ executer(projet) {
216
+ // Les variantes precompressees derivent des fichiers lus ; celles d'une carte sont signalees.
217
+ const livres = fichiers(projet.sortie).filter(
218
+ (chemin) => !/\.(?:br|gz)$/.test(chemin) || /\.map\.(?:br|gz)$/.test(chemin),
219
+ );
220
+ if (livres.length === 0) {
221
+ return {
222
+ defauts: [`aucune construction dans ${projet.sortieRelative} : construire d'abord.`],
223
+ };
224
+ }
225
+ const { partages } = natureDesFichiers(lireJson(join(projet.sortie, 'remoteEntry.json')));
226
+ const defauts = [];
227
+ for (const chemin of livres) {
228
+ const nom = relatif(projet.sortie, chemin);
229
+ const contenu = /\.(?:m?js|html|svg|css)$/.test(nom) ? readFileSync(chemin, 'utf8') : '';
230
+ defauts.push(
231
+ ...jugerUnFichierLivre(nom, contenu, { role: projet.role, partage: partages.has(nom) }),
232
+ );
233
+ }
234
+ return {
235
+ defauts,
236
+ resume: `${String(livres.length)} fichier(s) livre(s) dont ${String(partages.size)} du cadre partage, rien a signaler.`,
237
+ };
238
+ },
239
+ };