@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,331 @@
1
+ /** Modules réseau : surface d'attaque (DNS), ports ouverts, TLS. */
2
+ import { Resolver } from 'node:dns/promises';
3
+ import net from 'node:net';
4
+ import { badRequest } from '../../errors.js';
5
+ import { httpRequest, pool, tcpProbe, tlsProbe } from '../net.js';
6
+ import { isLoopback } from '../../engine/paths.js';
7
+ import { parseHost, plural, resteACorriger } from '../types.js';
8
+ const targetField = { key: 'target', label: 'Cible', help: 'Nom d’hôte ou IP de ton périmètre autorisé.', placeholder: 'exemple.fr', required: true };
9
+ // ------------------------------------------------------------------ Surface d'attaque
10
+ async function lookup(query) {
11
+ try {
12
+ return await query();
13
+ }
14
+ catch {
15
+ return [];
16
+ }
17
+ }
18
+ export const surfaceModule = {
19
+ info: {
20
+ id: 'surface',
21
+ letter: 'α',
22
+ title: 'Surface d’attaque',
23
+ objective: 'Dresser ce qu’un attaquant voit en premier : adresses, serveurs de noms, messagerie, services web.',
24
+ perimeter: 'Requêtes DNS (A, AAAA, CNAME, NS, MX, TXT, DMARC, et PTR de la première adresse), puis une requête HEAD sur le port 80 et une sur le port 443. Aucun balayage de ports.',
25
+ results: 'Les enregistrements trouvés, la protection de la messagerie (SPF, DMARC) et ce que les serveurs web annoncent.',
26
+ input: 'target',
27
+ fields: [targetField],
28
+ },
29
+ resolve: (input) => {
30
+ const { host } = parseHost(input.target);
31
+ return { label: host, host };
32
+ },
33
+ async run(input, ctx) {
34
+ const { host } = parseHost(input.target);
35
+ const findings = [];
36
+ const checks = [];
37
+ const isIp = net.isIP(host) !== 0;
38
+ const resolver = new Resolver({ timeout: 4000, tries: 2 });
39
+ const total = 4;
40
+ ctx.progress('Résolution DNS', 0, total);
41
+ const [a, aaaa, cname, ns, mx, txt] = isIp
42
+ ? [[host], [], [], [], [], []]
43
+ : await Promise.all([
44
+ lookup(() => resolver.resolve4(host)),
45
+ lookup(() => resolver.resolve6(host)),
46
+ lookup(() => resolver.resolveCname(host)),
47
+ lookup(() => resolver.resolveNs(host)),
48
+ lookup(() => resolver.resolveMx(host)),
49
+ lookup(() => resolver.resolveTxt(host)),
50
+ ]);
51
+ const list = (items) => (items.length > 0 ? items.join(', ') : 'aucun');
52
+ checks.push({ label: 'Adresses IPv4 (A)', status: a.length > 0 ? 'info' : 'warn', value: list(a) });
53
+ if (!isIp) {
54
+ checks.push({ label: 'Adresses IPv6 (AAAA)', status: 'info', value: list(aaaa) });
55
+ if (cname.length > 0)
56
+ checks.push({ label: 'Alias (CNAME)', status: 'info', value: list(cname) });
57
+ checks.push({ label: 'Serveurs de noms (NS)', status: 'info', value: list(ns) });
58
+ checks.push({ label: 'Messagerie (MX)', status: 'info', value: list(mx.sort((x, y) => x.priority - y.priority).map((m) => `${m.exchange} (${m.priority})`)) });
59
+ }
60
+ if (a.length === 0 && aaaa.length === 0 && cname.length === 0) {
61
+ findings.push({ severity: 'info', title: 'Le nom ne se résout pas', detail: `Aucune adresse trouvée pour ${host}. Vérifie l’orthographe ou la zone DNS.` });
62
+ }
63
+ ctx.progress('Protection de la messagerie', 1, total);
64
+ if (!isIp) {
65
+ const flat = txt.map((parts) => parts.join(''));
66
+ const spf = flat.find((t) => /^v=spf1/i.test(t));
67
+ const dmarc = (await lookup(() => resolver.resolveTxt(`_dmarc.${host}`))).map((p) => p.join('')).find((t) => /^v=DMARC1/i.test(t));
68
+ const sendsMail = mx.length > 0;
69
+ checks.push({ label: 'SPF', status: spf ? 'pass' : sendsMail ? 'fail' : 'info', value: spf ?? 'absent' });
70
+ checks.push({ label: 'DMARC', status: dmarc ? (/p=none/i.test(dmarc) ? 'warn' : 'pass') : sendsMail ? 'fail' : 'info', value: dmarc ?? 'absent' });
71
+ if (!spf)
72
+ findings.push({ severity: sendsMail ? 'medium' : 'low', title: 'Pas d’enregistrement SPF', detail: 'N’importe quel serveur peut envoyer des courriels au nom de ce domaine sans être signalé.', recommendation: sendsMail ? 'Publier un enregistrement TXT « v=spf1 … -all » listant tes serveurs d’envoi.' : 'Si le domaine n’envoie pas de courriel, publier « v=spf1 -all ».' });
73
+ else if (/\+all/i.test(spf))
74
+ findings.push({ severity: 'high', title: 'SPF qui autorise tout le monde', detail: 'Le mécanisme +all valide n’importe quel expéditeur.', evidence: spf, recommendation: 'Remplacer +all par -all.' });
75
+ if (!dmarc)
76
+ findings.push({ severity: sendsMail ? 'medium' : 'low', title: 'Pas d’enregistrement DMARC', detail: 'Sans DMARC, les destinataires ne savent pas quoi faire des courriels qui usurpent le domaine.', recommendation: 'Publier « _dmarc » avec « v=DMARC1; p=quarantine; rua=mailto:… ».' });
77
+ else if (/p=none/i.test(dmarc))
78
+ findings.push({ severity: 'low', title: 'DMARC en simple observation', detail: 'La politique p=none ne bloque rien.', evidence: dmarc, recommendation: 'Passer à p=quarantine puis p=reject une fois les rapports vérifiés.' });
79
+ }
80
+ ctx.progress('Services web', 2, total);
81
+ for (const [scheme, port] of [['http', 80], ['https', 443]]) {
82
+ try {
83
+ const res = await httpRequest(`${scheme}://${host}/`, { method: 'HEAD', timeoutMs: 5000 });
84
+ const server = res.headers.server ?? 'non annoncé';
85
+ const redirect = res.location ? ` → ${res.location}` : '';
86
+ checks.push({ label: `Port ${port} (${scheme.toUpperCase()})`, status: 'info', value: `${res.status}${redirect} · serveur : ${server}` });
87
+ if (/\d+\.\d+/.test(server))
88
+ findings.push({ severity: 'low', title: `Version du serveur visible sur le port ${port}`, detail: 'Le serveur annonce son logiciel et sa version exacte.', evidence: `Server: ${server}`, recommendation: 'Masquer la version dans la configuration du serveur.' });
89
+ }
90
+ catch {
91
+ checks.push({ label: `Port ${port} (${scheme.toUpperCase()})`, status: 'info', value: 'pas de réponse' });
92
+ }
93
+ }
94
+ ctx.progress('Nom inverse', 3, total);
95
+ const first = a[0];
96
+ if (first) {
97
+ const reverse = await lookup(() => resolver.reverse(first));
98
+ checks.push({ label: `Nom inverse de ${first}`, status: 'info', value: list(reverse) });
99
+ }
100
+ ctx.progress('Terminé', total, total);
101
+ const real = findings.filter((f) => f.severity !== 'info').length;
102
+ return { summary: `${plural(a.length + aaaa.length, 'adresse', 'adresses')}, ${plural(mx.length, 'serveur de messagerie', 'serveurs de messagerie')}, ${resteACorriger(real)}.`, findings, checks };
103
+ },
104
+ };
105
+ // ------------------------------------------------------------------ Ports
106
+ export const DEFAULT_PORTS = [21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 465, 587, 993, 995, 1433, 3306, 3389, 5432, 5900, 5985, 6379, 8080, 8443, 9200, 11434, 27017];
107
+ const SERVICES = {
108
+ 21: { name: 'FTP', risky: 'transfert de fichiers en clair' },
109
+ 22: { name: 'SSH' },
110
+ 23: { name: 'Telnet', risky: 'administration en clair' },
111
+ 25: { name: 'SMTP' },
112
+ 53: { name: 'DNS' },
113
+ 80: { name: 'HTTP' },
114
+ 110: { name: 'POP3' },
115
+ 135: { name: 'RPC Windows', risky: 'service Windows interne' },
116
+ 139: { name: 'NetBIOS', risky: 'partage Windows' },
117
+ 143: { name: 'IMAP' },
118
+ 443: { name: 'HTTPS' },
119
+ 445: { name: 'SMB', risky: 'partage de fichiers Windows' },
120
+ 465: { name: 'SMTPS' },
121
+ 587: { name: 'SMTP (soumission)' },
122
+ 993: { name: 'IMAPS' },
123
+ 995: { name: 'POP3S' },
124
+ 1433: { name: 'SQL Server', risky: 'base de données' },
125
+ 3306: { name: 'MySQL', risky: 'base de données' },
126
+ 3389: { name: 'Bureau à distance (RDP)', risky: 'prise de contrôle à distance' },
127
+ 5432: { name: 'PostgreSQL', risky: 'base de données' },
128
+ 5900: { name: 'VNC', risky: 'prise de contrôle à distance' },
129
+ 5985: { name: 'WinRM', risky: 'administration Windows à distance' },
130
+ 6379: { name: 'Redis', risky: 'base de données souvent sans mot de passe' },
131
+ 8080: { name: 'HTTP (alternatif)' },
132
+ 8443: { name: 'HTTPS (alternatif)' },
133
+ 9200: { name: 'Elasticsearch', risky: 'base de données souvent sans mot de passe' },
134
+ 11434: { name: 'Ollama', risky: 'API de modèles sans authentification' },
135
+ 27017: { name: 'MongoDB', risky: 'base de données' },
136
+ };
137
+ export function parsePorts(value, field) {
138
+ const raw = (value ?? '').trim();
139
+ if (!raw)
140
+ return [];
141
+ const ports = new Set();
142
+ for (const part of raw.split(/[\s,;]+/).filter(Boolean)) {
143
+ const range = /^(\d{1,5})-(\d{1,5})$/.exec(part);
144
+ const from = Number(range ? range[1] : part);
145
+ const to = Number(range ? range[2] : part);
146
+ if (!Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to > 65535 || from > to)
147
+ throw badRequest(`${field} : « ${part} » n’est pas un port valide.`);
148
+ for (let p = from; p <= to; p++)
149
+ ports.add(p);
150
+ if (ports.size > 64)
151
+ throw badRequest(`${field} : 64 ports au maximum par vérification.`);
152
+ }
153
+ return [...ports].sort((x, y) => x - y);
154
+ }
155
+ /**
156
+ * `local` : la cible est cette machine (127.0.0.1, localhost, ::1). Un port qui y répond peut n'écouter que sur la boucle locale,
157
+ * comme Ollama : le balayage ne dit pas s'il est joignable depuis le réseau, le constat ne le dit pas non plus (relecture D).
158
+ */
159
+ export function classifyPorts(results, expected, local = false) {
160
+ const findings = [];
161
+ const declared = expected.length > 0;
162
+ const checks = results.map(({ port, state }) => {
163
+ const service = SERVICES[port];
164
+ const label = `Port ${port}${service ? ` · ${service.name}` : ''}`;
165
+ if (state !== 'open')
166
+ return { label, status: expected.includes(port) ? 'warn' : 'pass', value: state === 'closed' ? 'fermé' : 'filtré (pas de réponse)' };
167
+ const unexpected = declared && !expected.includes(port);
168
+ if (unexpected) {
169
+ findings.push({ severity: service?.risky ? 'high' : 'medium', title: `Port ${port} ouvert sans être attendu`, detail: `${service?.name ?? 'Service inconnu'} répond alors qu’il ne figure pas dans ta liste de ports attendus.${service?.risky ? ` Risque : ${service.risky}.` : ''}`, recommendation: 'Fermer le port au pare-feu, ou l’ajouter à la liste s’il est voulu.' });
170
+ }
171
+ else if (!declared && service?.risky && local) {
172
+ findings.push({ severity: 'medium', title: `${service.name} ouvert (port ${port})`, detail: `Risque s’il est joignable depuis le réseau : ${service.risky}. Un test depuis cette machine ne dit pas s’il l’est.`, recommendation: `Lire son adresse d’écoute avec Get-NetTCPConnection -LocalPort ${port} -State Listen : 127.0.0.1 le garde sur cette machine, 0.0.0.0 l’ouvre au réseau.` });
173
+ }
174
+ else if (!declared && service?.risky) {
175
+ findings.push({ severity: 'medium', title: `${service.name} exposé (port ${port})`, detail: `Risque : ${service.risky}. Ce service ne devrait en général pas être joignable depuis l’extérieur.`, recommendation: 'Le restreindre à un réseau de confiance ou à un VPN.' });
176
+ }
177
+ return { label, status: unexpected ? 'fail' : !declared && service?.risky ? 'warn' : 'info', value: 'ouvert' };
178
+ });
179
+ for (const port of expected) {
180
+ const found = results.find((r) => r.port === port);
181
+ if (found && found.state !== 'open')
182
+ findings.push({ severity: 'info', title: `Port ${port} attendu mais injoignable`, detail: 'Le service prévu sur ce port ne répond pas.' });
183
+ }
184
+ const open = results.filter((r) => r.state === 'open').length;
185
+ return { summary: `${plural(open, 'port ouvert', 'ports ouverts')} sur ${results.length} testés${declared ? `, ${plural(findings.filter((f) => f.severity !== 'info').length, 'inattendu', 'inattendus')}` : ''}.`, findings, checks };
186
+ }
187
+ export const portsModule = {
188
+ info: {
189
+ id: 'ports',
190
+ letter: 'β',
191
+ title: 'Ports autorisés',
192
+ objective: 'Comparer les ports réellement ouverts à ceux que tu attends.',
193
+ perimeter: `Une connexion TCP complète par port, refermée aussitôt (pas de paquets forgés). Par défaut ${DEFAULT_PORTS.length} ports courants. 64 ports au plus par liste, 96 en tout, 12 en parallèle.`,
194
+ results: 'L’état de chaque port. Un constat par port ouvert hors de ta liste, ou, sans liste, par service à risque ouvert.',
195
+ input: 'target',
196
+ fields: [
197
+ targetField,
198
+ { key: 'ports', label: 'Ports à tester', help: `Vide : les ${DEFAULT_PORTS.length} ports courants.`, placeholder: '22, 443, 8000-8010', required: false },
199
+ { key: 'expected', label: 'Ports attendus ouverts', help: 'Rempli : tout autre port ouvert est signalé.', placeholder: '80, 443', required: false },
200
+ ],
201
+ },
202
+ resolve: (input) => {
203
+ const { host } = parseHost(input.target);
204
+ return { label: host, host };
205
+ },
206
+ async run(input, ctx) {
207
+ const { host } = parseHost(input.target);
208
+ const expected = parsePorts(input.expected, 'Ports attendus');
209
+ const asked = parsePorts(input.ports, 'Ports à tester');
210
+ const ports = [...new Set([...(asked.length > 0 ? asked : DEFAULT_PORTS), ...expected])].sort((x, y) => x - y);
211
+ if (ports.length > 96)
212
+ throw badRequest('Trop de ports : réduis la liste.');
213
+ let done = 0;
214
+ ctx.progress('Connexions TCP', 0, ports.length);
215
+ const results = await pool(ports, 12, async (port) => {
216
+ const state = await tcpProbe(host, port);
217
+ ctx.progress(`Port ${port}`, ++done, ports.length);
218
+ return { port, state };
219
+ });
220
+ return classifyPorts(results, expected, isLoopback(host));
221
+ },
222
+ };
223
+ // ------------------------------------------------------------------ TLS
224
+ const DAY = 86_400_000;
225
+ export function analyzeCertificate(info, host, now) {
226
+ const findings = [];
227
+ const checks = [];
228
+ const expires = new Date(info.validTo);
229
+ const days = Math.floor((expires.getTime() - now.getTime()) / DAY);
230
+ const dateText = Number.isNaN(expires.getTime()) ? info.validTo : expires.toLocaleDateString('fr-FR');
231
+ if (Number.isNaN(days))
232
+ checks.push({ label: 'Expiration du certificat', status: 'skip', value: 'date illisible' });
233
+ else if (days < 0) {
234
+ checks.push({ label: 'Expiration du certificat', status: 'fail', value: `expiré depuis ${-days} j (${dateText})` });
235
+ findings.push({ severity: 'critical', title: 'Certificat expiré', detail: `Le certificat a expiré le ${dateText}. Les navigateurs affichent une alerte bloquante.`, recommendation: 'Renouveler le certificat et automatiser le renouvellement.' });
236
+ }
237
+ else {
238
+ checks.push({ label: 'Expiration du certificat', status: days < 30 ? 'warn' : 'pass', value: `dans ${days} j (${dateText})` });
239
+ if (days < 15)
240
+ findings.push({ severity: 'high', title: 'Certificat sur le point d’expirer', detail: `Il reste ${days} jours.`, recommendation: 'Renouveler le certificat maintenant.' });
241
+ else if (days < 30)
242
+ findings.push({ severity: 'medium', title: 'Certificat à renouveler ce mois-ci', detail: `Il reste ${days} jours.`, recommendation: 'Planifier le renouvellement.' });
243
+ }
244
+ const matches = net.isIP(host) !== 0 || info.altNames.some((n) => n.toLowerCase() === host || (n.startsWith('*.') && host.endsWith(n.slice(1).toLowerCase()) && host.split('.').length === n.split('.').length));
245
+ checks.push({ label: 'Nom couvert par le certificat', status: matches ? 'pass' : 'fail', value: info.altNames.slice(0, 6).join(', ') || info.subject || 'aucun nom' });
246
+ if (!matches)
247
+ findings.push({ severity: 'high', title: 'Le certificat ne couvre pas ce nom', detail: `${host} n’apparaît pas dans les noms du certificat.`, evidence: info.altNames.join(', '), recommendation: 'Émettre un certificat qui inclut ce nom.' });
248
+ if (info.selfSigned) {
249
+ checks.push({ label: 'Chaîne de confiance', status: 'fail', value: 'certificat auto-signé' });
250
+ findings.push({ severity: 'high', title: 'Certificat auto-signé', detail: 'Aucune autorité reconnue ne garantit ce certificat : un visiteur ne peut pas distinguer ton serveur d’un imposteur.', recommendation: 'Utiliser un certificat d’une autorité reconnue (Let’s Encrypt est gratuit).' });
251
+ }
252
+ else if (!info.authorized && info.authorizationError && !/HOSTNAME_MISMATCH|ERR_TLS_CERT_ALTNAME|CERT_HAS_EXPIRED/i.test(info.authorizationError)) {
253
+ checks.push({ label: 'Chaîne de confiance', status: 'fail', value: info.authorizationError });
254
+ findings.push({ severity: 'high', title: 'Chaîne de certificats non reconnue', detail: `La vérification échoue : ${info.authorizationError}.`, recommendation: 'Servir la chaîne complète, intermédiaires compris.' });
255
+ }
256
+ else
257
+ checks.push({ label: 'Chaîne de confiance', status: 'pass', value: `émis par ${info.issuer || 'inconnu'}` });
258
+ if (info.keyBits !== null) {
259
+ const weak = info.keyBits < 2048 && info.keyBits >= 1024;
260
+ const tiny = info.keyBits < 1024 && info.keyBits > 521;
261
+ checks.push({ label: 'Taille de clé', status: weak || tiny ? 'fail' : 'pass', value: `${info.keyBits} bits` });
262
+ if (weak || tiny)
263
+ findings.push({ severity: 'high', title: 'Clé du certificat trop courte', detail: `${info.keyBits} bits : en dessous de 2048 bits pour RSA, la clé est considérée comme cassable.`, recommendation: 'Générer une clé RSA de 2048 bits au moins, ou une clé ECDSA P-256.' });
264
+ }
265
+ if (info.cipher) {
266
+ const weakCipher = /RC4|3DES|DES-CBC|NULL|EXPORT|MD5/i.test(info.cipher);
267
+ checks.push({ label: 'Suite négociée', status: weakCipher ? 'fail' : 'pass', value: `${info.protocol ?? '?'} · ${info.cipher}` });
268
+ if (weakCipher)
269
+ findings.push({ severity: 'medium', title: 'Suite de chiffrement faible', detail: `Le serveur a choisi ${info.cipher}.`, recommendation: 'Désactiver RC4, 3DES et les suites sans authentification.' });
270
+ }
271
+ return { summary: '', findings, checks };
272
+ }
273
+ const VERSIONS = [
274
+ { version: 'TLSv1', label: 'TLS 1.0', deprecated: true },
275
+ { version: 'TLSv1.1', label: 'TLS 1.1', deprecated: true },
276
+ { version: 'TLSv1.2', label: 'TLS 1.2', deprecated: false },
277
+ { version: 'TLSv1.3', label: 'TLS 1.3', deprecated: false },
278
+ ];
279
+ export const tlsModule = {
280
+ info: {
281
+ id: 'tls',
282
+ letter: 'δ',
283
+ title: 'TLS / SSL',
284
+ objective: 'Vérifier le certificat et les versions de TLS que le serveur accepte.',
285
+ perimeter: 'Cinq poignées de main TLS sur le port choisi (443 par défaut) : une normale, puis une par version de TLS 1.0 à 1.3. Aucune donnée applicative n’est envoyée.',
286
+ results: 'Validité, nom, chaîne et clé du certificat, puis la liste des versions acceptées.',
287
+ input: 'target',
288
+ fields: [{ ...targetField, help: 'Nom d’hôte ou IP de ton périmètre, avec le port s’il n’est pas 443 (exemple.fr:8443).' }],
289
+ },
290
+ resolve: (input) => {
291
+ const { host, port } = parseHost(input.target);
292
+ return { label: port && port !== 443 ? `${host}:${port}` : host, host };
293
+ },
294
+ async run(input, ctx) {
295
+ const { host, port: givenPort } = parseHost(input.target);
296
+ const port = givenPort ?? 443;
297
+ const total = 1 + VERSIONS.length;
298
+ ctx.progress('Certificat', 0, total);
299
+ const main = await tlsProbe(host, port);
300
+ if (!main.ok) {
301
+ return {
302
+ summary: `Pas de TLS sur ${host}:${port}.`,
303
+ findings: [{ severity: 'info', title: 'Poignée de main TLS impossible', detail: main.reason === 'unreachable' ? `Le port ${port} ne répond pas (${main.message}).` : `Le serveur a refusé la connexion TLS (${main.message}).` }],
304
+ checks: [{ label: `TLS sur le port ${port}`, status: 'fail', value: main.message }],
305
+ };
306
+ }
307
+ const out = analyzeCertificate(main.info, host, ctx.now);
308
+ let done = 1;
309
+ const accepted = [];
310
+ for (const { version, label, deprecated } of VERSIONS) {
311
+ ctx.progress(label, done++, total);
312
+ const attempt = await tlsProbe(host, port, version);
313
+ if (attempt.ok) {
314
+ accepted.push(label);
315
+ out.checks.push({ label, status: deprecated ? 'fail' : 'pass', value: 'acceptée' });
316
+ if (deprecated)
317
+ out.findings.push({ severity: 'medium', title: `${label} encore acceptée`, detail: `${label} est obsolète depuis 2021 (RFC 8996) et vulnérable à des attaques connues.`, recommendation: 'Désactiver TLS 1.0 et 1.1, garder 1.2 et 1.3.' });
318
+ }
319
+ else if (attempt.reason === 'unsupported')
320
+ out.checks.push({ label, status: 'skip', value: 'non testable depuis cette machine' });
321
+ else
322
+ out.checks.push({ label, status: deprecated ? 'pass' : 'warn', value: 'refusée' });
323
+ }
324
+ if (!accepted.includes('TLS 1.3') && out.checks.some((c) => c.label === 'TLS 1.3' && c.value === 'refusée'))
325
+ out.findings.push({ severity: 'low', title: 'TLS 1.3 non proposé', detail: 'TLS 1.3 est plus rapide et retire les suites fragiles.', recommendation: 'Activer TLS 1.3 sur le serveur.' });
326
+ ctx.progress('Terminé', total, total);
327
+ const real = out.findings.filter((f) => f.severity !== 'info').length;
328
+ out.summary = `${accepted.join(', ') || 'Aucune version'} acceptée${accepted.length > 1 ? 's' : ''}, ${resteACorriger(real)}.`;
329
+ return out;
330
+ },
331
+ };