@nodefony/devkit 10.0.0-beta.2 → 10.0.0-beta.3
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 +5 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/controllers/DevkitController.js +2 -2
- package/dist/nodefony/controllers/McpController.js +71 -6
- package/dist/nodefony/service/DevkitService.js +2 -2
- package/dist/nodefony/src/card.js +1 -2
- package/dist/types/nodefony/controllers/McpController.d.ts +12 -1
- package/docs/index.md +62 -14
- package/package.json +9 -9
- package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
- package/skills/nodefony-add-service/SKILL.md +11 -0
- package/skills/nodefony-authentication/SKILL.md +234 -0
- package/skills/nodefony-browser/SKILL.md +45 -21
- package/skills/nodefony-browser/references/sondes.md +13 -0
- package/skills/nodefony-browser/scripts/audit.mjs +59 -10
- package/skills/nodefony-browser/scripts/inspect.mjs +122 -4
- package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
- package/skills/nodefony-browser/scripts/lib/probes.mjs +166 -7
- package/skills/nodefony-dev/SKILL.md +47 -81
- package/skills/nodefony-dev/scripts/docs.mjs +21 -5
- package/skills/nodefony-devops/SKILL.md +16 -13
- package/skills/nodefony-devops/references/compose.md +8 -0
- package/skills/nodefony-devops/references/frontal.md +5 -1
- package/skills/nodefony-devops/references/secrets.md +18 -4
- package/skills/nodefony-devops/references/variables.md +52 -19
- package/skills/nodefony-migrate-schema/SKILL.md +5 -2
- package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
- package/skills/nodefony-protect-route/SKILL.md +118 -17
- package/skills/nodefony-security-review/SKILL.md +127 -0
- package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
- package/skills/nodefony-troubleshoot/SKILL.md +151 -0
- package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
- package/skills/nodefony-upgrade/SKILL.md +130 -0
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Relit le diff d'une application Nodefony et nomme les fautes de sécurité qu'un motif suffit à voir — fichier, ligne, raison et geste qui corrige.
|
|
4
|
+
*
|
|
5
|
+
* Une relecture « à l'œil » d'un diff passe à côté de ce qui ne se voit pas :
|
|
6
|
+
* une route ajoutée sous une zone qui laisse passer l'anonyme a l'air parfaite,
|
|
7
|
+
* une mutation sans jeton anti-falsification aussi. Ce script lit chaque ligne
|
|
8
|
+
* AJOUTÉE, applique les règles qu'un motif tranche, et croise chaque route
|
|
9
|
+
* nouvelle avec les zones du pare-feu déclarées dans `nodefony/config/` — ce
|
|
10
|
+
* que ni `doctor` ni `inspect` ne font.
|
|
11
|
+
*
|
|
12
|
+
* Il ne remplace pas le jugement : une route publique peut être voulue, une
|
|
13
|
+
* variable lue hors de `env.ts` peut être anodine. Il rend la LISTE à trancher,
|
|
14
|
+
* et dit ce qu'il ne voit pas (dépendances, autorisation sur l'objet).
|
|
15
|
+
*
|
|
16
|
+
* Par défaut il relit le travail en cours (`git diff HEAD` + fichiers non
|
|
17
|
+
* suivis) ; s'il n'y en a pas, le dernier commit — et il le dit.
|
|
18
|
+
*
|
|
19
|
+
* `@usage` node node_modules/@nodefony/devkit/skills/nodefony-security-review/scripts/review-diff.mjs
|
|
20
|
+
* `@usage` node node_modules/@nodefony/devkit/skills/nodefony-security-review/scripts/review-diff.mjs --base main
|
|
21
|
+
* `@usage` node node_modules/@nodefony/devkit/skills/nodefony-security-review/scripts/review-diff.mjs --staged
|
|
22
|
+
* `@option` --base - relit tout ce qui a changé depuis cette référence git, commits ET travail en cours
|
|
23
|
+
* `@option` --staged - relit seulement ce qui est indexé (`git add`)
|
|
24
|
+
* `@option` --diff - relit un fichier de patch (`git diff > x.patch`) au lieu du dépôt
|
|
25
|
+
* `@option` --json - la même réponse, sérialisée, pour rechaîner
|
|
26
|
+
* `@option` --root - racine de l'application (défaut : le dossier courant)
|
|
27
|
+
* `@output` les points relevés, fautes d'abord, chacun avec fichier:ligne, la raison et le geste ; puis ce que le script ne voit pas
|
|
28
|
+
*
|
|
29
|
+
* Sorties : `0` rien à signaler · `1` au moins un point · `64` usage · `66` aucun diff lisible.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
existsSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
readdirSync,
|
|
36
|
+
openSync,
|
|
37
|
+
fstatSync,
|
|
38
|
+
closeSync,
|
|
39
|
+
realpathSync,
|
|
40
|
+
} from "node:fs";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import process from "node:process";
|
|
43
|
+
import { spawnSync } from "node:child_process";
|
|
44
|
+
import { fileURLToPath } from "node:url";
|
|
45
|
+
import { findProjectRoot } from "nodefony/project-root";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Racine de l'APPLICATION vue depuis `cwd` — le dossier qui porte
|
|
49
|
+
* `nodefony.config.ts` —, ou `cwd` lui-même hors application. Un script lancé
|
|
50
|
+
* depuis `frontend/` ou `modules/blog/` travaille ainsi sur l'app entière.
|
|
51
|
+
* `nodefony/project-root` est le sous-chemin LÉGER du cœur (`node:` seul) :
|
|
52
|
+
* importer `nodefony` entier coûtait jusqu'à plusieurs secondes à froid.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} cwd - le dossier de lancement.
|
|
55
|
+
* @returns {string} la racine retenue.
|
|
56
|
+
*/
|
|
57
|
+
const appRoot = (cwd) => findProjectRoot(cwd) ?? cwd;
|
|
58
|
+
|
|
59
|
+
/** Une faute : à corriger, sauf raison écrite. */
|
|
60
|
+
const FAULT = "FAUTE";
|
|
61
|
+
/** Un point à justifier : légitime parfois, jamais par accident. */
|
|
62
|
+
const CHECK = "À JUSTIFIER";
|
|
63
|
+
|
|
64
|
+
/** Arbre vide de git — la base d'un dépôt qui n'a qu'un commit. */
|
|
65
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
66
|
+
|
|
67
|
+
/** Extensions relues : le code, pas la documentation. */
|
|
68
|
+
const CODE_FILE = /\.(?:[cm]?[jt]sx?|vue|svelte)$/u;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Un test, un décor de test : ses identifiants en dur sont des fixtures. Deux
|
|
72
|
+
* motifs DISTINCTS — un dossier de tests n'importe où dans le chemin, un
|
|
73
|
+
* suffixe en fin de nom : réunis par `|` dans une seule expression, l'ancre
|
|
74
|
+
* `$` semblait couvrir les deux (js/regex/missing-regexp-anchor).
|
|
75
|
+
*/
|
|
76
|
+
const TEST_DIR = /(?:^|\/)(?:tests?|__tests__|e2e|fixtures?)\//u;
|
|
77
|
+
const TEST_SUFFIX = /\.(?:test|spec|selftest)\.[cm]?[jt]sx?$/u;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Ce qui n'est pas le code de l'application : dépendances, produits de build,
|
|
81
|
+
* et les dossiers CACHÉS (`.claude/`, `.github/`, `.agents/`) — outillage du
|
|
82
|
+
* poste et de la forge, qui ne part pas avec l'application.
|
|
83
|
+
*/
|
|
84
|
+
const FOREIGN = /(?:^|\/)(?:node_modules|dist|coverage|\.[^/]+)\//u;
|
|
85
|
+
|
|
86
|
+
/** Un `.env` qui porte des valeurs — la notice `.env.example` n'en porte pas. */
|
|
87
|
+
const ENV_FILE = /(?:^|\/)\.env(?:\.(?!example$)[\w.-]+)?$/u;
|
|
88
|
+
|
|
89
|
+
/** La forme d'une requête SQL, pas un mot isolé dans un message. */
|
|
90
|
+
const SQL_SHAPE =
|
|
91
|
+
/\bSELECT\b[\s\S]*\bFROM\b|\bINSERT\s+INTO\b|\bUPDATE\s+\S+\s+SET\b|\bDELETE\s+FROM\b|\bWHERE\s+[\w."]+\s*(?:=|<|>|\bLIKE\b|\bIN\b)/iu;
|
|
92
|
+
|
|
93
|
+
/** Une donnée qui vient de la requête. */
|
|
94
|
+
const REQUEST_INPUT =
|
|
95
|
+
/\b(?:query|params|body|searchParams|getParameter|getQuery)\b/u;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Le code d'une ligne, chaînes vidées — les expressions `${…}` d'un gabarit
|
|
99
|
+
* restent, parce que c'est par elles qu'une donnée entre.
|
|
100
|
+
*
|
|
101
|
+
* Sans ce filtre, le mot `token` d'un message (« jeton renouvelé ») ou le
|
|
102
|
+
* `from` d'une URL écrite en dur déclenchent une règle sur un texte inerte.
|
|
103
|
+
*
|
|
104
|
+
* @param text - la ligne.
|
|
105
|
+
* @returns la ligne, littéraux vidés.
|
|
106
|
+
*/
|
|
107
|
+
export function codeOnly(text) {
|
|
108
|
+
let out = "";
|
|
109
|
+
let quote = null;
|
|
110
|
+
let depth = 0;
|
|
111
|
+
for (let i = 0; i < text.length; i++) {
|
|
112
|
+
const c = text[i];
|
|
113
|
+
if (quote === null) {
|
|
114
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
115
|
+
quote = c;
|
|
116
|
+
out += c;
|
|
117
|
+
} else out += c;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (c === "\\") {
|
|
121
|
+
i++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (quote === "`" && depth === 0 && c === "$" && text[i + 1] === "{") {
|
|
125
|
+
depth = 1;
|
|
126
|
+
out += "${";
|
|
127
|
+
i++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (depth > 0) {
|
|
131
|
+
if (c === "{") depth++;
|
|
132
|
+
else if (c === "}") depth--;
|
|
133
|
+
out += depth === 0 ? "}" : c;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (c === quote) {
|
|
137
|
+
quote = null;
|
|
138
|
+
out += c;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Une ligne de code sans son commentaire — vide si la ligne n'est QUE commentaire.
|
|
146
|
+
*
|
|
147
|
+
* Les règles cherchent des GESTES ; un commentaire qui en parle (« la CSP par
|
|
148
|
+
* défaut porte `'unsafe-inline'` », « `@Anonymous` rend l'action publique »)
|
|
149
|
+
* n'en est pas un. Mesuré sur un diff réel : la moitié des points levés
|
|
150
|
+
* l'étaient sur de la prose. Une ligne qui commence par `*` est une ligne de
|
|
151
|
+
* bloc de documentation.
|
|
152
|
+
*
|
|
153
|
+
* @param text - la ligne.
|
|
154
|
+
* @returns le code de la ligne, sans `// …` final.
|
|
155
|
+
*/
|
|
156
|
+
export function withoutLineComment(text) {
|
|
157
|
+
const s = text.trimStart();
|
|
158
|
+
if (s.startsWith("//") || s.startsWith("*") || s.startsWith("/*")) return "";
|
|
159
|
+
let quote = null;
|
|
160
|
+
for (let i = 0; i < text.length; i++) {
|
|
161
|
+
const c = text[i];
|
|
162
|
+
if (quote !== null) {
|
|
163
|
+
if (c === "\\") i++;
|
|
164
|
+
else if (c === quote) quote = null;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (c === '"' || c === "'" || c === "`") quote = c;
|
|
168
|
+
else if (c === "/" && text[i + 1] === "/") return text.slice(0, i);
|
|
169
|
+
}
|
|
170
|
+
return text;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Une interpolation de requête SQL hors du gabarit `sql`…`` de drizzle.
|
|
175
|
+
*
|
|
176
|
+
* Le gabarit étiqueté LIE ses valeurs (paramètres) : `sql\`… ${id}\`` est sûr.
|
|
177
|
+
* Le même texte sans l'étiquette, ou assemblé par `+`, ne l'est pas. Une
|
|
178
|
+
* requête sur plusieurs lignes s'ouvre plus haut : on remonte jusqu'à six
|
|
179
|
+
* lignes chercher l'accent grave qui l'ouvre, et ce qui le précède.
|
|
180
|
+
*
|
|
181
|
+
* @param text - la ligne ajoutée.
|
|
182
|
+
* @param lines - les lignes du fichier (ou celles connues).
|
|
183
|
+
* @param index - l'indice de la ligne dans `lines`.
|
|
184
|
+
* @returns vrai si une donnée est interpolée dans du SQL brut.
|
|
185
|
+
*/
|
|
186
|
+
function sqlInterpolated(text, lines, index) {
|
|
187
|
+
// Cherché dans le CODE : « jamais sql.raw() » écrit dans un message n'est pas un appel.
|
|
188
|
+
const raw = /\bsql\.raw\(\s*/u.exec(text);
|
|
189
|
+
if (raw && /\bsql\.raw\(/u.test(codeOnly(text))) {
|
|
190
|
+
const rest = text.slice(raw.index + raw[0].length);
|
|
191
|
+
const literal = /^(["'`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/u.exec(rest);
|
|
192
|
+
if (!literal || literal[2].includes("${")) return true;
|
|
193
|
+
}
|
|
194
|
+
if (/["'`][^"'`]*["'`]\s*\+\s*[\w$]/u.test(text)) {
|
|
195
|
+
const strings = text.match(/["'`][^"'`]*["'`]/gu) ?? [];
|
|
196
|
+
if (strings.some((s) => SQL_SHAPE.test(s))) return true;
|
|
197
|
+
}
|
|
198
|
+
const at = text.indexOf("${");
|
|
199
|
+
if (at === -1) return false;
|
|
200
|
+
const above = lines.slice(Math.max(0, index - 6), index).join("\n");
|
|
201
|
+
const ctx = `${above}\n${text}`;
|
|
202
|
+
const pos = above.length + 1 + at;
|
|
203
|
+
if (!SQL_SHAPE.test(ctx.slice(Math.max(0, ctx.lastIndexOf("`", pos))))) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
const open = ctx.lastIndexOf("`", pos);
|
|
207
|
+
if (open === -1) return false;
|
|
208
|
+
return ctx.slice(Math.max(0, open - 3), open) !== "sql";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Une valeur qui a la forme d'un secret, pas d'un libellé. */
|
|
212
|
+
function looksSecret(value) {
|
|
213
|
+
if (value.includes("${") || /^(?:https?:|\/|\.)/u.test(value)) return false;
|
|
214
|
+
if (value.length >= 20 && !/\s/u.test(value)) return true;
|
|
215
|
+
return value.length >= 8 && /\d/u.test(value) && /[a-z]/iu.test(value);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Les règles qu'une ligne ajoutée suffit à trancher.
|
|
220
|
+
*
|
|
221
|
+
* `test` reçoit la ligne, les lignes du fichier, l'indice, et le chemin.
|
|
222
|
+
*/
|
|
223
|
+
export const LINE_RULES = [
|
|
224
|
+
{
|
|
225
|
+
id: "sql-interpolated",
|
|
226
|
+
level: FAULT,
|
|
227
|
+
title: "donnée interpolée dans du SQL brut",
|
|
228
|
+
why: "une valeur concaténée à la requête en change le SENS : injection SQL",
|
|
229
|
+
fix: "le gabarit sql`… ${valeur}` de drizzle LIE la valeur ; jamais sql.raw() ni une chaîne assemblée sur une donnée reçue",
|
|
230
|
+
test: (t, lines, i) => sqlInterpolated(t, lines, i),
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: "code-exec",
|
|
234
|
+
level: FAULT,
|
|
235
|
+
title: "texte exécuté comme code ou comme commande",
|
|
236
|
+
why: "une donnée qui atteint eval, new Function ou un shell s'exécute avec les droits du serveur",
|
|
237
|
+
fix: "execFile()/spawn() avec un TABLEAU d'arguments et sans shell ; jamais eval ni new Function",
|
|
238
|
+
test: (t) =>
|
|
239
|
+
/\beval\s*\(|\bnew\s+Function\s*\(/u.test(codeOnly(t)) ||
|
|
240
|
+
/\b(?:exec|execSync)\s*\(\s*`[^`]*\$\{/u.test(t) ||
|
|
241
|
+
/\bshell\s*:\s*true\b/u.test(t),
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: "secret-hardcoded",
|
|
245
|
+
level: FAULT,
|
|
246
|
+
title: "secret écrit dans le source",
|
|
247
|
+
why: "le source part dans git, dans les images et chez chaque contributeur : un secret écrit ici est publié",
|
|
248
|
+
fix: "déclare une variable NF_… dans env.ts (defineEnv), valeur dans le .env du poste (jamais commité) ; npx nodefony env prouve qu'elle est lue et d'où, --example met la notice à jour → skill nodefony-dev",
|
|
249
|
+
test: (t) => {
|
|
250
|
+
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/u.test(t)) return true;
|
|
251
|
+
if (
|
|
252
|
+
/\b(?:sk_live_[\w]{8,}|ghp_[\w]{20,}|xox[bp]-[\w-]{10,}|AKIA[0-9A-Z]{16})/u.test(
|
|
253
|
+
t,
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
return true;
|
|
257
|
+
const m =
|
|
258
|
+
/([\w$]*(?:password|passwd|secret|api_?key|apikey|token|private_?key)[\w$]*)["']?\s*[:=]\s*(["'`])((?:\\.|(?!\2)[^\\])*)\2/iu.exec(
|
|
259
|
+
t,
|
|
260
|
+
);
|
|
261
|
+
return m !== null && looksSecret(m[3]);
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
id: "secret-logged",
|
|
266
|
+
level: CHECK,
|
|
267
|
+
title: "valeur sensible envoyée au journal",
|
|
268
|
+
why: "le journal est lu, copié et expédié vers des collecteurs : un mot de passe, un jeton ou un cookie n'y a pas sa place",
|
|
269
|
+
fix: "journalise l'identifiant de l'objet, jamais la valeur du secret",
|
|
270
|
+
test: (t) => {
|
|
271
|
+
const call = /\b(?:console\.\w+|this\.log|logger\.\w+|log)\s*\(/u.exec(t);
|
|
272
|
+
if (!call) return false;
|
|
273
|
+
return /[\w$.]*(?:password|passwd|secret|token|apikey|api_key|authorization|cookie|otp)\b/iu.test(
|
|
274
|
+
codeOnly(t.slice(call.index + call[0].length)),
|
|
275
|
+
);
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: "env-outside-envts",
|
|
280
|
+
level: CHECK,
|
|
281
|
+
title: "process.env lu hors de env.ts",
|
|
282
|
+
why: "une variable lue ailleurs échappe au catalogue : ni doctor ni .env.example ne la connaissent, et son absence passe en silence",
|
|
283
|
+
fix: "déclare-la dans env.ts (defineEnv, seul lecteur de process.env) et fais-la passer par la configuration ; npx nodefony env montre ensuite qu'elle est lue → skill nodefony-dev",
|
|
284
|
+
test: (t, _l, _i, file) =>
|
|
285
|
+
/\bprocess\.env\b/u.test(codeOnly(t)) &&
|
|
286
|
+
!/(?:^|\/)env\.[cm]?[jt]s$/u.test(file) &&
|
|
287
|
+
// Un script AUTONOME (`scripts/`) ne charge pas l'application : il n'a
|
|
288
|
+
// pas d'`env.ts` à qui demander, et lit son environnement lui-même.
|
|
289
|
+
!file.startsWith("scripts/"),
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
id: "raw-identity",
|
|
293
|
+
level: FAULT,
|
|
294
|
+
title: "identité BRUTE de la requête",
|
|
295
|
+
why: "getUser() rend aussi l'anonyme (anon.) d'une zone anonymous : tester « connecté » dessus laisse passer tout le monde",
|
|
296
|
+
fix: "this.user dans un controller, RequestContext.getAuthenticatedUser() ailleurs → skill nodefony-authentication",
|
|
297
|
+
test: (t) =>
|
|
298
|
+
/\b(?:RequestContext|context|ctx|this\.context)\??\.getUser\s*\(/u.test(
|
|
299
|
+
codeOnly(t),
|
|
300
|
+
),
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: "firewall-opening",
|
|
304
|
+
level: CHECK,
|
|
305
|
+
title: "route ou zone sortie du pare-feu",
|
|
306
|
+
why: "aucune identité n'y est établie et aucun rôle n'y est exigé",
|
|
307
|
+
fix: "voulu public ? écris-le dans la relecture. Sinon retire-le → skill nodefony-protect-route",
|
|
308
|
+
test: (t) =>
|
|
309
|
+
/@(?:BypassFirewall|Anonymous)\b|\bbypassFirewall\s*:\s*true\b|\bareaRoleExempt\s*:\s*true\b|\bsecurity\s*:\s*false\b|\bauthenticators\s*:\s*\[[^\]]*["']anonymous["']/u.test(
|
|
310
|
+
t,
|
|
311
|
+
),
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
id: "csrf-off",
|
|
315
|
+
level: FAULT,
|
|
316
|
+
title: "défense anti-falsification coupée",
|
|
317
|
+
why: "n'importe quel site peut alors déclencher la mutation au nom de l'utilisateur connecté",
|
|
318
|
+
fix: "une origine tierce se DÉCLARE (csrf.trustedOrigins) ; le client rejoue le jeton dans x-csrf-token → skill nodefony-protect-route",
|
|
319
|
+
test: (t) =>
|
|
320
|
+
/@CsrfExempt\b|\bcsrf\s*:\s*(?:false\b|\{[^}]*\benabled\s*:\s*false)|\btrustedOrigins\s*:\s*\[[^\]]*["']\*["']/u.test(
|
|
321
|
+
t,
|
|
322
|
+
),
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
id: "guard-off",
|
|
326
|
+
level: FAULT,
|
|
327
|
+
title: "garde éteinte au lieu d'être réglée",
|
|
328
|
+
why: "la protection disparaît sans erreur ni trace : limite de tentatives, script injecté, secret en clair",
|
|
329
|
+
fix: "relever un seuil est un réglage, l'éteindre ne l'est pas → skill nodefony-protect-route",
|
|
330
|
+
test: (t) =>
|
|
331
|
+
/\brateLimit\s*:\s*(?:false\b|\{[^}]*\benabled\s*:\s*false)|["']unsafe-inline["']|\ballowInsecureCredentials\s*:\s*true\b/u.test(
|
|
332
|
+
t,
|
|
333
|
+
),
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: "open-redirect",
|
|
337
|
+
level: FAULT,
|
|
338
|
+
title: "redirection vers une adresse reçue",
|
|
339
|
+
why: "une adresse de retour lue dans la requête renvoie vers n'importe quel site : redirection ouverte, base d'un hameçonnage",
|
|
340
|
+
fix: 'safeRedirectPath(valeur) (import { safeRedirectPath } from "nodefony") — la garde qui refuse ce qui sort de l\'application',
|
|
341
|
+
test: (t) => {
|
|
342
|
+
const code = codeOnly(t);
|
|
343
|
+
return (
|
|
344
|
+
/\bredirect\w*\s*\(/iu.test(code) &&
|
|
345
|
+
/\b(?:query|params|body|searchParams|from|returnTo|redirectTo|next)\b/u.test(
|
|
346
|
+
code.slice(code.search(/\bredirect\w*\s*\(/iu)),
|
|
347
|
+
) &&
|
|
348
|
+
!/\bsafeRedirectPath\b/u.test(code)
|
|
349
|
+
);
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
id: "path-from-input",
|
|
354
|
+
level: CHECK,
|
|
355
|
+
title: "chemin de fichier composé avec une donnée reçue",
|
|
356
|
+
why: "un « ../../ » dans la donnée fait sortir du dossier prévu : lecture ou écriture n'importe où",
|
|
357
|
+
fix: "résous le chemin, puis vérifie que path.relative(racine, chemin) ne commence pas par « .. »",
|
|
358
|
+
test: (t) => {
|
|
359
|
+
const code = codeOnly(t);
|
|
360
|
+
return (
|
|
361
|
+
/\b(?:readFile\w*|createReadStream|createWriteStream|sendFile|unlink\w*|writeFile\w*|rm\w*|path\.(?:join|resolve))\s*\(/u.test(
|
|
362
|
+
code,
|
|
363
|
+
) && REQUEST_INPUT.test(code)
|
|
364
|
+
);
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
id: "mcp-tool-exposed",
|
|
369
|
+
level: CHECK,
|
|
370
|
+
title: "outil MCP publié par un module",
|
|
371
|
+
why: "il est servi par /nodefony/mcp, qui échappe aux zones du pare-feu : sans mcp.authorization, seules l'adresse locale et l'origine le gardent",
|
|
372
|
+
fix: "un outil en lecture seule, qui ne rend que ce qui supporterait d'être lu sans compte ; sinon des scopes sur l'outil et mcp.authorization déclaré (node_modules/@nodefony/devkit/docs/index.md)",
|
|
373
|
+
test: (t) => /\bgetMcpTools\s*\(/u.test(codeOnly(t)),
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
id: "mcp-opened",
|
|
377
|
+
level: CHECK,
|
|
378
|
+
title: "porte MCP ouverte au-delà du poste",
|
|
379
|
+
why: "la porte répond alors à une autre machine, ou à toute page web — ce qui ferme le détournement DNS, c'est précisément l'origine",
|
|
380
|
+
fix: "déclare mcp.authorization AVANT d'ouvrir ; une origine se nomme, jamais « * »",
|
|
381
|
+
test: (t) =>
|
|
382
|
+
/\ballowRemote\s*:\s*true\b|\ballowedOrigins\s*:\s*\[[^\]]*["']\*["']/u.test(
|
|
383
|
+
t,
|
|
384
|
+
),
|
|
385
|
+
},
|
|
386
|
+
];
|
|
387
|
+
|
|
388
|
+
/** Les règles qui portent sur un FICHIER entier. */
|
|
389
|
+
export const FILE_RULES = [
|
|
390
|
+
{
|
|
391
|
+
id: "env-file-versioned",
|
|
392
|
+
level: FAULT,
|
|
393
|
+
title: "fichier .env dans le diff",
|
|
394
|
+
why: "il porte les valeurs du poste, secrets compris : versionné, il part chez tous ceux qui clonent",
|
|
395
|
+
fix: "git rm --cached <fichier> (il reste sur le disque) ; la notice versionnée est .env.example",
|
|
396
|
+
test: (file) => ENV_FILE.test(file),
|
|
397
|
+
},
|
|
398
|
+
];
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* L'extrait d'une ligne, chaque littéral long réduit à ses quatre premiers caractères.
|
|
402
|
+
*
|
|
403
|
+
* La relecture est LUE — par l'agent, recopiée dans un compte rendu, versée au
|
|
404
|
+
* journal d'une forge. Un extrait qui portait la valeur d'un secret écrit en
|
|
405
|
+
* dur le publiait une fois de plus à chaque passage ; quatre caractères
|
|
406
|
+
* suffisent à le reconnaître.
|
|
407
|
+
*
|
|
408
|
+
* @param text - la ligne.
|
|
409
|
+
* @returns la ligne, valeurs masquées.
|
|
410
|
+
*/
|
|
411
|
+
export function redact(text) {
|
|
412
|
+
return text.replace(
|
|
413
|
+
/(["'`])([^"'`\s]{4})[^"'`\s]{4,}\1/gu,
|
|
414
|
+
(_m, q, debut) => `${q}${debut}…${q}`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Règle « mutation sans jeton », croisée avec la zone. */
|
|
419
|
+
export const MUTATION_RULE = {
|
|
420
|
+
id: "mutation-without-csrf",
|
|
421
|
+
level: FAULT,
|
|
422
|
+
title: "mutation sans @CsrfProtect sous une zone à session",
|
|
423
|
+
why: "le cookie de session part avec toute requête, y compris celle qu'une autre page déclenche : la provenance n'est pas une preuve d'intention",
|
|
424
|
+
fix: "@CsrfProtect() sur l'action (ou la classe) ; le client sème le jeton par un GET et le rejoue dans x-csrf-token → skill nodefony-protect-route",
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/** Règle « route ouverte », croisée avec la zone. */
|
|
428
|
+
export const OPEN_ROUTE_RULE = {
|
|
429
|
+
id: "route-open",
|
|
430
|
+
level: CHECK,
|
|
431
|
+
title: "route atteignable par un anonyme",
|
|
432
|
+
why: "aucune garde ne la protège : n'importe qui l'appelle, sans compte",
|
|
433
|
+
fix: 'voulue publique ? écris-le dans la relecture. Sinon @IsGranted("ROLE_…") sur l\'action ou la classe, ou une zone qui exige une session → skill nodefony-protect-route',
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Le texte d'un source, commentaires retirés — chaînes intactes.
|
|
438
|
+
*
|
|
439
|
+
* Indispensable pour lire les zones : le gabarit de `security.ts` porte des
|
|
440
|
+
* exemples de zones en commentaire, qu'il ne faut pas prendre pour des zones.
|
|
441
|
+
*
|
|
442
|
+
* @param src - le source.
|
|
443
|
+
* @returns le source sans commentaires (longueur non conservée).
|
|
444
|
+
*/
|
|
445
|
+
export function stripComments(src) {
|
|
446
|
+
let out = "";
|
|
447
|
+
let quote = null;
|
|
448
|
+
for (let i = 0; i < src.length; i++) {
|
|
449
|
+
const c = src[i];
|
|
450
|
+
if (quote !== null) {
|
|
451
|
+
out += c;
|
|
452
|
+
if (c === "\\") {
|
|
453
|
+
out += src[++i] ?? "";
|
|
454
|
+
} else if (c === quote) quote = null;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (c === "/" && src[i + 1] === "/") {
|
|
458
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
459
|
+
out += "\n";
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (c === "/" && src[i + 1] === "*") {
|
|
463
|
+
const end = src.indexOf("*/", i + 2);
|
|
464
|
+
i = end === -1 ? src.length : end + 1;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (c === '"' || c === "'" || c === "`") quote = c;
|
|
468
|
+
out += c;
|
|
469
|
+
}
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Les zones du pare-feu déclarées dans un source de configuration.
|
|
475
|
+
*
|
|
476
|
+
* Chaque `pattern:` littéral est rattaché à l'objet qui le porte, et l'on y lit
|
|
477
|
+
* ses authentificateurs et un éventuel `security: false`.
|
|
478
|
+
*
|
|
479
|
+
* @param src - le source d'un fichier de configuration.
|
|
480
|
+
* @returns les zones lues : nom, motif, authentificateurs, publique ou non.
|
|
481
|
+
*/
|
|
482
|
+
export function readZones(src) {
|
|
483
|
+
const text = stripComments(src);
|
|
484
|
+
const zones = [];
|
|
485
|
+
const re = /\bpattern\s*:\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/gu;
|
|
486
|
+
for (let m = re.exec(text); m !== null; m = re.exec(text)) {
|
|
487
|
+
let depth = 0;
|
|
488
|
+
let start = -1;
|
|
489
|
+
for (let i = m.index; i >= 0; i--) {
|
|
490
|
+
if (text[i] === "}") depth++;
|
|
491
|
+
else if (text[i] === "{") {
|
|
492
|
+
if (depth === 0) {
|
|
493
|
+
start = i;
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
depth--;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (start === -1) continue;
|
|
500
|
+
depth = 0;
|
|
501
|
+
let end = text.length;
|
|
502
|
+
for (let i = start; i < text.length; i++) {
|
|
503
|
+
if (text[i] === "{") depth++;
|
|
504
|
+
else if (text[i] === "}" && --depth === 0) {
|
|
505
|
+
end = i;
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const block = text.slice(start, end + 1);
|
|
510
|
+
const name =
|
|
511
|
+
/([\w$]+|"[^"]+"|'[^']+')\s*:\s*$/u
|
|
512
|
+
.exec(text.slice(Math.max(0, start - 80), start))?.[1]
|
|
513
|
+
?.replace(/["']/gu, "") ?? "?";
|
|
514
|
+
const auth = /\bauthenticators\s*:\s*\[([^\]]*)\]/u.exec(block);
|
|
515
|
+
const authenticators = auth
|
|
516
|
+
? [...auth[1].matchAll(/["']([^"']+)["']/gu)].map((a) => a[1])
|
|
517
|
+
: [];
|
|
518
|
+
let regex = null;
|
|
519
|
+
try {
|
|
520
|
+
regex = new RegExp(m[2].replace(/\\\\/gu, "\\"), "u");
|
|
521
|
+
} catch {
|
|
522
|
+
regex = null;
|
|
523
|
+
}
|
|
524
|
+
if (regex === null) continue;
|
|
525
|
+
zones.push({
|
|
526
|
+
name,
|
|
527
|
+
pattern: m[2],
|
|
528
|
+
regex,
|
|
529
|
+
authenticators,
|
|
530
|
+
open:
|
|
531
|
+
/\bsecurity\s*:\s*false\b/u.test(block) ||
|
|
532
|
+
authenticators.includes("anonymous"),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
return zones;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Les zones de l'application : `nodefony/config/**` et `nodefony.config.ts`.
|
|
540
|
+
*
|
|
541
|
+
* @param root - racine de l'application.
|
|
542
|
+
* @returns les zones lues, et les fichiers lus.
|
|
543
|
+
*/
|
|
544
|
+
export function appZones(root) {
|
|
545
|
+
const files = [];
|
|
546
|
+
const walk = (dir) => {
|
|
547
|
+
let entries = [];
|
|
548
|
+
try {
|
|
549
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
550
|
+
} catch {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
for (const e of entries) {
|
|
554
|
+
const full = path.join(dir, e.name);
|
|
555
|
+
if (e.isDirectory()) walk(full);
|
|
556
|
+
else if (/\.[cm]?[jt]s$/u.test(e.name)) files.push(full);
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
walk(path.join(root, "nodefony", "config"));
|
|
560
|
+
for (const f of ["nodefony.config.ts", "nodefony.config.js"]) {
|
|
561
|
+
const full = path.join(root, f);
|
|
562
|
+
if (existsSync(full)) files.push(full);
|
|
563
|
+
}
|
|
564
|
+
const zones = [];
|
|
565
|
+
for (const f of files) {
|
|
566
|
+
try {
|
|
567
|
+
zones.push(...readZones(readFileSync(f, "utf8")));
|
|
568
|
+
} catch {
|
|
569
|
+
// illisible : la zone manquera, et la route sera dite hors zone — visible.
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return zones.sort((a, b) => b.pattern.length - a.pattern.length);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Les lignes ajoutées par un diff unifié, rangées par fichier.
|
|
577
|
+
*
|
|
578
|
+
* @param diff - la sortie de `git diff` (n'importe quel contexte).
|
|
579
|
+
* @returns `Map<chemin, { added: {line, text}[], isNew }>`.
|
|
580
|
+
*/
|
|
581
|
+
export function parseDiff(diff) {
|
|
582
|
+
const files = new Map();
|
|
583
|
+
let current = null;
|
|
584
|
+
let line = 0;
|
|
585
|
+
let isNew = false;
|
|
586
|
+
for (const raw of diff.split(/\r?\n/u)) {
|
|
587
|
+
if (raw.startsWith("diff --git ")) {
|
|
588
|
+
current = null;
|
|
589
|
+
isNew = false;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (raw.startsWith("new file mode")) {
|
|
593
|
+
isNew = true;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (raw.startsWith("+++ ")) {
|
|
597
|
+
const p = raw
|
|
598
|
+
.slice(4)
|
|
599
|
+
.trim()
|
|
600
|
+
.replace(/^"(.*)"$/u, "$1");
|
|
601
|
+
if (p === "/dev/null") {
|
|
602
|
+
current = null;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const file = p.replace(/^b\//u, "");
|
|
606
|
+
current = files.get(file) ?? { added: [], isNew };
|
|
607
|
+
files.set(file, current);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (raw.startsWith("--- ")) continue;
|
|
611
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/u.exec(raw);
|
|
612
|
+
if (hunk) {
|
|
613
|
+
line = Number(hunk[1]);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (current === null) continue;
|
|
617
|
+
if (raw.startsWith("+")) {
|
|
618
|
+
current.added.push({ line, text: raw.slice(1) });
|
|
619
|
+
line++;
|
|
620
|
+
} else if (raw.startsWith(" ")) line++;
|
|
621
|
+
}
|
|
622
|
+
return files;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Une ligne qui CLÔT le membre précédent : on ne remonte pas au-delà. */
|
|
626
|
+
const MEMBER_BOUNDARY = (s) =>
|
|
627
|
+
s === "}" || s === "};" || s.endsWith("{") || s.endsWith("*/") || s === "";
|
|
628
|
+
|
|
629
|
+
/** Une signature de méthode ou de classe : fin du bloc de décorateurs. */
|
|
630
|
+
const SIGNATURE =
|
|
631
|
+
/^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?(?:class\s|(?:public\s+|private\s+|protected\s+|static\s+|async\s+)*[\w$]+\s*\()/u;
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Les décorateurs qui précèdent une ligne de route : du membre précédent à la
|
|
635
|
+
* signature qui suit.
|
|
636
|
+
*
|
|
637
|
+
* @param lines - lignes du fichier.
|
|
638
|
+
* @param i - indice d'une ligne de décorateur.
|
|
639
|
+
* @returns `{ from, to, text }` — `to` est l'indice de la signature.
|
|
640
|
+
*/
|
|
641
|
+
function decoratorGroup(lines, i) {
|
|
642
|
+
let from = i;
|
|
643
|
+
while (from > 0 && !MEMBER_BOUNDARY((lines[from - 1] ?? "").trim())) from--;
|
|
644
|
+
let to = i;
|
|
645
|
+
while (to < lines.length - 1) {
|
|
646
|
+
const s = (lines[to] ?? "").trim();
|
|
647
|
+
if (!s.startsWith("@") && SIGNATURE.test(s)) break;
|
|
648
|
+
to++;
|
|
649
|
+
}
|
|
650
|
+
return { from, to, text: lines.slice(from, to + 1).join("\n") };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Les routes d'un fichier de controller, avec le bloc de décorateurs de chaque action.
|
|
655
|
+
*
|
|
656
|
+
* @param lines - lignes du fichier.
|
|
657
|
+
* @returns préfixe, garde de classe, et les actions : chemin, méthodes, ligne, bloc.
|
|
658
|
+
*/
|
|
659
|
+
export function controllerRoutes(lines) {
|
|
660
|
+
const prefix =
|
|
661
|
+
/@controller\(\s*["'`]([^"'`]*)["'`]/u.exec(lines.join("\n"))?.[1] ?? "";
|
|
662
|
+
const classAt = lines.findIndex((l) =>
|
|
663
|
+
/^(?:export\s+)?(?:default\s+)?class\s/u.test(l.trim()),
|
|
664
|
+
);
|
|
665
|
+
const classGroup = classAt > 0 ? decoratorGroup(lines, classAt - 1).text : "";
|
|
666
|
+
const actions = [];
|
|
667
|
+
const seen = new Set();
|
|
668
|
+
for (let i = 0; i < lines.length; i++) {
|
|
669
|
+
const t = lines[i] ?? "";
|
|
670
|
+
const routeDeco = /@route\(\s*["'`][^"'`]*["'`]\s*,/u.test(t);
|
|
671
|
+
const verbDeco = /@(Get|Post|Put|Patch|Delete)\s*\(/u.exec(t);
|
|
672
|
+
if (!routeDeco && !verbDeco) continue;
|
|
673
|
+
const group = decoratorGroup(lines, i);
|
|
674
|
+
if (seen.has(group.to)) continue;
|
|
675
|
+
seen.add(group.to);
|
|
676
|
+
let routePath = "";
|
|
677
|
+
let methods = [];
|
|
678
|
+
if (verbDeco) {
|
|
679
|
+
routePath =
|
|
680
|
+
new RegExp(
|
|
681
|
+
`@${verbDeco[1]}\\s*\\(\\s*["'\`]([^"'\`]*)["'\`]`,
|
|
682
|
+
"u",
|
|
683
|
+
).exec(group.text)?.[1] ?? "";
|
|
684
|
+
methods = [verbDeco[1].toUpperCase()];
|
|
685
|
+
} else {
|
|
686
|
+
routePath =
|
|
687
|
+
/\bpath\s*:\s*["'`]([^"'`]*)["'`]/u.exec(group.text)?.[1] ?? "";
|
|
688
|
+
const one = /\bmethod\s*:\s*["'`](\w+)["'`]/u.exec(group.text);
|
|
689
|
+
const many = /\bmethods?\s*:\s*\[([^\]]*)\]/u.exec(group.text);
|
|
690
|
+
methods = one
|
|
691
|
+
? [one[1].toUpperCase()]
|
|
692
|
+
: many
|
|
693
|
+
? [...many[1].matchAll(/["'`](\w+)["'`]/gu)].map((x) =>
|
|
694
|
+
x[1].toUpperCase(),
|
|
695
|
+
)
|
|
696
|
+
: ["GET"];
|
|
697
|
+
}
|
|
698
|
+
const full = `/${[prefix, routePath]
|
|
699
|
+
.map((s) => s.replace(/^\/+|\/+$/gu, ""))
|
|
700
|
+
.filter(Boolean)
|
|
701
|
+
.join("/")}`;
|
|
702
|
+
actions.push({
|
|
703
|
+
line: i,
|
|
704
|
+
from: group.from,
|
|
705
|
+
to: group.to,
|
|
706
|
+
path: full,
|
|
707
|
+
methods,
|
|
708
|
+
text: group.text,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
prefix,
|
|
713
|
+
classGuarded: /@IsGranted\b/u.test(classGroup),
|
|
714
|
+
classCsrf: /@CsrfProtect\b/u.test(classGroup),
|
|
715
|
+
actions,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** La zone retenue pour un chemin : le motif le plus LONG qui le couvre (règle du pare-feu). */
|
|
720
|
+
export function zoneFor(zones, urlPath) {
|
|
721
|
+
return zones.find((z) => z.regex.test(urlPath)) ?? null;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Relit les fichiers donnés et rend les points relevés.
|
|
726
|
+
*
|
|
727
|
+
* @param files - `Map<chemin, { added, isNew }>` (sortie de `parseDiff`).
|
|
728
|
+
* @param opts - `root` (racine de l'application : ses zones), `filesRoot` (racine
|
|
729
|
+
* du dépôt, à laquelle les chemins du diff sont relatifs ; défaut `root`),
|
|
730
|
+
* `readDisk` (relire le fichier entier ; faux pour un patch), `zones` (sinon lues sous `root`).
|
|
731
|
+
* @returns `{ findings, reviewed, skippedTests, zonesRead }`.
|
|
732
|
+
*/
|
|
733
|
+
export function review(files, opts = {}) {
|
|
734
|
+
const root = opts.root ?? process.cwd();
|
|
735
|
+
const zones = opts.zones ?? appZones(root);
|
|
736
|
+
const findings = [];
|
|
737
|
+
const reviewed = [];
|
|
738
|
+
const skippedTests = [];
|
|
739
|
+
const push = (rule, file, line, excerpt, extra = "") => {
|
|
740
|
+
findings.push({
|
|
741
|
+
rule: rule.id,
|
|
742
|
+
level: rule.level,
|
|
743
|
+
file,
|
|
744
|
+
line,
|
|
745
|
+
excerpt: (rule.id === "secret-hardcoded" ? redact(excerpt) : excerpt)
|
|
746
|
+
.trim()
|
|
747
|
+
.slice(0, 160),
|
|
748
|
+
title: rule.title,
|
|
749
|
+
why: extra ? `${rule.why} — ${extra}` : rule.why,
|
|
750
|
+
fix: rule.fix,
|
|
751
|
+
});
|
|
752
|
+
};
|
|
753
|
+
for (const [file, { added, isNew }] of files) {
|
|
754
|
+
const posix = file.split(path.sep).join("/");
|
|
755
|
+
if (FOREIGN.test(posix)) continue;
|
|
756
|
+
for (const r of FILE_RULES) {
|
|
757
|
+
if (r.test(posix)) push(r, posix, added[0]?.line ?? 1, posix);
|
|
758
|
+
}
|
|
759
|
+
if (!CODE_FILE.test(posix)) continue;
|
|
760
|
+
if (TEST_DIR.test(posix) || TEST_SUFFIX.test(posix)) {
|
|
761
|
+
skippedTests.push(posix);
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
reviewed.push(posix);
|
|
765
|
+
// Le fichier entier quand il est sur le disque — sinon les seules lignes connues.
|
|
766
|
+
let lines = null;
|
|
767
|
+
const onDisk = path.join(opts.filesRoot ?? root, file);
|
|
768
|
+
if (opts.readDisk !== false && existsSync(onDisk)) {
|
|
769
|
+
try {
|
|
770
|
+
lines = readFileSync(onDisk, "utf8").split(/\r?\n/u);
|
|
771
|
+
} catch {
|
|
772
|
+
lines = null;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (lines === null) {
|
|
776
|
+
lines = [];
|
|
777
|
+
for (const a of added) lines[a.line - 1] = a.text;
|
|
778
|
+
for (let i = 0; i < lines.length; i++) lines[i] ??= "";
|
|
779
|
+
}
|
|
780
|
+
// Le code seul : un commentaire qui PARLE d'un décorateur ou d'une garde
|
|
781
|
+
// ne la pose pas — ni pour lever un point, ni pour en éteindre un.
|
|
782
|
+
const code = lines.map(withoutLineComment);
|
|
783
|
+
const addedAt = new Set(added.map((a) => a.line - 1));
|
|
784
|
+
for (const a of added) {
|
|
785
|
+
const t = code[a.line - 1] ?? withoutLineComment(a.text);
|
|
786
|
+
if (t.trim() === "") continue;
|
|
787
|
+
for (const r of LINE_RULES) {
|
|
788
|
+
if (r.test(t, code, a.line - 1, posix)) push(r, posix, a.line, a.text);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (!/@controller\s*\(/u.test(code.join("\n"))) continue;
|
|
792
|
+
const ctl = controllerRoutes(code);
|
|
793
|
+
for (const act of ctl.actions) {
|
|
794
|
+
let touched = isNew;
|
|
795
|
+
for (let k = act.from; k <= act.to && !touched; k++)
|
|
796
|
+
if (addedAt.has(k)) touched = true;
|
|
797
|
+
if (!touched) continue;
|
|
798
|
+
const zone = zoneFor(zones, act.path);
|
|
799
|
+
const verbs = act.methods.join(",");
|
|
800
|
+
const guarded = ctl.classGuarded || /@IsGranted\b/u.test(act.text);
|
|
801
|
+
const excerpt = `${verbs} ${act.path}`;
|
|
802
|
+
if (!guarded) {
|
|
803
|
+
if (zone === null)
|
|
804
|
+
push(
|
|
805
|
+
OPEN_ROUTE_RULE,
|
|
806
|
+
posix,
|
|
807
|
+
act.line + 1,
|
|
808
|
+
excerpt,
|
|
809
|
+
zones.length === 0
|
|
810
|
+
? "aucune zone de pare-feu lue dans nodefony/config/ : chaque route est publique"
|
|
811
|
+
: `aucune zone ne couvre ${act.path} : this.user y vaut toujours null`,
|
|
812
|
+
);
|
|
813
|
+
else if (zone.open)
|
|
814
|
+
push(
|
|
815
|
+
OPEN_ROUTE_RULE,
|
|
816
|
+
posix,
|
|
817
|
+
act.line + 1,
|
|
818
|
+
excerpt,
|
|
819
|
+
`elle tombe dans la zone « ${zone.name} » (${zone.pattern}), qui laisse passer l'anonyme, et l'action ne porte aucun @IsGranted`,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
const mutates = act.methods.some((m) =>
|
|
823
|
+
["POST", "PUT", "PATCH", "DELETE"].includes(m),
|
|
824
|
+
);
|
|
825
|
+
const cookieZone =
|
|
826
|
+
zone === null
|
|
827
|
+
? zones.length === 0
|
|
828
|
+
: zone.authenticators.includes("session");
|
|
829
|
+
if (
|
|
830
|
+
mutates &&
|
|
831
|
+
cookieZone &&
|
|
832
|
+
!ctl.classCsrf &&
|
|
833
|
+
!/@CsrfProtect\b/u.test(act.text)
|
|
834
|
+
)
|
|
835
|
+
push(
|
|
836
|
+
MUTATION_RULE,
|
|
837
|
+
posix,
|
|
838
|
+
act.line + 1,
|
|
839
|
+
excerpt,
|
|
840
|
+
zone ? `zone « ${zone.name} »` : "",
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
findings.sort(
|
|
845
|
+
(a, b) =>
|
|
846
|
+
(a.level === FAULT ? 0 : 1) - (b.level === FAULT ? 0 : 1) ||
|
|
847
|
+
a.file.localeCompare(b.file) ||
|
|
848
|
+
a.line - b.line,
|
|
849
|
+
);
|
|
850
|
+
return { findings, reviewed, skippedTests, zonesRead: zones.length };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const FLAGS = new Set([
|
|
854
|
+
"--base",
|
|
855
|
+
"--staged",
|
|
856
|
+
"--diff",
|
|
857
|
+
"--json",
|
|
858
|
+
"--root",
|
|
859
|
+
"--help",
|
|
860
|
+
"-h",
|
|
861
|
+
]);
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Lit les arguments, et REFUSE ce qu'il ne comprend pas.
|
|
865
|
+
*
|
|
866
|
+
* Un drapeau mal tapé qu'on ignorerait rendrait « rien à signaler » — la pire
|
|
867
|
+
* réponse possible d'une relecture.
|
|
868
|
+
*
|
|
869
|
+
* @param argv - les arguments, sans `node` ni le chemin du script.
|
|
870
|
+
* @returns {{base: string|null, staged: boolean, diff: string|null, json: boolean, root: string, help: boolean}} les options lues.
|
|
871
|
+
* @throws Error quand un drapeau est inconnu ou qu'une valeur manque.
|
|
872
|
+
*/
|
|
873
|
+
export function parseArgs(argv, cwd = process.cwd()) {
|
|
874
|
+
const opts = {
|
|
875
|
+
base: null,
|
|
876
|
+
staged: false,
|
|
877
|
+
diff: null,
|
|
878
|
+
json: false,
|
|
879
|
+
root: appRoot(cwd),
|
|
880
|
+
help: false,
|
|
881
|
+
};
|
|
882
|
+
for (let i = 0; i < argv.length; i++) {
|
|
883
|
+
const a = argv[i];
|
|
884
|
+
if (!FLAGS.has(a)) throw new Error(`option inconnue : ${a}`);
|
|
885
|
+
if (a === "--help" || a === "-h") opts.help = true;
|
|
886
|
+
else if (a === "--staged") opts.staged = true;
|
|
887
|
+
else if (a === "--json") opts.json = true;
|
|
888
|
+
else {
|
|
889
|
+
const v = argv[++i];
|
|
890
|
+
if (v === undefined || v.startsWith("-"))
|
|
891
|
+
throw new Error(`${a} attend une valeur`);
|
|
892
|
+
if (a === "--root") opts.root = path.resolve(v);
|
|
893
|
+
else if (a === "--diff") opts.diff = v;
|
|
894
|
+
else opts.base = v;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (opts.staged && opts.base !== null)
|
|
898
|
+
throw new Error("--staged et --base s'excluent : choisis l'un ou l'autre");
|
|
899
|
+
return opts;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Lance git dans la racine ; `null` si git manque ou refuse. */
|
|
903
|
+
function git(root, args) {
|
|
904
|
+
const r = spawnSync("git", ["-c", "core.quotepath=false", ...args], {
|
|
905
|
+
cwd: root,
|
|
906
|
+
encoding: "utf8",
|
|
907
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
908
|
+
});
|
|
909
|
+
return r.status === 0 ? r.stdout : null;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Le diff à relire, et la phrase qui dit lequel.
|
|
914
|
+
*
|
|
915
|
+
* @param opts - options lues.
|
|
916
|
+
* @returns `{ files, source, top }` — `top` est la racine du dépôt, à laquelle
|
|
917
|
+
* les chemins sont relatifs — ou `null` si aucun diff n'est lisible.
|
|
918
|
+
*/
|
|
919
|
+
export function collect(opts) {
|
|
920
|
+
if (opts.diff !== null) {
|
|
921
|
+
const file = path.resolve(opts.root, opts.diff);
|
|
922
|
+
if (!existsSync(file)) return null;
|
|
923
|
+
return {
|
|
924
|
+
files: parseDiff(readFileSync(file, "utf8")),
|
|
925
|
+
source: `le patch ${opts.diff}`,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
const topOut = git(opts.root, ["rev-parse", "--show-toplevel"]);
|
|
929
|
+
if (topOut === null) return null;
|
|
930
|
+
// Les chemins de `git diff` sont relatifs à la racine du DÉPÔT, quel que soit
|
|
931
|
+
// le dossier courant : c'est d'elle qu'on relit les fichiers.
|
|
932
|
+
const top = path.resolve(topOut.trim());
|
|
933
|
+
const diffArgs = ["diff", "--no-color", "--no-ext-diff", "-U0"];
|
|
934
|
+
if (opts.staged) {
|
|
935
|
+
const out = git(opts.root, [...diffArgs, "--cached"]);
|
|
936
|
+
return out === null
|
|
937
|
+
? null
|
|
938
|
+
: { files: parseDiff(out), source: "ce qui est indexé (git add)", top };
|
|
939
|
+
}
|
|
940
|
+
const hasHead = git(opts.root, ["rev-parse", "--verify", "HEAD"]) !== null;
|
|
941
|
+
const base = opts.base ?? (hasHead ? "HEAD" : EMPTY_TREE);
|
|
942
|
+
const out = git(opts.root, [...diffArgs, base]);
|
|
943
|
+
if (out === null) return null;
|
|
944
|
+
const files = parseDiff(out);
|
|
945
|
+
const untracked = (
|
|
946
|
+
git(opts.root, [
|
|
947
|
+
"ls-files",
|
|
948
|
+
"--others",
|
|
949
|
+
"--exclude-standard",
|
|
950
|
+
"--full-name",
|
|
951
|
+
]) ?? ""
|
|
952
|
+
)
|
|
953
|
+
.split(/\r?\n/u)
|
|
954
|
+
.filter(Boolean);
|
|
955
|
+
for (const f of untracked) {
|
|
956
|
+
const full = path.join(top, f);
|
|
957
|
+
let text = "";
|
|
958
|
+
try {
|
|
959
|
+
// Une seule ouverture : la taille se lit sur le descripteur, pas sur le
|
|
960
|
+
// chemin, qui peut changer entre les deux (js/file-system-race).
|
|
961
|
+
const fd = openSync(full, "r");
|
|
962
|
+
try {
|
|
963
|
+
if (fstatSync(fd).size > 1024 * 1024) continue;
|
|
964
|
+
text = readFileSync(fd, "utf8");
|
|
965
|
+
} finally {
|
|
966
|
+
closeSync(fd);
|
|
967
|
+
}
|
|
968
|
+
} catch {
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
files.set(f, {
|
|
972
|
+
added: text.split(/\r?\n/u).map((t, i) => ({ line: i + 1, text: t })),
|
|
973
|
+
isNew: true,
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
if (files.size > 0 || opts.base !== null) {
|
|
977
|
+
return {
|
|
978
|
+
files,
|
|
979
|
+
source:
|
|
980
|
+
opts.base !== null
|
|
981
|
+
? `tout ce qui a changé depuis ${opts.base}`
|
|
982
|
+
: "le travail en cours (non commité, fichiers non suivis compris)",
|
|
983
|
+
top,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
if (!hasHead) return { files, source: "le dépôt (vide)", top };
|
|
987
|
+
const prev = git(opts.root, ["rev-parse", "--verify", "HEAD~1"]) !== null;
|
|
988
|
+
const last = git(opts.root, [
|
|
989
|
+
...diffArgs,
|
|
990
|
+
prev ? "HEAD~1" : EMPTY_TREE,
|
|
991
|
+
"HEAD",
|
|
992
|
+
]);
|
|
993
|
+
if (last === null) return null;
|
|
994
|
+
return {
|
|
995
|
+
files: parseDiff(last),
|
|
996
|
+
source:
|
|
997
|
+
"le DERNIER COMMIT — rien n'était en cours (autre périmètre : --base <réf>)",
|
|
998
|
+
top,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Ce que le script ne voit pas, et la commande qui le montre. */
|
|
1003
|
+
const NOT_SEEN = [
|
|
1004
|
+
"npx nodefony doctor → Surface ouverte, Secrets hors de git, Cohérence du firewall",
|
|
1005
|
+
"npm audit --omit=dev → dépendances à vulnérabilité connue",
|
|
1006
|
+
"l'autorisation sur l'OBJET → cet utilisateur a-t-il droit à CET enregistrement ? (voter, skill nodefony-protect-route)",
|
|
1007
|
+
"les entrées non bornées → une liste, une taille, une pagination sans plafond",
|
|
1008
|
+
];
|
|
1009
|
+
|
|
1010
|
+
const USAGE = `Usage : node review-diff.mjs [--base REF | --staged] [--diff PATCH] [--json] [--root DIR]
|
|
1011
|
+
|
|
1012
|
+
Sans option : le travail en cours (git diff HEAD + fichiers non suivis), sinon le dernier commit.
|
|
1013
|
+
Sorties : 0 rien à signaler · 1 au moins un point · 64 usage · 66 aucun diff lisible.`;
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Point d'entrée.
|
|
1017
|
+
*
|
|
1018
|
+
* @returns le code de sortie.
|
|
1019
|
+
*/
|
|
1020
|
+
export function main(argv = process.argv.slice(2)) {
|
|
1021
|
+
let opts;
|
|
1022
|
+
try {
|
|
1023
|
+
opts = parseArgs(argv);
|
|
1024
|
+
} catch (e) {
|
|
1025
|
+
process.stderr.write(`${e.message}\n\n${USAGE}\n`);
|
|
1026
|
+
return 64;
|
|
1027
|
+
}
|
|
1028
|
+
if (opts.help) {
|
|
1029
|
+
process.stdout.write(`${USAGE}\n`);
|
|
1030
|
+
return 0;
|
|
1031
|
+
}
|
|
1032
|
+
const got = collect(opts);
|
|
1033
|
+
if (got === null) {
|
|
1034
|
+
process.stderr.write(
|
|
1035
|
+
opts.diff !== null
|
|
1036
|
+
? `Patch introuvable : ${opts.diff}\n`
|
|
1037
|
+
: `Aucun diff lisible : ${opts.root} n'est pas un dépôt git (ou git manque, ou la référence n'existe pas).\n` +
|
|
1038
|
+
"Passe un patch : git diff > relecture.patch, puis --diff relecture.patch.\n",
|
|
1039
|
+
);
|
|
1040
|
+
return 66;
|
|
1041
|
+
}
|
|
1042
|
+
const result = review(got.files, {
|
|
1043
|
+
root: opts.root,
|
|
1044
|
+
filesRoot: got.top ?? opts.root,
|
|
1045
|
+
readDisk: opts.diff === null,
|
|
1046
|
+
});
|
|
1047
|
+
if (opts.json) {
|
|
1048
|
+
process.stdout.write(
|
|
1049
|
+
`${JSON.stringify({ source: got.source, ...result, notSeen: NOT_SEEN }, null, 2)}\n`,
|
|
1050
|
+
);
|
|
1051
|
+
return result.findings.length > 0 ? 1 : 0;
|
|
1052
|
+
}
|
|
1053
|
+
const { findings, reviewed, skippedTests, zonesRead } = result;
|
|
1054
|
+
process.stdout.write(
|
|
1055
|
+
`Relu : ${got.source}\n` +
|
|
1056
|
+
`${reviewed.length} fichier(s) de code · ${zonesRead} zone(s) de pare-feu lue(s)` +
|
|
1057
|
+
`${skippedTests.length ? ` · ${skippedTests.length} fichier(s) de test non relu(s)` : ""}\n\n`,
|
|
1058
|
+
);
|
|
1059
|
+
if (findings.length === 0) {
|
|
1060
|
+
process.stdout.write("Rien à signaler sur ce que le script sait voir.\n\n");
|
|
1061
|
+
} else {
|
|
1062
|
+
const faults = findings.filter((f) => f.level === FAULT).length;
|
|
1063
|
+
process.stdout.write(
|
|
1064
|
+
`${findings.length} point(s) : ${faults} faute(s), ${findings.length - faults} à justifier\n\n`,
|
|
1065
|
+
);
|
|
1066
|
+
for (const f of findings) {
|
|
1067
|
+
process.stdout.write(
|
|
1068
|
+
`${f.level} ${f.file}:${f.line} — ${f.title} [${f.rule}]\n` +
|
|
1069
|
+
` ${f.excerpt}\n pourquoi : ${f.why}\n ➜ ${f.fix}\n\n`,
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
process.stdout.write(
|
|
1074
|
+
`Ce que ce script ne voit pas :\n${NOT_SEEN.map((l) => ` ${l}`).join("\n")}\n`,
|
|
1075
|
+
);
|
|
1076
|
+
return findings.length > 0 ? 1 : 0;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Exécuté directement (et non importé par son test) : rendre le code.
|
|
1080
|
+
// La comparaison passe par une URL, jamais par un chemin : sous Windows, `D:\…`
|
|
1081
|
+
// verrait son `d:` lu comme un protocole.
|
|
1082
|
+
if (
|
|
1083
|
+
process.argv[1] !== undefined &&
|
|
1084
|
+
// Chemins RÉELS des deux côtés : Node résout `import.meta.url` en suivant
|
|
1085
|
+
// les liens, pas `argv[1]` — lancé par `/var/…` (macOS) ou un dossier lié,
|
|
1086
|
+
// la comparaison brute échouait et le script ne faisait RIEN.
|
|
1087
|
+
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
1088
|
+
) {
|
|
1089
|
+
process.exitCode = main();
|
|
1090
|
+
}
|