@nosserb/prmt 0.0.0-stage → 2.0.0

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.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -2
  3. package/bin/prmt.mjs +174 -0
  4. package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
  5. package/dist/assets/Audits-yUBm0EpW.js +1 -0
  6. package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
  7. package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
  8. package/dist/assets/Carte-CPuiR1Ha.css +1 -0
  9. package/dist/assets/Carte-xycu1mQn.js +1 -0
  10. package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
  11. package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
  12. package/dist/assets/Field-C7c2mCbb.js +2 -0
  13. package/dist/assets/Journal-BLIIlh80.css +1 -0
  14. package/dist/assets/Journal-BaelFjwV.js +1 -0
  15. package/dist/assets/Landing-B2jxV3zs.css +1 -0
  16. package/dist/assets/Landing-Dr612Zdu.js +26 -0
  17. package/dist/assets/Register-DA_Aq5f8.js +1 -0
  18. package/dist/assets/Reports-8wfDNSxH.js +1 -0
  19. package/dist/assets/Reports-B8GXN95N.css +1 -0
  20. package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
  21. package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
  22. package/dist/assets/SettingsPage-BElIP46c.css +1 -0
  23. package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
  24. package/dist/assets/Shell-BAyHVljp.css +1 -0
  25. package/dist/assets/Shell-CQRlx9-V.js +2 -0
  26. package/dist/assets/Table-DUmGel5e.js +1 -0
  27. package/dist/assets/Tabs-CfhbELZk.js +1 -0
  28. package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
  29. package/dist/assets/Terminal-CEfj38NV.css +1 -0
  30. package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
  31. package/dist/assets/auditViews-BMza2Sdz.js +1 -0
  32. package/dist/assets/auditViews-BePWj26h.css +1 -0
  33. package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
  34. package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
  35. package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
  36. package/dist/assets/charts-BcijSJ4J.js +1 -0
  37. package/dist/assets/circle-stop-C33BAcKW.js +1 -0
  38. package/dist/assets/command-ChA49Xk7.js +1 -0
  39. package/dist/assets/command-Ck4pdQCv.css +1 -0
  40. package/dist/assets/download-CeuKOenD.js +1 -0
  41. package/dist/assets/eye-CXXHBBpr.js +1 -0
  42. package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  43. package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  44. package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  45. package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  46. package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  47. package/dist/assets/habillage-B5mMBadn.js +1 -0
  48. package/dist/assets/index-BH1etemR.css +1 -0
  49. package/dist/assets/index-COSiM-R9.js +14 -0
  50. package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  51. package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  52. package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  53. package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  54. package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  55. package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  56. package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
  57. package/dist/assets/layers-BWBAp2CZ.png +0 -0
  58. package/dist/assets/markdown-DF87LQh1.css +1 -0
  59. package/dist/assets/markdown-o5sMhPHN.js +4 -0
  60. package/dist/assets/marker-icon-hN30_KVU.png +0 -0
  61. package/dist/assets/play-Daw4K_73.js +1 -0
  62. package/dist/assets/plus-D_QHm8ud.js +1 -0
  63. package/dist/assets/trash-DLpHM-s8.js +1 -0
  64. package/dist/assets/web-6j1-aY67.js +1 -0
  65. package/dist/favicon.svg +1 -0
  66. package/dist/index.html +18 -0
  67. package/docs/ANALYSE.md +87 -0
  68. package/docs/SECURITE.md +188 -0
  69. package/lib/scripts/lier.js +15 -0
  70. package/lib/scripts/searxng.js +91 -0
  71. package/lib/server/app.js +444 -0
  72. package/lib/server/audits/modules/local.js +429 -0
  73. package/lib/server/audits/modules/network.js +331 -0
  74. package/lib/server/audits/modules/web.js +302 -0
  75. package/lib/server/audits/net.js +141 -0
  76. package/lib/server/audits/registry.js +202 -0
  77. package/lib/server/audits/types.js +58 -0
  78. package/lib/server/brain/brain.js +624 -0
  79. package/lib/server/brain/conversations.js +82 -0
  80. package/lib/server/brain/ollama.js +197 -0
  81. package/lib/server/brain/prompt.js +188 -0
  82. package/lib/server/brain/provider.js +1 -0
  83. package/lib/server/cli/bridge.js +292 -0
  84. package/lib/server/cli/pont.js +62 -0
  85. package/lib/server/config.js +68 -0
  86. package/lib/server/engine/analyzer.js +205 -0
  87. package/lib/server/engine/catalog.js +229 -0
  88. package/lib/server/engine/decoder.js +91 -0
  89. package/lib/server/engine/engine.js +194 -0
  90. package/lib/server/engine/paths.js +129 -0
  91. package/lib/server/engine/policy.js +397 -0
  92. package/lib/server/engine/relax.js +10 -0
  93. package/lib/server/engine/runner.js +193 -0
  94. package/lib/server/errors.js +23 -0
  95. package/lib/server/http/guard.js +75 -0
  96. package/lib/server/http/router.js +154 -0
  97. package/lib/server/http/static.js +76 -0
  98. package/lib/server/index.js +40 -0
  99. package/lib/server/journal.js +139 -0
  100. package/lib/server/lien.js +33 -0
  101. package/lib/server/savoir/base.js +284 -0
  102. package/lib/server/savoir/lecture.js +406 -0
  103. package/lib/server/savoir/memoire.js +115 -0
  104. package/lib/server/search/categories.js +179 -0
  105. package/lib/server/search/fiche.js +326 -0
  106. package/lib/server/search/lieux.js +134 -0
  107. package/lib/server/search/overpass.js +216 -0
  108. package/lib/server/search/searxng.js +223 -0
  109. package/lib/server/search/tiles.js +103 -0
  110. package/lib/server/status.js +157 -0
  111. package/lib/server/stores.js +141 -0
  112. package/lib/server/telephone/acces.js +457 -0
  113. package/lib/server/telephone/certificat.js +79 -0
  114. package/lib/server/telephone/qr.js +308 -0
  115. package/lib/shared/analysis.js +40 -0
  116. package/lib/shared/schemas.js +105 -0
  117. package/lib/shared/types.js +10 -0
  118. package/package.json +81 -4
  119. package/searxng/settings.yml +35 -0
  120. package/tui/Modelfile +23 -0
  121. package/tui/README.md +103 -0
  122. package/tui/prmt_tui.py +65 -0
  123. package/tui/promethee/__init__.py +1 -0
  124. package/tui/promethee/__main__.py +4 -0
  125. package/tui/promethee/accueil.py +66 -0
  126. package/tui/promethee/agents.py +220 -0
  127. package/tui/promethee/app/__init__.py +4 -0
  128. package/tui/promethee/app/application.py +299 -0
  129. package/tui/promethee/app/clavier.py +99 -0
  130. package/tui/promethee/app/commandes_slash.py +164 -0
  131. package/tui/promethee/app/contexte.py +188 -0
  132. package/tui/promethee/app/definitions.py +518 -0
  133. package/tui/promethee/app/ecran.py +177 -0
  134. package/tui/promethee/app/envoi.py +141 -0
  135. package/tui/promethee/app/historique.py +373 -0
  136. package/tui/promethee/app/modele.py +108 -0
  137. package/tui/promethee/app/notes.py +63 -0
  138. package/tui/promethee/app/outils.py +589 -0
  139. package/tui/promethee/app/plateforme.py +409 -0
  140. package/tui/promethee/app/saisie.py +100 -0
  141. package/tui/promethee/app/taches.py +92 -0
  142. package/tui/promethee/app/tour.py +539 -0
  143. package/tui/promethee/app/validation.py +210 -0
  144. package/tui/promethee/appels.py +35 -0
  145. package/tui/promethee/commandes.py +154 -0
  146. package/tui/promethee/completion.py +140 -0
  147. package/tui/promethee/config.py +12 -0
  148. package/tui/promethee/consignes.py +278 -0
  149. package/tui/promethee/conversation.py +94 -0
  150. package/tui/promethee/etat.py +253 -0
  151. package/tui/promethee/fichiers.py +354 -0
  152. package/tui/promethee/journal.py +116 -0
  153. package/tui/promethee/logo.py +132 -0
  154. package/tui/promethee/memoire.py +50 -0
  155. package/tui/promethee/ollama.py +52 -0
  156. package/tui/promethee/plan.py +91 -0
  157. package/tui/promethee/politique.py +429 -0
  158. package/tui/promethee/preferences.py +65 -0
  159. package/tui/promethee/presse_papiers.py +44 -0
  160. package/tui/promethee/recherche.py +581 -0
  161. package/tui/promethee/securite.py +204 -0
  162. package/tui/promethee/shell.py +190 -0
  163. package/tui/promethee/textes.py +190 -0
  164. package/tui/promethee/theme.py +92 -0
  165. package/tui/promethee/widgets.py +493 -0
  166. package/tui/requirements.txt +1 -0
@@ -0,0 +1,302 @@
1
+ import { httpRequest, pool } from '../net.js';
2
+ import { parseUrl, plural, resteACorrigerEnTete } from '../types.js';
3
+ const urlField = { key: 'url', label: 'Adresse du site', help: 'Le site doit être dans ton périmètre autorisé.', placeholder: 'https://exemple.fr', required: true };
4
+ /** Suit au plus trois redirections, sans jamais sortir du périmètre autorisé. */
5
+ async function fetchPage(start, ctx) {
6
+ const hops = [];
7
+ let current = start.href;
8
+ let result = await httpRequest(current);
9
+ for (let i = 0; i < 3 && result.status >= 300 && result.status < 400 && result.location; i++) {
10
+ const next = new URL(result.location);
11
+ if (!ctx.inScope(next.hostname))
12
+ return { result, hops, leftScope: next.hostname };
13
+ hops.push(next.href);
14
+ current = next.href;
15
+ result = await httpRequest(current);
16
+ }
17
+ return { result, hops, leftScope: null };
18
+ }
19
+ // ------------------------------------------------------------------ En-têtes HTTP
20
+ const SESSION_COOKIE = /sess|sid|auth|token|login|jwt/i;
21
+ export function analyzeHeaders(headers, setCookies, isHttps) {
22
+ const findings = [];
23
+ const checks = [];
24
+ const has = (name) => headers[name];
25
+ const check = (label, status, value) => void checks.push({ label, status, value });
26
+ const hsts = has('strict-transport-security');
27
+ if (!isHttps)
28
+ check('Strict-Transport-Security', 'skip', 'page servie en HTTP');
29
+ else if (!hsts) {
30
+ check('Strict-Transport-Security', 'fail', 'absent');
31
+ findings.push({ severity: 'medium', title: 'HSTS absent', detail: 'Sans Strict-Transport-Security, le navigateur accepte de revenir en HTTP : un attaquant sur le réseau peut intercepter la première requête.', recommendation: 'Ajouter « Strict-Transport-Security: max-age=31536000; includeSubDomains ».' });
32
+ }
33
+ else {
34
+ const age = Number(/max-age=(\d+)/i.exec(hsts)?.[1] ?? 0);
35
+ check('Strict-Transport-Security', age >= 15552000 ? 'pass' : 'warn', hsts);
36
+ if (age < 15552000)
37
+ findings.push({ severity: 'low', title: 'HSTS trop court', detail: `max-age vaut ${age} s, soit moins de six mois.`, evidence: hsts, recommendation: 'Passer max-age à 31536000 (un an).' });
38
+ }
39
+ const csp = has('content-security-policy');
40
+ if (!csp) {
41
+ check('Content-Security-Policy', 'fail', 'absent');
42
+ findings.push({ severity: 'medium', title: 'Content-Security-Policy absente', detail: 'Aucune politique ne limite les scripts que la page peut charger : une injection de script (XSS) s’exécute sans obstacle.', recommendation: 'Définir une CSP, en commençant par « default-src \'self\' ».' });
43
+ }
44
+ else {
45
+ // Seule la directive qui gouverne les scripts compte ici : script-src, sinon default-src.
46
+ const directives = new Map(csp.split(';').map((d) => d.trim().split(/\s+/)).filter((parts) => parts[0]).map((parts) => [(parts[0] ?? '').toLowerCase(), parts.slice(1).join(' ')]));
47
+ const scripts = directives.get('script-src') ?? directives.get('default-src') ?? '';
48
+ const loose = /'unsafe-inline'|'unsafe-eval'/.test(scripts);
49
+ check('Content-Security-Policy', loose ? 'warn' : 'pass', csp.length > 120 ? `${csp.slice(0, 120)}…` : csp);
50
+ if (loose)
51
+ findings.push({ severity: 'low', title: 'CSP permissive', detail: 'La politique autorise unsafe-inline ou unsafe-eval pour les scripts, ce qui annule une bonne partie de la protection contre les XSS.', evidence: csp.slice(0, 300), recommendation: 'Remplacer unsafe-inline par des nonces ou des empreintes.' });
52
+ }
53
+ const xfo = has('x-frame-options');
54
+ const frameAncestors = csp ? /frame-ancestors/i.test(csp) : false;
55
+ if (xfo || frameAncestors)
56
+ check('Protection contre l’encadrement', 'pass', xfo ?? 'frame-ancestors (CSP)');
57
+ else {
58
+ check('Protection contre l’encadrement', 'fail', 'ni X-Frame-Options ni frame-ancestors');
59
+ findings.push({ severity: 'medium', title: 'Page encadrable par un autre site', detail: 'Sans X-Frame-Options ni frame-ancestors, la page peut être affichée dans une iframe invisible (détournement de clic).', recommendation: 'Ajouter « X-Frame-Options: DENY » ou « frame-ancestors \'none\' » dans la CSP.' });
60
+ }
61
+ const nosniff = has('x-content-type-options');
62
+ if (nosniff?.toLowerCase().includes('nosniff'))
63
+ check('X-Content-Type-Options', 'pass', nosniff);
64
+ else {
65
+ check('X-Content-Type-Options', 'fail', nosniff ?? 'absent');
66
+ findings.push({ severity: 'low', title: 'X-Content-Type-Options absent', detail: 'Le navigateur peut deviner le type d’un fichier et exécuter comme script un contenu qui n’en est pas un.', recommendation: 'Ajouter « X-Content-Type-Options: nosniff ».' });
67
+ }
68
+ const referrer = has('referrer-policy');
69
+ check('Referrer-Policy', referrer ? 'pass' : 'warn', referrer ?? 'absent');
70
+ if (!referrer)
71
+ findings.push({ severity: 'low', title: 'Referrer-Policy absente', detail: 'L’adresse complète des pages peut être transmise aux sites tiers.', recommendation: 'Ajouter « Referrer-Policy: strict-origin-when-cross-origin ».' });
72
+ const permissions = has('permissions-policy');
73
+ check('Permissions-Policy', permissions ? 'pass' : 'info', permissions ? (permissions.length > 100 ? `${permissions.slice(0, 100)}…` : permissions) : 'absent');
74
+ for (const name of ['server', 'x-powered-by', 'x-aspnet-version']) {
75
+ const value = has(name);
76
+ if (!value)
77
+ continue;
78
+ const versioned = /\d+\.\d+/.test(value);
79
+ check(`En-tête ${name}`, versioned ? 'warn' : 'info', value);
80
+ if (versioned)
81
+ findings.push({ severity: 'low', title: `Version divulguée par l’en-tête ${name}`, detail: 'Connaître la version exacte aide à choisir une faille connue.', evidence: `${name}: ${value}`, recommendation: 'Masquer la version (server_tokens off sous nginx, ServerTokens Prod sous Apache).' });
82
+ }
83
+ for (const cookie of setCookies) {
84
+ const cookieName = cookie.split('=')[0]?.trim() ?? 'cookie';
85
+ const flags = cookie.toLowerCase();
86
+ const missing = [isHttps && !/;\s*secure/.test(flags) ? 'Secure' : null, !/;\s*httponly/.test(flags) ? 'HttpOnly' : null, !/;\s*samesite=/.test(flags) ? 'SameSite' : null].filter((f) => f !== null);
87
+ check(`Cookie ${cookieName}`, missing.length === 0 ? 'pass' : 'fail', missing.length === 0 ? 'Secure, HttpOnly, SameSite' : `manque ${missing.join(', ')}`);
88
+ if (missing.length > 0) {
89
+ const sensitive = SESSION_COOKIE.test(cookieName);
90
+ findings.push({
91
+ severity: sensitive && (missing.includes('Secure') || missing.includes('HttpOnly')) ? 'medium' : 'low',
92
+ title: `Cookie « ${cookieName} » mal protégé`,
93
+ detail: `Attributs manquants : ${missing.join(', ')}.${sensitive ? ' Son nom laisse penser qu’il porte la session.' : ''}`,
94
+ recommendation: 'Poser Secure, HttpOnly et SameSite=Lax (ou Strict) sur les cookies de session.',
95
+ });
96
+ }
97
+ }
98
+ const acao = has('access-control-allow-origin');
99
+ if (acao === '*' && has('access-control-allow-credentials')?.toLowerCase() === 'true') {
100
+ check('CORS', 'fail', 'origine * avec identifiants');
101
+ findings.push({ severity: 'high', title: 'CORS ouvert avec identifiants', detail: 'Access-Control-Allow-Origin: * combiné à Allow-Credentials: true expose les réponses authentifiées à tous les sites.', recommendation: 'Lister les origines autorisées une par une.' });
102
+ }
103
+ else if (acao)
104
+ check('CORS', acao === '*' ? 'info' : 'pass', `Access-Control-Allow-Origin: ${acao}`);
105
+ const summary = `${resteACorrigerEnTete(findings.filter((f) => f.severity !== 'info').length)} sur ${checks.length} vérifications.`;
106
+ return { summary, findings, checks };
107
+ }
108
+ export const headersModule = {
109
+ info: {
110
+ id: 'headers',
111
+ letter: 'γ',
112
+ title: 'En-têtes HTTP',
113
+ objective: 'Vérifier que le site envoie les en-têtes qui protègent ses visiteurs : HSTS, CSP, anti-encadrement, cookies.',
114
+ perimeter: 'Une requête GET sur l’adresse donnée, plus trois redirections au maximum, sans sortir du périmètre autorisé.',
115
+ results: 'Chaque en-tête attendu avec sa valeur, et pour chaque manque le risque et la ligne à ajouter.',
116
+ input: 'url',
117
+ fields: [urlField],
118
+ },
119
+ resolve: (input) => {
120
+ const url = parseUrl(input.url);
121
+ return { label: url.origin + (url.pathname === '/' ? '' : url.pathname), host: url.hostname };
122
+ },
123
+ async run(input, ctx) {
124
+ const url = parseUrl(input.url);
125
+ ctx.progress('Requête sur le site', 0, 1);
126
+ const { result, leftScope } = await fetchPage(url, ctx);
127
+ ctx.progress('Analyse des en-têtes', 1, 1);
128
+ const out = analyzeHeaders(result.headers, result.setCookies, new URL(result.url).protocol === 'https:');
129
+ out.checks.unshift({ label: 'Réponse', status: result.status < 400 ? 'info' : 'warn', value: `${result.status} sur ${result.url}` });
130
+ if (leftScope)
131
+ out.checks.push({ label: 'Redirection', status: 'info', value: `vers ${leftScope}, hors périmètre : non suivie` });
132
+ return out;
133
+ },
134
+ };
135
+ // ------------------------------------------------------------------ Audit de sécurité web
136
+ export function analyzeHtml(body, isHttps) {
137
+ const findings = [];
138
+ if (isHttps) {
139
+ const mixed = [...body.matchAll(/<(?:script|img|iframe|link|audio|video|source)\b[^>]*?(?:src|href)\s*=\s*["'](http:\/\/[^"']+)["']/gi)].map((m) => m[1] ?? '');
140
+ if (mixed.length > 0)
141
+ findings.push({ severity: 'medium', title: 'Contenu mixte', detail: `${plural(mixed.length, 'ressource chargée', 'ressources chargées')} en HTTP depuis une page HTTPS : elles peuvent être modifiées en chemin.`, evidence: mixed.slice(0, 3).join('\n'), recommendation: 'Charger toutes les ressources en HTTPS.' });
142
+ }
143
+ const insecureForm = /<form\b[^>]*action\s*=\s*["']http:\/\//i.test(body);
144
+ const password = /<input\b[^>]*type\s*=\s*["']password["']/i.test(body);
145
+ if (insecureForm)
146
+ findings.push({ severity: 'high', title: 'Formulaire envoyé en HTTP', detail: 'Un formulaire de la page envoie ses données en clair.', recommendation: 'Faire pointer l’attribut action vers une adresse HTTPS.' });
147
+ if (password && !isHttps)
148
+ findings.push({ severity: 'high', title: 'Mot de passe saisi sur une page HTTP', detail: 'Le champ mot de passe est servi sans chiffrement.', recommendation: 'Servir la page de connexion en HTTPS uniquement.' });
149
+ if (/<title>\s*Index of \//i.test(body))
150
+ findings.push({ severity: 'medium', title: 'Listage de répertoire activé', detail: 'Le serveur affiche le contenu du dossier au lieu d’une page.', recommendation: 'Désactiver l’index automatique (autoindex off, Options -Indexes).' });
151
+ return findings;
152
+ }
153
+ export const webModule = {
154
+ info: {
155
+ id: 'web',
156
+ letter: 'ε',
157
+ title: 'Audit de sécurité web',
158
+ objective: 'Contrôler le comportement de l’application : passage forcé en HTTPS, contenu mixte, formulaires, méthodes HTTP, fichiers de contact sécurité.',
159
+ perimeter: 'Huit requêtes au plus : la racine du site en HTTP, l’adresse donnée et trois redirections au plus dans ton périmètre, une requête OPTIONS, robots.txt et /.well-known/security.txt.',
160
+ results: 'Les constats sur la page d’accueil et la configuration du serveur, avec leur gravité.',
161
+ input: 'url',
162
+ fields: [urlField],
163
+ },
164
+ resolve: (input) => {
165
+ const url = parseUrl(input.url);
166
+ return { label: url.origin, host: url.hostname };
167
+ },
168
+ async run(input, ctx) {
169
+ const url = parseUrl(input.url);
170
+ const findings = [];
171
+ const checks = [];
172
+ const total = 5;
173
+ const httpsOrigin = `https://${url.host}`;
174
+ ctx.progress('Redirection HTTP vers HTTPS', 0, total);
175
+ try {
176
+ const plain = await httpRequest(`http://${url.hostname}/`, { method: 'GET', timeoutMs: 6000 });
177
+ const forced = plain.status >= 300 && plain.status < 400 && (plain.location ?? '').startsWith('https://');
178
+ checks.push({ label: 'HTTP redirigé vers HTTPS', status: forced ? 'pass' : 'fail', value: forced ? `${plain.status} vers ${plain.location}` : `réponse ${plain.status} sans redirection HTTPS` });
179
+ if (!forced)
180
+ findings.push({ severity: 'medium', title: 'Le site répond en HTTP sans rediriger', detail: 'Un visiteur qui tape l’adresse sans « https:// » reste en clair.', recommendation: 'Rediriger tout le trafic du port 80 vers HTTPS (301).' });
181
+ }
182
+ catch {
183
+ checks.push({ label: 'HTTP redirigé vers HTTPS', status: 'info', value: 'port 80 fermé ou injoignable' });
184
+ }
185
+ ctx.progress('Page d’accueil', 1, total);
186
+ const { result: page, leftScope } = await fetchPage(url, ctx);
187
+ const isHttps = new URL(page.url).protocol === 'https:';
188
+ checks.push({ label: 'Page d’accueil', status: page.status < 400 ? 'info' : 'warn', value: `${page.status} sur ${page.url}` });
189
+ if (leftScope)
190
+ checks.push({ label: 'Redirection', status: 'info', value: `vers ${leftScope}, hors périmètre : non suivie` });
191
+ const html = analyzeHtml(page.body, isHttps);
192
+ findings.push(...html);
193
+ checks.push({ label: 'Contenu de la page', status: html.length === 0 ? 'pass' : 'fail', value: html.length === 0 ? 'ni contenu mixte ni formulaire en clair' : html.map((f) => f.title).join(', ') });
194
+ ctx.progress('Méthodes HTTP', 2, total);
195
+ try {
196
+ const options = await httpRequest(url.href, { method: 'OPTIONS', timeoutMs: 6000 });
197
+ const allow = options.headers.allow ?? options.headers['access-control-allow-methods'] ?? '';
198
+ const risky = allow.split(',').map((m) => m.trim().toUpperCase()).filter((m) => ['TRACE', 'PUT', 'DELETE', 'CONNECT'].includes(m));
199
+ checks.push({ label: 'Méthodes annoncées', status: risky.length > 0 ? 'warn' : 'pass', value: allow || 'non annoncées' });
200
+ if (risky.length > 0)
201
+ findings.push({ severity: 'low', title: `Méthodes ${risky.join(', ')} annoncées`, detail: 'Le serveur annonce des méthodes rarement nécessaires sur un site public.', evidence: `Allow: ${allow}`, recommendation: 'Limiter les méthodes à GET, HEAD et POST si l’application n’en utilise pas d’autres.' });
202
+ }
203
+ catch {
204
+ checks.push({ label: 'Méthodes annoncées', status: 'skip', value: 'pas de réponse à OPTIONS' });
205
+ }
206
+ ctx.progress('robots.txt', 3, total);
207
+ try {
208
+ const robots = await httpRequest(`${isHttps ? httpsOrigin : url.origin}/robots.txt`, { timeoutMs: 6000, maxBytes: 32 * 1024 });
209
+ const disallow = robots.status === 200 ? [...robots.body.matchAll(/^\s*Disallow:\s*(\S+)/gim)].map((m) => m[1] ?? '') : [];
210
+ checks.push({ label: 'robots.txt', status: 'info', value: robots.status === 200 ? `${plural(disallow.length, 'chemin déclaré', 'chemins déclarés')}` : `absent (${robots.status})` });
211
+ const sensitive = disallow.filter((p) => /admin|backup|private|secret|config|\.git|intern/i.test(p));
212
+ if (sensitive.length > 0)
213
+ findings.push({ severity: 'info', title: 'robots.txt signale des chemins sensibles', detail: 'Ces chemins sont cachés aux moteurs de recherche, mais lisibles par tous dans robots.txt.', evidence: sensitive.slice(0, 6).join('\n'), recommendation: 'Vérifier que chacun exige une authentification.' });
214
+ }
215
+ catch {
216
+ checks.push({ label: 'robots.txt', status: 'skip', value: 'injoignable' });
217
+ }
218
+ ctx.progress('security.txt', 4, total);
219
+ try {
220
+ const sec = await httpRequest(`${isHttps ? httpsOrigin : url.origin}/.well-known/security.txt`, { timeoutMs: 6000, maxBytes: 16 * 1024 });
221
+ const present = sec.status === 200 && /^contact:/im.test(sec.body);
222
+ // Une réponse 200 sans ligne Contact peut être un vrai fichier incomplet, ou la page d'accueil servie pour tout chemin.
223
+ checks.push({ label: 'security.txt', status: present ? 'pass' : 'info', value: present ? 'présent, avec un contact' : sec.status === 200 ? 'réponse 200 sans ligne Contact' : `absent (${sec.status})` });
224
+ if (!present)
225
+ findings.push({ severity: 'info', title: sec.status === 200 ? 'Pas de ligne Contact dans security.txt' : 'Pas de fichier security.txt', detail: 'Un chercheur qui trouve une faille ne sait pas à qui la signaler.', recommendation: 'Publier /.well-known/security.txt avec une ligne Contact.' });
226
+ }
227
+ catch {
228
+ checks.push({ label: 'security.txt', status: 'skip', value: 'injoignable' });
229
+ }
230
+ ctx.progress('Terminé', total, total);
231
+ const real = findings.filter((f) => f.severity !== 'info').length;
232
+ return { summary: `${resteACorrigerEnTete(real)} sur la page d’accueil et le serveur.`, findings, checks };
233
+ },
234
+ };
235
+ export const PROBES = [
236
+ { path: '/.git/HEAD', label: 'Dépôt Git', signature: /^ref: refs\//m, severity: 'critical', detail: 'Le dossier .git est servi : tout le code source et son historique sont téléchargeables.', recommendation: 'Interdire l’accès à /.git/ et retirer le dossier du serveur.' },
237
+ { path: '/.env', label: 'Fichier .env', signature: /^[A-Z][A-Z0-9_]{2,}\s*=\s*\S/m, severity: 'critical', detail: 'Le fichier .env est lisible : il contient en général des mots de passe et des clés d’API.', recommendation: 'Bloquer l’accès aux fichiers cachés et changer tous les secrets exposés.' },
238
+ { path: '/.svn/entries', label: 'Dépôt Subversion', signature: /^\d+\s*$|svn:/m, severity: 'high', detail: 'Les métadonnées Subversion sont servies.', recommendation: 'Interdire l’accès à /.svn/.' },
239
+ { path: '/.htaccess', label: 'Fichier .htaccess', signature: /RewriteEngine|AuthType|Require |<Files/i, severity: 'medium', detail: 'La configuration Apache du dossier est lisible.', recommendation: 'Bloquer l’accès aux fichiers commençant par un point.' },
240
+ { path: '/web.config', label: 'web.config', signature: /<configuration[\s>]/i, severity: 'high', detail: 'La configuration IIS est lisible, parfois avec des chaînes de connexion.', recommendation: 'Vérifier le filtrage des requêtes IIS.' },
241
+ { path: '/phpinfo.php', label: 'phpinfo()', signature: /phpinfo\(\)|PHP Version \d/i, severity: 'high', detail: 'La page phpinfo détaille la configuration du serveur, ses chemins et ses variables d’environnement.', recommendation: 'Supprimer ce fichier.' },
242
+ { path: '/server-status', label: 'Statut Apache', signature: /Apache Server Status/i, severity: 'medium', detail: 'La page de statut montre les requêtes en cours et les adresses des visiteurs.', recommendation: 'Restreindre mod_status à la machine locale.' },
243
+ { path: '/actuator/env', label: 'Spring Actuator', signature: /"propertySources"|"activeProfiles"/, severity: 'high', detail: 'L’environnement de l’application Spring est exposé.', recommendation: 'Fermer ou authentifier les points d’accès Actuator.' },
244
+ { path: '/config.php.bak', label: 'Sauvegarde de configuration', signature: /<\?php/, severity: 'high', detail: 'Une sauvegarde de fichier PHP est servie en texte : son code et ses secrets sont lisibles.', recommendation: 'Supprimer les sauvegardes du dossier public.' },
245
+ { path: '/wp-config.php.bak', label: 'Sauvegarde WordPress', signature: /DB_PASSWORD|<\?php/, severity: 'critical', detail: 'La sauvegarde de wp-config.php expose les identifiants de la base de données.', recommendation: 'Supprimer le fichier et changer le mot de passe de la base.' },
246
+ { path: '/.DS_Store', label: 'Fichier .DS_Store', signature: /Bud1/, severity: 'low', detail: 'Ce fichier macOS révèle les noms des fichiers du dossier.', recommendation: 'Le supprimer et l’exclure du déploiement.' },
247
+ { path: '/composer.json', label: 'composer.json', signature: /"require"\s*:/, severity: 'low', detail: 'La liste des dépendances PHP et leurs versions sont publiques.', recommendation: 'Ne pas servir ce fichier.' },
248
+ { path: '/package.json', label: 'package.json', signature: /"dependencies"\s*:|"name"\s*:/, severity: 'low', detail: 'La liste des dépendances JavaScript et leurs versions sont publiques.', recommendation: 'Ne pas servir ce fichier.' },
249
+ ];
250
+ export const exposureModule = {
251
+ info: {
252
+ id: 'exposure',
253
+ letter: 'ζ',
254
+ title: 'Mauvaises configurations',
255
+ objective: 'Repérer les fichiers qui ne devraient jamais être servis : dépôt Git, .env, sauvegardes, pages de diagnostic.',
256
+ perimeter: `${PROBES.length} requêtes GET sur des chemins connus (${PROBES.slice(0, 4).map((p) => p.path).join(', ')}…). Chaque réponse est comparée à la signature du vrai fichier.`,
257
+ results: 'La liste des chemins testés et, pour chaque fichier exposé, ce qu’il révèle et comment le fermer.',
258
+ input: 'url',
259
+ fields: [urlField],
260
+ },
261
+ resolve: (input) => {
262
+ const url = parseUrl(input.url);
263
+ return { label: url.origin, host: url.hostname };
264
+ },
265
+ async run(input, ctx) {
266
+ const url = parseUrl(input.url);
267
+ const findings = [];
268
+ let done = 0;
269
+ ctx.progress('Chemins sensibles', 0, PROBES.length);
270
+ const results = await pool(PROBES, 4, async (probe) => {
271
+ let status = 0;
272
+ let exposed = false;
273
+ try {
274
+ const res = await httpRequest(url.origin + probe.path, { timeoutMs: 6000, maxBytes: 64 * 1024 });
275
+ status = res.status;
276
+ exposed = res.status === 200 && probe.signature.test(res.body);
277
+ }
278
+ catch {
279
+ status = -1;
280
+ }
281
+ ctx.progress(probe.path, ++done, PROBES.length);
282
+ return { probe, status, exposed };
283
+ });
284
+ const checks = results.map(({ probe, status, exposed }) => ({
285
+ label: probe.path,
286
+ status: exposed ? 'fail' : status === -1 ? 'skip' : 'pass',
287
+ value: exposed ? `exposé (${probe.label})` : status === -1 ? 'pas de réponse' : status === 200 ? 'réponse 200 sans le contenu attendu' : `réponse ${status}`,
288
+ }));
289
+ for (const { probe, exposed } of results) {
290
+ if (exposed)
291
+ findings.push({ severity: probe.severity, title: `${probe.label} accessible`, detail: probe.detail, evidence: url.origin + probe.path, recommendation: probe.recommendation });
292
+ }
293
+ // Un chemin sans réponse ne prouve rien : il ne compte pas parmi les chemins « non exposés ».
294
+ const muets = results.filter((r) => r.status === -1).length;
295
+ const rien = muets === 0
296
+ ? `Aucun des ${PROBES.length} fichiers sensibles testés n’est exposé.`
297
+ : muets === PROBES.length
298
+ ? `Aucun des ${PROBES.length} chemins testés n’a répondu.`
299
+ : `Aucun fichier exposé parmi ${plural(PROBES.length - muets, 'chemin qui a répondu', 'chemins qui ont répondu')}, ${muets} sans réponse.`;
300
+ return { summary: findings.length === 0 ? rien : `${plural(findings.length, 'fichier sensible exposé', 'fichiers sensibles exposés')} sur ${PROBES.length} testés.`, findings, checks };
301
+ },
302
+ };
@@ -0,0 +1,141 @@
1
+ /** Sondes réseau des modules d'audit : requête HTTP(S), connexion TCP, poignée de main TLS. */
2
+ import http from 'node:http';
3
+ import https from 'node:https';
4
+ import net from 'node:net';
5
+ import tls from 'node:tls';
6
+ const USER_AGENT = 'Promethee-Audit/2.0 (audit local autorisé)';
7
+ /** Requête unique, sans suivi de redirection. Les certificats invalides sont acceptés : c'est le module TLS qui les juge. */
8
+ export function httpRequest(target, options = {}) {
9
+ const url = new URL(target);
10
+ const lib = url.protocol === 'https:' ? https : http;
11
+ const maxBytes = options.maxBytes ?? 256 * 1024;
12
+ return new Promise((resolve, reject) => {
13
+ const req = lib.request(url, { method: options.method ?? 'GET', headers: { 'User-Agent': USER_AGENT, Accept: '*/*' }, timeout: options.timeoutMs ?? 8000, agent: false, rejectUnauthorized: false }, (res) => {
14
+ const chunks = [];
15
+ let size = 0;
16
+ res.on('data', (chunk) => {
17
+ size += chunk.length;
18
+ if (size <= maxBytes)
19
+ chunks.push(chunk);
20
+ else
21
+ res.destroy();
22
+ });
23
+ const finish = () => {
24
+ const headers = {};
25
+ for (const [name, value] of Object.entries(res.headers))
26
+ if (value !== undefined)
27
+ headers[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value;
28
+ resolve({
29
+ url: url.href,
30
+ status: res.statusCode ?? 0,
31
+ headers,
32
+ setCookies: res.headers['set-cookie'] ?? [],
33
+ body: Buffer.concat(chunks).toString('utf8'),
34
+ location: res.headers.location ? new URL(res.headers.location, url).href : null,
35
+ });
36
+ };
37
+ res.on('end', finish);
38
+ res.on('close', finish);
39
+ });
40
+ req.on('timeout', () => req.destroy(new Error('délai dépassé')));
41
+ req.on('error', (err) => reject(err));
42
+ req.end();
43
+ });
44
+ }
45
+ /** Connexion TCP complète puis fermeture immédiate : aucun paquet forgé. */
46
+ export function tcpProbe(host, port, timeoutMs = 1500) {
47
+ return new Promise((resolve) => {
48
+ const socket = net.connect({ host, port });
49
+ let settled = false;
50
+ const done = (state) => {
51
+ if (settled)
52
+ return;
53
+ settled = true;
54
+ socket.destroy();
55
+ resolve(state);
56
+ };
57
+ socket.setTimeout(timeoutMs, () => done('filtered'));
58
+ socket.on('connect', () => done('open'));
59
+ socket.on('error', (err) => done(err.code === 'ECONNREFUSED' ? 'closed' : 'filtered'));
60
+ });
61
+ }
62
+ const name = (o) => {
63
+ if (!o || typeof o !== 'object')
64
+ return '';
65
+ const record = o;
66
+ return String(record.CN ?? record.O ?? '');
67
+ };
68
+ /** Poignée de main TLS. « only » force une seule version du protocole pour savoir si le serveur l'accepte. */
69
+ export function tlsProbe(host, port, only, timeoutMs = 6000) {
70
+ return new Promise((resolve) => {
71
+ let socket;
72
+ try {
73
+ socket = tls.connect({
74
+ host,
75
+ port,
76
+ servername: net.isIP(host) ? undefined : host,
77
+ rejectUnauthorized: false,
78
+ ...(only ? { minVersion: only, maxVersion: only, ...(only === 'TLSv1' || only === 'TLSv1.1' ? { ciphers: 'DEFAULT@SECLEVEL=0' } : {}) } : {}),
79
+ });
80
+ }
81
+ catch (err) {
82
+ resolve({ ok: false, reason: 'unsupported', message: err instanceof Error ? err.message : String(err) });
83
+ return;
84
+ }
85
+ let settled = false;
86
+ const done = (attempt) => {
87
+ if (settled)
88
+ return;
89
+ settled = true;
90
+ socket.destroy();
91
+ resolve(attempt);
92
+ };
93
+ socket.setTimeout(timeoutMs, () => done({ ok: false, reason: 'unreachable', message: 'délai dépassé' }));
94
+ socket.on('secureConnect', () => {
95
+ const cert = socket.getPeerCertificate();
96
+ const alt = typeof cert.subjectaltname === 'string' ? cert.subjectaltname.split(',').map((s) => s.trim().replace(/^DNS:|^IP Address:/, '')) : [];
97
+ done({
98
+ ok: true,
99
+ info: {
100
+ protocol: socket.getProtocol(),
101
+ cipher: socket.getCipher()?.name ?? null,
102
+ authorized: socket.authorized,
103
+ authorizationError: socket.authorizationError ? String(socket.authorizationError) : null,
104
+ subject: name(cert.subject),
105
+ issuer: name(cert.issuer),
106
+ validFrom: cert.valid_from ?? '',
107
+ validTo: cert.valid_to ?? '',
108
+ altNames: alt,
109
+ keyBits: typeof cert.bits === 'number' ? cert.bits : null,
110
+ selfSigned: Boolean(cert.subject && cert.issuer && JSON.stringify(cert.subject) === JSON.stringify(cert.issuer)),
111
+ },
112
+ });
113
+ });
114
+ socket.on('error', (err) => {
115
+ const text = `${err.code ?? ''} ${err.message}`;
116
+ // Le client lui-même ne sait pas parler cette version : on ne peut rien conclure sur le serveur.
117
+ if (/NO_PROTOCOLS_AVAILABLE|UNSUPPORTED_PROTOCOL|no protocols available|unsupported protocol/i.test(text) && !/alert/i.test(text))
118
+ done({ ok: false, reason: 'unsupported', message: err.message });
119
+ else if (/ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|ETIMEDOUT|EAI_AGAIN/.test(text))
120
+ done({ ok: false, reason: 'unreachable', message: err.message });
121
+ else
122
+ done({ ok: false, reason: 'refused', message: err.message });
123
+ });
124
+ });
125
+ }
126
+ /** Exécute des tâches avec un nombre limité en parallèle. */
127
+ export async function pool(items, limit, worker) {
128
+ const results = new Array(items.length);
129
+ let next = 0;
130
+ const run = async () => {
131
+ for (;;) {
132
+ const index = next++;
133
+ const item = items[index];
134
+ if (index >= items.length || item === undefined)
135
+ return;
136
+ results[index] = await worker(item, index);
137
+ }
138
+ };
139
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
140
+ return results;
141
+ }
@@ -0,0 +1,202 @@
1
+ /** Registre des modules d'audit : contrôle du périmètre, exécution, historique, rapport. */
2
+ import { randomBytes } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { SEVERITIES } from '../../shared/types.js';
6
+ import { describeError, forbidden, notFound } from '../errors.js';
7
+ import { hostInScope, isLoopback } from '../engine/paths.js';
8
+ import { readJson, writeJson } from '../stores.js';
9
+ import { configModule, filesModule } from './modules/local.js';
10
+ import { portsModule, surfaceModule, tlsModule } from './modules/network.js';
11
+ import { exposureModule, headersModule, webModule } from './modules/web.js';
12
+ import { parseHost } from './types.js';
13
+ export const MODULES = [surfaceModule, portsModule, headersModule, tlsModule, webModule, exposureModule, configModule, filesModule];
14
+ const SEVERITY_LABELS = { critical: 'Critique', high: 'Élevée', medium: 'Moyenne', low: 'Faible', info: 'Information' };
15
+ export function countBySeverity(findings) {
16
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
17
+ for (const f of findings)
18
+ counts[f.severity]++;
19
+ return counts;
20
+ }
21
+ const summarize = (run) => ({ id: run.id, module: run.module, target: run.target, startedAt: run.startedAt, durationMs: run.durationMs, status: run.status, counts: countBySeverity(run.findings) });
22
+ export class Audits {
23
+ runs = new Map();
24
+ deps;
25
+ constructor(deps) {
26
+ this.deps = deps;
27
+ if (!deps.dir)
28
+ return;
29
+ fs.mkdirSync(deps.dir, { recursive: true });
30
+ for (const name of fs.readdirSync(deps.dir)) {
31
+ if (!/^[a-f0-9]{16}\.json$/.test(name))
32
+ continue;
33
+ const run = readJson(path.join(deps.dir, name), null);
34
+ if (run && typeof run.id === 'string' && Array.isArray(run.findings))
35
+ this.runs.set(run.id, run);
36
+ }
37
+ }
38
+ modules() {
39
+ return MODULES.map((m) => m.info);
40
+ }
41
+ /** Lance un module. Le périmètre est vérifié avant le premier paquet. */
42
+ async *run(moduleId, input) {
43
+ const mod = MODULES.find((m) => m.info.id === moduleId);
44
+ if (!mod)
45
+ throw notFound('Module d’audit inconnu.');
46
+ const { label, host } = mod.resolve(input);
47
+ const targets = this.deps.targets();
48
+ if (host !== null && !hostInScope(host, targets)) {
49
+ this.deps.journal.add({ kind: 'refusal', outcome: 'refused', title: `Audit « ${mod.info.title} » refusé : ${host} hors périmètre`, data: { module: moduleId, host } });
50
+ throw forbidden(`« ${host} » n’est pas dans ton périmètre autorisé. Déclare cette cible dans Audits › Périmètre si elle t’appartient ou si tu as une autorisation écrite.`, 'target_out_of_scope');
51
+ }
52
+ const queue = [];
53
+ let wake = null;
54
+ let finished = false;
55
+ const push = (event) => {
56
+ queue.push(event);
57
+ wake?.();
58
+ wake = null;
59
+ };
60
+ const started = Date.now();
61
+ const base = { id: randomBytes(8).toString('hex'), module: moduleId, target: label, startedAt: new Date(started).toISOString() };
62
+ const work = (async () => {
63
+ let run;
64
+ try {
65
+ const out = await mod.run(input, {
66
+ progress: (text, done, total) => push({ type: 'progress', label: text, done, total }),
67
+ engine: this.deps.engine,
68
+ roots: this.deps.settings().engine.roots,
69
+ home: this.deps.home,
70
+ now: new Date(),
71
+ inScope: (h) => hostInScope(h, targets),
72
+ });
73
+ run = { ...base, durationMs: Date.now() - started, status: 'done', summary: out.summary, checks: out.checks, findings: out.findings.map((f) => ({ ...f, id: randomBytes(6).toString('hex') })) };
74
+ }
75
+ catch (err) {
76
+ const { message } = describeError(err);
77
+ run = { ...base, durationMs: Date.now() - started, status: 'error', error: message, summary: 'Le module n’a pas pu aller au bout.', checks: [], findings: [] };
78
+ }
79
+ this.runs.set(run.id, run);
80
+ if (this.deps.dir)
81
+ writeJson(path.join(this.deps.dir, `${run.id}.json`), run);
82
+ const counts = countBySeverity(run.findings);
83
+ this.deps.journal.add({
84
+ kind: 'audit',
85
+ outcome: run.status === 'done' ? 'ok' : 'error',
86
+ title: `${mod.info.title} · ${label}`,
87
+ detail: run.status === 'done' ? run.summary : run.error,
88
+ data: { audit: run.id, module: moduleId, target: label, durationMs: run.durationMs, counts },
89
+ });
90
+ push({ type: 'result', run });
91
+ })().finally(() => {
92
+ finished = true;
93
+ wake?.();
94
+ });
95
+ for (;;) {
96
+ const event = queue.shift();
97
+ if (event) {
98
+ yield event;
99
+ continue;
100
+ }
101
+ if (finished)
102
+ break;
103
+ await new Promise((resolve) => {
104
+ wake = resolve;
105
+ });
106
+ }
107
+ await work;
108
+ }
109
+ get(id) {
110
+ const run = this.runs.get(id);
111
+ if (!run)
112
+ throw notFound('Audit introuvable.');
113
+ return run;
114
+ }
115
+ remove(id) {
116
+ this.get(id);
117
+ this.runs.delete(id);
118
+ if (this.deps.dir)
119
+ fs.rmSync(path.join(this.deps.dir, `${id}.json`), { force: true });
120
+ }
121
+ history(filter = {}) {
122
+ return [...this.runs.values()]
123
+ .filter((r) => !filter.module || r.module === filter.module)
124
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
125
+ .map(summarize);
126
+ }
127
+ /** Dernier audit réussi de chaque couple module + cible : c'est l'état actuel connu. */
128
+ latest() {
129
+ const byKey = new Map();
130
+ for (const run of this.runs.values()) {
131
+ if (run.status !== 'done')
132
+ continue;
133
+ const key = `${run.module}|${run.target}`;
134
+ const known = byKey.get(key);
135
+ if (!known || known.startedAt < run.startedAt)
136
+ byKey.set(key, run);
137
+ }
138
+ return [...byKey.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
139
+ }
140
+ report(id) {
141
+ const run = this.get(id);
142
+ const mod = MODULES.find((m) => m.info.id === run.module);
143
+ const date = new Date(run.startedAt).toLocaleString('fr-FR');
144
+ const lines = [
145
+ `# ${mod?.info.title ?? run.module} · ${run.target}`,
146
+ '',
147
+ `- Date : ${date}`,
148
+ `- Durée : ${(run.durationMs / 1000).toFixed(1)} s`,
149
+ `- Module : ${mod?.info.title ?? run.module}`,
150
+ `- Périmètre du test : ${mod?.info.perimeter ?? ''}`,
151
+ '',
152
+ `## Résumé`,
153
+ '',
154
+ run.status === 'done' ? run.summary : `Échec : ${run.error ?? 'erreur inconnue'}`,
155
+ '',
156
+ ];
157
+ const counts = countBySeverity(run.findings);
158
+ if (run.findings.length > 0) {
159
+ lines.push('## Constats', '', '| Gravité | Nombre |', '|---|---|');
160
+ for (const s of SEVERITIES)
161
+ if (counts[s] > 0)
162
+ lines.push(`| ${SEVERITY_LABELS[s]} | ${counts[s]} |`);
163
+ lines.push('');
164
+ for (const s of SEVERITIES) {
165
+ for (const f of run.findings.filter((x) => x.severity === s)) {
166
+ lines.push(`### [${SEVERITY_LABELS[s]}] ${f.title}`, '', f.detail, '');
167
+ if (f.evidence)
168
+ lines.push('```', f.evidence, '```', '');
169
+ if (f.recommendation)
170
+ lines.push(`**Correction :** ${f.recommendation}`, '');
171
+ }
172
+ }
173
+ }
174
+ if (run.checks.length > 0) {
175
+ const status = { pass: 'conforme', fail: 'à corriger', warn: 'à surveiller', info: 'information', skip: 'non vérifié' };
176
+ lines.push('## Vérifications', '', '| Vérification | État | Valeur |', '|---|---|---|');
177
+ for (const c of run.checks)
178
+ lines.push(`| ${c.label.replace(/\|/g, '\\|')} | ${status[c.status]} | ${c.value.replace(/\|/g, '\\|').replace(/\n/g, ' ')} |`);
179
+ lines.push('');
180
+ }
181
+ lines.push('---', `Rapport produit par Prométhée, ${ouLeTestAPorte(run.target, mod)}.`);
182
+ return lines.join('\n');
183
+ }
184
+ }
185
+ /**
186
+ * Où le test a porté, pour la dernière ligne du rapport exporté. « Une cible déclarée » est faux pour la machine elle-même
187
+ * (127.0.0.1 n'est jamais déclarée) et pour les modules sans cible réseau (relecture D, groupe des « Haute »).
188
+ */
189
+ export function ouLeTestAPorte(target, mod) {
190
+ if (mod?.info.input === 'path')
191
+ return 'sur des fichiers des dossiers autorisés de cette machine';
192
+ if (mod?.info.input === 'none')
193
+ return 'sur cette machine';
194
+ let hote = null;
195
+ try {
196
+ hote = parseHost(target).host;
197
+ }
198
+ catch {
199
+ hote = null;
200
+ }
201
+ return hote !== null && isLoopback(hote) ? 'sur cette machine' : 'sur une cible déclarée dans le périmètre autorisé';
202
+ }