@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,229 @@
1
+ import { hostFromUrl, isSingleHost } from './paths.js';
2
+ const block = (code, message) => ({ code, message, severity: 'block' });
3
+ const warn = (code, message) => ({ code, message, severity: 'warn' });
4
+ const P = ['Path', 'LiteralPath'];
5
+ const cmdlets = new Map();
6
+ const add = (names, rule) => {
7
+ for (const name of names.trim().split(/\s+/))
8
+ cmdlets.set(name.toLowerCase(), rule);
9
+ };
10
+ // ------------------------------------------------------------------ Lecture
11
+ add('Get-ChildItem Get-Content Get-Item Get-ItemProperty Get-ItemPropertyValue Get-FileHash Get-Acl Test-Path Resolve-Path Import-Csv', { level: 'read', category: 'fichiers', read: P });
12
+ add('Get-AuthenticodeSignature', { level: 'read', category: 'fichiers', read: ['FilePath', 'LiteralPath'] });
13
+ add('Select-String', { level: 'read', category: 'fichiers', read: P });
14
+ add('Split-Path Join-Path Get-Location Get-PSDrive Get-PSProvider', { level: 'read', category: 'fichiers' });
15
+ add('Get-Process Get-Service Get-ComputerInfo Get-CimInstance Get-WmiObject Get-WinEvent Get-EventLog Get-HotFix Get-Volume Get-Disk Get-Partition Get-ScheduledTask Get-ScheduledTaskInfo Get-LocalUser Get-LocalGroup Get-LocalGroupMember Get-AppxPackage Get-Package Get-PnpDevice Get-Tpm Get-BitLockerVolume Get-WindowsOptionalFeature Get-TimeZone Get-Culture Get-Host Get-ExecutionPolicy Get-Counter Get-History Get-Variable', { level: 'read', category: 'système' });
16
+ add('Get-NetTCPConnection Get-NetUDPEndpoint Get-NetIPAddress Get-NetIPConfiguration Get-NetAdapter Get-NetRoute Get-NetNeighbor Get-NetConnectionProfile Get-NetFirewallProfile Get-NetFirewallRule Get-NetFirewallPortFilter Get-NetFirewallAddressFilter Get-NetIPInterface Get-NetAdapterBinding Get-DnsClient Get-DnsClientServerAddress Get-DnsClientCache Get-SmbShare Get-SmbServerConfiguration Get-SmbConnection', { level: 'read', category: 'réseau' });
17
+ add('Get-MpComputerStatus Get-MpPreference Get-MpThreat Get-MpThreatDetection', { level: 'read', category: 'sécurité' });
18
+ add('Resolve-DnsName', { level: 'read', category: 'réseau', host: ['Name'] });
19
+ add('Test-Connection', { level: 'read', category: 'réseau', host: ['ComputerName'] });
20
+ add('Test-NetConnection', { level: 'read', category: 'réseau', host: ['ComputerName'], scan: ['Port', 'CommonTCPPort'] });
21
+ add('Where-Object ForEach-Object Sort-Object Group-Object Measure-Object Measure-Command', { level: 'read', category: 'pipeline', blocks: true });
22
+ add('Select-Object Format-Table Format-List Format-Wide Format-Custom Out-String Out-Null Out-Host Out-Default ConvertTo-Json ConvertFrom-Json ConvertTo-Csv ConvertFrom-Csv ConvertTo-Html ConvertFrom-StringData Compare-Object Get-Unique Get-Member Get-Random Get-Date Get-Command Get-Help Get-Module Get-Alias New-TimeSpan New-Guid Write-Output Write-Host Write-Warning Write-Error Write-Verbose Write-Information Start-Sleep Clear-Host help', { level: 'read', category: 'pipeline' });
23
+ // ------------------------------------------------------------------ Sensible
24
+ add('New-Item mkdir', { level: 'sensitive', category: 'fichiers', write: ['Path', 'Target', 'Value'], require: ['Path'], note: 'crée un fichier ou un dossier' });
25
+ add('Set-Content Add-Content', { level: 'sensitive', category: 'fichiers', write: P, require: P, note: 'écrit dans un fichier' });
26
+ add('Out-File Tee-Object', { level: 'sensitive', category: 'fichiers', write: ['FilePath', 'LiteralPath'], require: ['FilePath', 'LiteralPath'], note: 'écrit dans un fichier' });
27
+ add('Export-Csv', { level: 'sensitive', category: 'fichiers', write: P, require: P, note: 'écrit un fichier CSV' });
28
+ add('Copy-Item', { level: 'sensitive', category: 'fichiers', read: P, write: ['Destination'], require: ['Destination'], note: 'copie des fichiers' });
29
+ add('Move-Item', { level: 'sensitive', category: 'fichiers', write: [...P, 'Destination'], require: P, note: 'déplace des fichiers' });
30
+ add('Rename-Item', { level: 'sensitive', category: 'fichiers', write: P, require: P, note: 'renomme un élément' });
31
+ add('Compress-Archive Expand-Archive', { level: 'sensitive', category: 'fichiers', read: P, write: ['DestinationPath'], require: ['DestinationPath'], note: 'crée ou extrait une archive' });
32
+ add('Invoke-WebRequest Invoke-RestMethod', { level: 'sensitive', category: 'réseau', host: ['Uri'], write: ['OutFile'], note: 'envoie une requête web' });
33
+ add('Stop-Process', { level: 'sensitive', category: 'processus', note: 'arrête un processus' });
34
+ add('Start-Service Stop-Service Restart-Service', { level: 'sensitive', category: 'système', note: 'agit sur un service' });
35
+ add('Clear-DnsClientCache', { level: 'sensitive', category: 'réseau', note: 'vide le cache DNS' });
36
+ // ------------------------------------------------------------------ Destructif : refusé par défaut, débloquable
37
+ add('Remove-Item Clear-Content', { level: 'destructive', category: 'fichiers', write: P, require: P, note: 'supprime des fichiers ou leur contenu' });
38
+ add('Set-ItemProperty New-ItemProperty Remove-ItemProperty', { level: 'destructive', category: 'système', note: 'modifie le registre ou des propriétés' });
39
+ add('Set-Acl', { level: 'destructive', category: 'fichiers', write: P, require: P, note: 'modifie des permissions' });
40
+ add('Clear-RecycleBin', { level: 'destructive', category: 'fichiers', note: 'vide la corbeille' });
41
+ add('Stop-Computer Restart-Computer', { level: 'destructive', category: 'système', note: 'éteint ou redémarre la machine' });
42
+ add('Set-Service', { level: 'destructive', category: 'système', note: 'reconfigure un service' });
43
+ add('New-NetFirewallRule Remove-NetFirewallRule Set-NetFirewallRule Enable-NetFirewallRule Disable-NetFirewallRule Set-NetFirewallProfile', { level: 'destructive', category: 'réseau', note: 'modifie le pare-feu' });
44
+ add('New-LocalUser Remove-LocalUser Set-LocalUser Add-LocalGroupMember Remove-LocalGroupMember', { level: 'destructive', category: 'système', note: 'modifie les comptes locaux' });
45
+ add('Install-Package Uninstall-Package', { level: 'destructive', category: 'système', note: 'installe ou désinstalle un logiciel' });
46
+ add('Disable-ScheduledTask Enable-ScheduledTask Unregister-ScheduledTask', { level: 'destructive', category: 'système', note: 'modifie une tâche planifiée' });
47
+ add('Start-Process', { level: 'destructive', category: 'processus', note: 'lance un programme quelconque' });
48
+ // ------------------------------------------------------------------ Interdit
49
+ add('Invoke-Expression Invoke-Command Start-Job Add-Type New-Object Import-Module Install-Module Save-Module Invoke-Item Invoke-CimMethod Invoke-WmiMethod Set-CimInstance Remove-CimInstance Register-ObjectEvent Register-WmiEvent Register-CimIndicationEvent Register-ScheduledTask New-PSSession Enter-PSSession New-PSDrive', { level: 'forbidden', category: 'code dynamique', note: 'exécute du code ou une commande que l’analyse ne peut pas voir' });
50
+ add('Set-Alias New-Alias Set-Variable New-Variable Set-Item', { level: 'forbidden', category: 'code dynamique', note: 'peut remplacer une commande autorisée par une autre' });
51
+ add('Set-Location Push-Location Pop-Location', { level: 'forbidden', category: 'dossiers', note: 'change de dossier en cours de commande : utilise « cd » seul dans le terminal' });
52
+ add('Set-ExecutionPolicy Set-MpPreference Add-MpPreference Remove-MpPreference Unblock-File', { level: 'forbidden', category: 'protections', note: 'affaiblit une protection de Windows' });
53
+ add('Format-Volume Clear-Disk Initialize-Disk Remove-Partition', { level: 'forbidden', category: 'disques', note: 'efface un disque ou une partition' });
54
+ // ------------------------------------------------------------------ Applications natives
55
+ const apps = new Map();
56
+ const addApp = (names, rule) => {
57
+ for (const name of names.trim().split(/\s+/))
58
+ apps.set(name.toLowerCase(), rule);
59
+ };
60
+ const only = (label, allowed) => (argv) => {
61
+ const bad = argv.find((a) => !allowed.test(a));
62
+ return { reasons: bad === undefined ? [] : [block('app-args', `${label} : l’option « ${bad} » n’est pas autorisée (elle modifie la configuration ou n’est pas reconnue).`)] };
63
+ };
64
+ function hostArgs(label, flags, valued, refused) {
65
+ return (argv) => {
66
+ const reasons = [];
67
+ const hosts = [];
68
+ for (let i = 0; i < argv.length; i++) {
69
+ const a = argv[i] ?? '';
70
+ if (refused.test(a))
71
+ reasons.push(block('app-args', `${label} : l’option « ${a} » est refusée (envoi continu ou paquets surdimensionnés).`));
72
+ else if (valued.test(a)) {
73
+ const value = argv[++i] ?? '';
74
+ if (!/^\d{1,5}$/.test(value))
75
+ reasons.push(block('app-args', `${label} : valeur invalide pour « ${a} ».`));
76
+ else if (/^-n$/i.test(a) && Number(value) > 20)
77
+ reasons.push(block('app-args', `${label} : 20 envois au maximum.`));
78
+ }
79
+ else if (flags.test(a))
80
+ continue;
81
+ else if (isSingleHost(a))
82
+ hosts.push(a.toLowerCase());
83
+ else
84
+ reasons.push(block('app-args', `${label} : argument « ${a} » non reconnu.`));
85
+ }
86
+ return { reasons, hosts };
87
+ };
88
+ }
89
+ const NMAP_FLAGS = /^(?:-Pn|-sV|-sT|-sS|-sU|-sn|-sC|-O|-A|-F|-n|-6|-v|-vv|--open|--reason|--version-light|--version-all|-T[0-4])$/;
90
+ const NMAP_VALUED = new Set(['-p', '--top-ports', '--script', '--max-retries', '--host-timeout', '--version-intensity']);
91
+ /**
92
+ * `--script` : une liste blanche, élément par élément. Une catégorie de nmap s'étend à tous ses scripts, et la plupart mêlent des
93
+ * scripts de lecture à d'autres. Relevé dans script.db (nmap 7.80) et la documentation de nmap :
94
+ * - `default` (celle de -sC et -A) et `version` n'ont aucun script intrusive, broadcast, brute, dos, exploit ni fuzzer. Deux
95
+ * scripts de `default` sont external (http-open-proxy, socks-open-proxy : une requête vers un tiers, à travers le mandataire
96
+ * de la cible) ;
97
+ * - `safe` est refusée : 40 scripts de diffusion sur le réseau local (hors de la cible), 27 qui écrivent à un service tiers, et
98
+ * deux intrusive. `discovery` est refusée : 45 intrusive.
99
+ * Les scripts nommés sont dans `default`, `safe` ou `version`, sans intrusive, sauf ssl-enum-ciphers (discovery, intrusive) :
100
+ * nmap le dit intrusif parce qu'il ouvre beaucoup de connexions, « quite noisy ». Il n'essaie ni mot de passe ni faille, et la
101
+ * présentation le montre dans l'audit TLS.
102
+ * Un élément est l'un de ces noms, tel quel : ni joker, ni `all`, ni expression (and, or, not), ni `+`, ni chemin, ni fichier .nse.
103
+ * `--script-args` et les autres options `--script-*` ne sont pas dans les options admises : elles sont refusées.
104
+ */
105
+ export const NMAP_CATEGORIES_ADMISES = ['default', 'version'];
106
+ export const NMAP_SCRIPTS_ADMIS = ['banner', 'http-headers', 'http-methods', 'http-server-header', 'http-title', 'ssh-hostkey', 'ssh2-enum-algos', 'ssl-cert', 'ssl-enum-ciphers'];
107
+ const NMAP_SCRIPTS = new Set([...NMAP_CATEGORIES_ADMISES, ...NMAP_SCRIPTS_ADMIS]);
108
+ const scriptsAdmis = (value) => value.split(',').every((element) => NMAP_SCRIPTS.has(element));
109
+ const REFUS_DU_SCRIPT = `--script n’accepte que les catégories ${NMAP_CATEGORIES_ADMISES.join(' et ')}, et les scripts de lecture ${NMAP_SCRIPTS_ADMIS.join(', ')}, séparés par des virgules, sans joker, expression ni fichier.`;
110
+ /** nmap : options de découverte seulement, une cible à la fois, jamais d'évasion ni de script offensif. */
111
+ export function nmapArgs(argv) {
112
+ const reasons = [];
113
+ const scanHosts = [];
114
+ if (argv.length > 0 && argv.every((a) => /^(?:-V|--version|-h|--help)$/.test(a)))
115
+ return { level: 'read', reasons };
116
+ for (let i = 0; i < argv.length; i++) {
117
+ const raw = argv[i] ?? '';
118
+ const eq = raw.startsWith('--') ? raw.indexOf('=') : -1;
119
+ const flag = eq > 0 ? raw.slice(0, eq) : raw;
120
+ if (/^-p[\dTU:,-]+$/.test(raw) || NMAP_FLAGS.test(raw))
121
+ continue;
122
+ if (NMAP_VALUED.has(flag)) {
123
+ const value = eq > 0 ? raw.slice(eq + 1) : (argv[++i] ?? '');
124
+ if (flag === '-p' && !/^[\dTU:,-]+$/.test(value))
125
+ reasons.push(block('app-args', 'nmap : liste de ports invalide.'));
126
+ else if (flag === '--top-ports' && !(Number(value) >= 1 && Number(value) <= 1000))
127
+ reasons.push(block('app-args', 'nmap : --top-ports accepte 1 à 1000.'));
128
+ else if (flag === '--script' && !scriptsAdmis(value))
129
+ reasons.push(block('app-args', `nmap : le script « ${value} » est refusé. ${REFUS_DU_SCRIPT}`));
130
+ else if (flag !== '-p' && flag !== '--top-ports' && flag !== '--script' && !/^[\w.]{1,12}$/.test(value))
131
+ reasons.push(block('app-args', `nmap : valeur invalide pour ${flag}.`));
132
+ continue;
133
+ }
134
+ if (raw.startsWith('-')) {
135
+ reasons.push(block('app-args', `nmap : l’option « ${raw} » n’est pas autorisée (évasion, usurpation, cibles aléatoires, sorties fichier et scripts offensifs sont exclus).`));
136
+ continue;
137
+ }
138
+ if (isSingleHost(raw))
139
+ scanHosts.push(raw.toLowerCase());
140
+ else
141
+ reasons.push(block('app-args', `nmap : « ${raw} » n’est pas une cible unique (plages, masques et jokers sont refusés).`));
142
+ }
143
+ if (scanHosts.length === 0 && reasons.length === 0)
144
+ reasons.push(block('app-args', 'nmap : aucune cible indiquée.'));
145
+ if (scanHosts.length > 1)
146
+ reasons.push(block('app-args', 'nmap : une seule cible par commande.'));
147
+ return { level: 'sensitive', reasons, scanHosts };
148
+ }
149
+ const CURL_FLAGS = /^(?:-[sSIiLvkf46]+|--head|--silent|--show-error|--location|--include|--compressed|--fail|--insecure|--ipv4|--ipv6|--verbose)$/;
150
+ const CURL_VALUED = new Set(['-m', '--max-time', '--connect-timeout', '-A', '--user-agent', '-H', '--header', '-X', '--request', '-o', '--output', '-w', '--write-out']);
151
+ /** curl.exe : requêtes de lecture (GET, HEAD, OPTIONS) vers http(s). Pas d'envoi de données ni de fichier. */
152
+ export function curlArgs(argv) {
153
+ const reasons = [];
154
+ const hosts = [];
155
+ const writes = [];
156
+ for (let i = 0; i < argv.length; i++) {
157
+ const a = argv[i] ?? '';
158
+ if (CURL_FLAGS.test(a))
159
+ continue;
160
+ if (CURL_VALUED.has(a)) {
161
+ const value = argv[++i] ?? '';
162
+ if ((a === '-X' || a === '--request') && !/^(?:GET|HEAD|OPTIONS)$/i.test(value))
163
+ reasons.push(block('app-args', `curl : la méthode ${value || '(vide)'} n’est pas autorisée (GET, HEAD ou OPTIONS).`));
164
+ if (a === '-o' || a === '--output')
165
+ writes.push(value);
166
+ continue;
167
+ }
168
+ if (a.startsWith('-')) {
169
+ reasons.push(block('app-args', `curl : l’option « ${a} » n’est pas autorisée (envoi de données, proxy et fichiers de configuration sont exclus).`));
170
+ continue;
171
+ }
172
+ const host = hostFromUrl(a);
173
+ if (host)
174
+ hosts.push(host);
175
+ else
176
+ reasons.push(block('app-args', `curl : « ${a} » n’est pas une URL http(s).`));
177
+ }
178
+ if (hosts.length === 0 && reasons.length === 0 && !argv.some((a) => /^(?:-V|--version)$/.test(a)))
179
+ reasons.push(block('app-args', 'curl : aucune URL indiquée.'));
180
+ return { level: 'sensitive', reasons, hosts, writes };
181
+ }
182
+ function ollamaArgs(argv) {
183
+ const sub = (argv[0] ?? '').toLowerCase();
184
+ if (['list', 'ls', 'ps', 'show', '--version', '-v', 'help', ''].includes(sub))
185
+ return { level: 'read', reasons: [] };
186
+ if (['run', 'pull', 'stop', 'create', 'cp'].includes(sub))
187
+ return { level: 'sensitive', reasons: [warn('ollama', `ollama ${sub} modifie les modèles installés ou en charge un en mémoire.`)] };
188
+ return { reasons: [block('app-args', `ollama ${sub} n’est pas autorisé depuis Prométhée.`)] };
189
+ }
190
+ const anyArgs = () => ({ reasons: [] });
191
+ addApp('whoami hostname systeminfo getmac tasklist driverquery qwinsta nslookup findstr', { level: 'read', category: 'système', system: true, args: anyArgs });
192
+ addApp('ipconfig', { level: 'read', category: 'réseau', system: true, args: only('ipconfig', /^\/(?:all|displaydns|allcompartments|\?)$/i) });
193
+ addApp('arp', { level: 'read', category: 'réseau', system: true, args: only('arp', /^(?:-a|-g|-v|\d{1,3}(?:\.\d{1,3}){3})$/i) });
194
+ addApp('route', {
195
+ level: 'read',
196
+ category: 'réseau',
197
+ system: true,
198
+ args: (argv) => ({ reasons: /^print$/i.test(argv[0] ?? '') ? [] : [block('app-args', 'route : seul « route print » est autorisé.')] }),
199
+ });
200
+ addApp('netstat', { level: 'read', category: 'réseau', system: true, args: only('netstat', /^-[a-zA-Z]{1,8}$/) });
201
+ addApp('nbtstat', { level: 'read', category: 'réseau', system: true, args: only('nbtstat', /^(?:-[aAcnrSs]|[\w.-]{1,64})$/) });
202
+ addApp('ping', { level: 'read', category: 'réseau', system: true, args: hostArgs('ping', /^-(?:4|6|a)$/i, /^-(?:n|w|i)$/i, /^-(?:t|l|f)$/i) });
203
+ addApp('tracert pathping', { level: 'read', category: 'réseau', system: true, args: hostArgs('tracert', /^-(?:4|6|d)$/i, /^-(?:h|w|q|p)$/i, /^$/) });
204
+ addApp('curl', { level: 'sensitive', category: 'réseau', system: true, note: 'envoie une requête web', args: curlArgs });
205
+ addApp('nmap', { level: 'sensitive', category: 'réseau', system: false, note: 'teste des ports : cible dans le périmètre autorisé', args: nmapArgs });
206
+ addApp('ollama', { level: 'read', category: 'IA locale', system: false, args: ollamaArgs });
207
+ /** winget : chercher et lister sont des lectures ; installer, mettre à jour ou désinstaller doivent être débloqués. */
208
+ function wingetArgs(argv) {
209
+ const sub = (argv[0] ?? '').toLowerCase();
210
+ if (['search', 'list', 'show', 'source', '--version', '-v', '--info', ''].includes(sub) && !argv.some((a) => /^(?:add|remove|reset|update)$/i.test(a)))
211
+ return { level: 'read', reasons: [] };
212
+ return { level: 'sensitive', reasons: [], needsUnlock: `winget ${sub} installe, met à jour ou retire un logiciel` };
213
+ }
214
+ addApp('winget', { level: 'read', category: 'système', system: false, note: 'recherche en lecture, installation à débloquer', args: wingetArgs, unlockable: true });
215
+ addApp('taskkill shutdown net net1 netsh sc reg schtasks icacls takeown attrib choco', {
216
+ level: 'destructive',
217
+ category: 'système',
218
+ system: false,
219
+ note: 'modifie le système',
220
+ });
221
+ addApp('powershell pwsh cmd wscript cscript mshta rundll32 regsvr32 certutil bitsadmin diskpart bcdedit vssadmin wbadmin cipher format wmic msiexec runas forfiles wsl bash sh ssh ftp tftp', { level: 'forbidden', category: 'interpréteurs', system: false, note: 'interpréteur ou outil système qui échappe à l’analyse' });
222
+ export const CMDLETS = cmdlets;
223
+ export const APPS = apps;
224
+ /** Nom de base d'une application : « C:\Windows\System32\whoami.exe » donne « whoami ». */
225
+ export function appKey(nameOrPath) {
226
+ const base = nameOrPath.split(/[\\/]/).pop() ?? nameOrPath;
227
+ const dot = base.lastIndexOf('.');
228
+ return dot > 0 ? { key: base.slice(0, dot).toLowerCase(), ext: base.slice(dot).toLowerCase() } : { key: base.toLowerCase(), ext: '' };
229
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Décodage de la sortie de PowerShell.
3
+ *
4
+ * PowerShell 5.1 et les outils Windows (ipconfig, whoami…) écrivent dans la page de codes OEM de
5
+ * la console (850 sur un Windows français), alors que curl, nmap ou git écrivent en UTF-8.
6
+ * Chaque ligne est donc décodée en UTF-8 si elle est valide, sinon avec la table OEM lue sur la machine.
7
+ */
8
+ const utf8 = new TextDecoder('utf-8', { fatal: true });
9
+ export function decodeLine(bytes, oemTable) {
10
+ let ascii = true;
11
+ for (const b of bytes) {
12
+ if (b >= 0x80) {
13
+ ascii = false;
14
+ break;
15
+ }
16
+ }
17
+ if (ascii)
18
+ return Buffer.from(bytes).toString('latin1');
19
+ try {
20
+ return utf8.decode(bytes);
21
+ }
22
+ catch {
23
+ if (!oemTable || oemTable.length !== 128)
24
+ return Buffer.from(bytes).toString('latin1');
25
+ let out = '';
26
+ for (const b of bytes)
27
+ out += b < 0x80 ? String.fromCharCode(b) : oemTable.charAt(b - 0x80);
28
+ return out;
29
+ }
30
+ }
31
+ export function createLineDecoder(oemTable) {
32
+ let pending = Buffer.alloc(0);
33
+ return {
34
+ push(chunk) {
35
+ pending = pending.length === 0 ? Buffer.from(chunk) : Buffer.concat([pending, chunk]);
36
+ let out = '';
37
+ let start = 0;
38
+ let nl = pending.indexOf(0x0a, start);
39
+ while (nl >= 0) {
40
+ const end = nl > start && pending[nl - 1] === 0x0d ? nl - 1 : nl;
41
+ out += `${decodeLine(pending.subarray(start, end), oemTable)}\n`;
42
+ start = nl + 1;
43
+ nl = pending.indexOf(0x0a, start);
44
+ }
45
+ pending = pending.subarray(start);
46
+ return out;
47
+ },
48
+ flush() {
49
+ if (pending.length === 0)
50
+ return '';
51
+ const text = decodeLine(pending, oemTable);
52
+ pending = Buffer.alloc(0);
53
+ return text;
54
+ },
55
+ };
56
+ }
57
+ const ERR_LOCATION = /^(?:Au caractère|At) (?:Ligne|line):\d+\s*(?::|char:)\s*\d+\s*$/;
58
+ const ERR_DETAIL = /^\s+\+ (?:CategoryInfo|FullyQualifiedErrorId)\s*:/;
59
+ /**
60
+ * Allège les erreurs de PowerShell : retire l'emplacement dans le script, le soulignement et
61
+ * les lignes CategoryInfo, pour ne garder que le message.
62
+ */
63
+ export function createErrorTidier() {
64
+ let inLocation = false;
65
+ let inDetail = false;
66
+ return (text) => {
67
+ const kept = [];
68
+ const lines = text.split('\n');
69
+ const last = lines.pop() ?? '';
70
+ for (const raw of lines) {
71
+ const line = raw.replace(/\r$/, '');
72
+ if (ERR_LOCATION.test(line)) {
73
+ inLocation = true;
74
+ continue;
75
+ }
76
+ if (inLocation && line.startsWith('+ '))
77
+ continue;
78
+ inLocation = false;
79
+ if (ERR_DETAIL.test(line)) {
80
+ inDetail = true;
81
+ continue;
82
+ }
83
+ if (inDetail && line.startsWith(' ') && line.trim() !== '')
84
+ continue;
85
+ inDetail = false;
86
+ kept.push(line.trimEnd());
87
+ }
88
+ const body = kept.join('\n');
89
+ return (kept.length > 0 ? `${body}\n` : '') + last;
90
+ };
91
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Moteur d'exécution : le seul chemin par lequel une commande peut s'exécuter.
3
+ *
4
+ * 1. preview() analyse la commande, applique la politique, délivre un ticket à usage unique ;
5
+ * 2. start() exige ce ticket et la confirmation humaine, puis lance PowerShell ;
6
+ * 3. chaque refus et chaque exécution sont écrits dans le journal.
7
+ *
8
+ * L'IA ne peut qu'appeler preview() : elle propose, l'humain confirme.
9
+ */
10
+ import { randomBytes } from 'node:crypto';
11
+ import { AppError, badRequest } from '../errors.js';
12
+ import { resolveDirectory } from './paths.js';
13
+ import { evaluate, modulesToPreload } from './policy.js';
14
+ const TICKET_TTL_MS = 5 * 60_000;
15
+ const MAX_RUNNING = 4;
16
+ const MAX_OUTPUT_BYTES = 1024 * 1024;
17
+ export class Engine {
18
+ tickets = new Map();
19
+ active = new Map();
20
+ deps;
21
+ constructor(deps) {
22
+ this.deps = deps;
23
+ }
24
+ get available() {
25
+ return this.deps.probe.available;
26
+ }
27
+ get running() {
28
+ return this.active.size;
29
+ }
30
+ defaultCwd() {
31
+ return this.deps.settings().engine.roots[0] ?? this.deps.home;
32
+ }
33
+ /** Valide un changement de dossier demandé par le terminal (« cd »), sans passer par PowerShell. */
34
+ resolveCwd(from, to) {
35
+ const result = resolveDirectory(from, to, this.deps.settings().engine.roots, this.deps.home);
36
+ if (!result.ok)
37
+ throw badRequest(result.message, result.code);
38
+ return result.cwd;
39
+ }
40
+ checkCwd(cwd) {
41
+ return this.resolveCwd(cwd, '.');
42
+ }
43
+ policyContext(cwd) {
44
+ const settings = this.deps.settings();
45
+ return {
46
+ cwd,
47
+ roots: settings.engine.roots,
48
+ home: this.deps.home,
49
+ custom: settings.allowlist.custom,
50
+ unlocked: settings.allowlist.unlocked,
51
+ targets: this.deps.targets(),
52
+ systemRoot: this.deps.systemRoot,
53
+ };
54
+ }
55
+ async preview(command, cwd, origin) {
56
+ if (!this.available)
57
+ throw new AppError('engine_down', 503, this.deps.probe.error ?? 'Le moteur PowerShell n’est pas disponible.');
58
+ const text = command.trim();
59
+ if (/`\s*$/.test(text))
60
+ throw badRequest('La commande ne peut pas se terminer par un accent grave (continuation de ligne).');
61
+ const dir = this.checkCwd(cwd ?? this.defaultCwd());
62
+ const analysis = await this.deps.analyzer.analyze(text);
63
+ const verdict = evaluate(analysis, this.policyContext(dir));
64
+ if (verdict.level === 'denied') {
65
+ this.deps.journal.add({
66
+ kind: 'refusal',
67
+ outcome: 'refused',
68
+ title: text.length > 120 ? `${text.slice(0, 120)}…` : text,
69
+ detail: verdict.reasons.filter((r) => r.severity === 'block').map((r) => r.message).join('\n'),
70
+ data: { command: text, cwd: dir, origin, codes: verdict.reasons.filter((r) => r.severity === 'block').map((r) => r.code) },
71
+ });
72
+ return { ticket: null, command: text, cwd: dir, verdict, expiresAt: null };
73
+ }
74
+ const now = Date.now();
75
+ for (const [id, t] of this.tickets)
76
+ if (t.expiresAt < now)
77
+ this.tickets.delete(id);
78
+ if (this.tickets.size > 200)
79
+ this.tickets.clear();
80
+ const ticket = randomBytes(16).toString('hex');
81
+ const expiresAt = now + TICKET_TTL_MS;
82
+ this.tickets.set(ticket, { command: text, cwd: dir, analysis, verdict, modules: modulesToPreload(analysis), origin, expiresAt });
83
+ return { ticket, command: text, cwd: dir, verdict, expiresAt };
84
+ }
85
+ /** Lance la commande d'un ticket. Toutes les vérifications ont lieu avant le premier événement. */
86
+ start(ticketId, options) {
87
+ const ticket = this.tickets.get(ticketId);
88
+ if (!ticket || ticket.expiresAt < Date.now()) {
89
+ this.tickets.delete(ticketId);
90
+ throw new AppError('ticket_expired', 410, 'Cette prévisualisation a expiré ou a déjà servi. Relance la commande.');
91
+ }
92
+ const settings = this.deps.settings();
93
+ // Les réglages ou le périmètre ont pu changer depuis la prévisualisation : la politique tranche à nouveau.
94
+ const current = evaluate(ticket.analysis, this.policyContext(ticket.cwd));
95
+ if (current.level === 'denied' || (current.level === 'sensitive' && ticket.verdict.level === 'read')) {
96
+ this.tickets.delete(ticketId);
97
+ throw new AppError('policy_changed', 409, 'Les réglages ont changé depuis la prévisualisation : relance la commande pour la faire réévaluer.');
98
+ }
99
+ if (ticket.verdict.level === 'sensitive' && options.acknowledge !== true)
100
+ throw new AppError('ack_required', 428, 'Cette commande est sensible : coche la case de confirmation avant de l’exécuter.');
101
+ if (options.confirmation === 'auto' && (ticket.verdict.level !== 'read' || ticket.origin !== 'terminal' || settings.engine.confirmRead))
102
+ throw new AppError('confirmation_required', 428, 'Cette commande demande une confirmation humaine.');
103
+ if (this.active.size >= MAX_RUNNING)
104
+ throw new AppError('busy', 429, `${MAX_RUNNING} commandes sont déjà en cours. Attends la fin de l’une d’elles ou annule-la.`);
105
+ this.tickets.delete(ticketId);
106
+ const execId = randomBytes(8).toString('hex');
107
+ const handle = this.deps.runner.run({
108
+ command: ticket.command,
109
+ cwd: ticket.cwd,
110
+ modules: ticket.modules,
111
+ constrained: settings.engine.constrainedLanguage,
112
+ timeoutMs: settings.engine.timeoutSec * 1000,
113
+ maxBytes: MAX_OUTPUT_BYTES,
114
+ });
115
+ this.active.set(execId, handle);
116
+ const { journal } = this.deps;
117
+ const active = this.active;
118
+ async function* stream() {
119
+ let tail = '';
120
+ let ended = false;
121
+ try {
122
+ yield { type: 'start', execId, startedAt: new Date().toISOString() };
123
+ for await (const event of handle.events) {
124
+ if (event.type === 'out') {
125
+ tail = (tail + event.text).slice(-800);
126
+ yield event;
127
+ continue;
128
+ }
129
+ ended = true;
130
+ const outcome = event.cancelled ? 'cancelled' : event.timedOut || event.exitCode !== 0 ? 'error' : 'ok';
131
+ journal.add({
132
+ kind: 'command',
133
+ outcome,
134
+ title: ticket && ticket.command.length > 120 ? `${ticket.command.slice(0, 120)}…` : (ticket?.command ?? ''),
135
+ detail: event.timedOut ? `Arrêtée après ${Math.round(event.durationMs / 1000)} s (délai dépassé).` : event.cancelled ? 'Annulée par toi.' : `Code de sortie ${event.exitCode ?? '?'} en ${event.durationMs} ms.`,
136
+ data: {
137
+ command: ticket?.command,
138
+ cwd: ticket?.cwd,
139
+ level: ticket?.verdict.level,
140
+ origin: ticket?.origin,
141
+ confirmation: options.confirmation,
142
+ exitCode: event.exitCode,
143
+ durationMs: event.durationMs,
144
+ timedOut: event.timedOut,
145
+ truncated: event.truncated,
146
+ tail,
147
+ },
148
+ });
149
+ yield event;
150
+ }
151
+ }
152
+ finally {
153
+ active.delete(execId);
154
+ // Le client a coupé la connexion avant la fin : on arrête la commande plutôt que de la laisser tourner sans témoin.
155
+ if (!ended)
156
+ handle.cancel();
157
+ }
158
+ }
159
+ return { execId, events: stream() };
160
+ }
161
+ cancel(execId) {
162
+ const handle = this.active.get(execId);
163
+ if (!handle)
164
+ return false;
165
+ handle.cancel();
166
+ return true;
167
+ }
168
+ cancelAll() {
169
+ for (const handle of this.active.values())
170
+ handle.cancel();
171
+ }
172
+ /** Exécute jusqu'au bout et rassemble la sortie (utilisé par le cerveau et les audits). */
173
+ async collect(execution, onEvent) {
174
+ let output = '';
175
+ let summary = { exitCode: null, durationMs: 0, cancelled: false, timedOut: false, truncated: false, output: '' };
176
+ for await (const event of execution.events) {
177
+ onEvent?.(event);
178
+ if (event.type === 'out' && output.length < 200_000)
179
+ output += event.text;
180
+ else if (event.type === 'end')
181
+ summary = { exitCode: event.exitCode, durationMs: event.durationMs, cancelled: event.cancelled, timedOut: event.timedOut, truncated: event.truncated, output: '' };
182
+ }
183
+ return { ...summary, output };
184
+ }
185
+ /** Commande fixe d'un module d'audit : elle doit être classée « lecture » par la même politique. */
186
+ async runRead(command, cwd) {
187
+ const preview = await this.preview(command, cwd, 'audit');
188
+ if (preview.verdict.level !== 'read' || !preview.ticket) {
189
+ const why = preview.verdict.reasons.find((r) => r.severity === 'block')?.message ?? 'commande non classée en lecture';
190
+ throw new AppError('audit_command_refused', 500, `Commande d’audit refusée par la politique : ${why}`);
191
+ }
192
+ return this.collect(this.start(preview.ticket, { confirmation: 'human' }));
193
+ }
194
+ }
@@ -0,0 +1,129 @@
1
+ /** Périmètre : dossiers autorisés en écriture, et hôtes autorisés pour les tests de ports. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ const win = path.win32;
5
+ const defaultRealpath = (p) => {
6
+ try {
7
+ return fs.realpathSync.native(p);
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ };
13
+ /** Vrai si « child » est « root » ou se trouve dedans (comparaison insensible à la casse). */
14
+ export function isInside(child, root) {
15
+ const rel = win.relative(root.toLowerCase(), child.toLowerCase());
16
+ return rel === '' || (!rel.startsWith('..') && !win.isAbsolute(rel));
17
+ }
18
+ /**
19
+ * Suit les jonctions et liens du plus long dossier existant, pour qu'un lien placé dans le
20
+ * périmètre ne permette pas d'écrire en dehors.
21
+ */
22
+ function realAncestor(absolute, realpath) {
23
+ const parts = absolute.split(/[\\/]+/);
24
+ const firstWild = parts.findIndex((part) => /[*?[\]]/.test(part));
25
+ const fixed = firstWild < 0 ? parts : parts.slice(0, firstWild);
26
+ const tail = firstWild < 0 ? [] : parts.slice(firstWild);
27
+ for (let n = fixed.length; n > 0; n--) {
28
+ const candidate = fixed.slice(0, n).join('\\') + (n === 1 ? '\\' : '');
29
+ const real = realpath(candidate);
30
+ if (real)
31
+ return win.join(real, ...fixed.slice(n), ...tail);
32
+ }
33
+ return absolute;
34
+ }
35
+ export function classifyPath(value, ctx) {
36
+ const v = value.trim();
37
+ if (/^[\\/]{2}/.test(v))
38
+ return { kind: 'unc', absolute: v, inScope: false };
39
+ // HKLM:\…, Env:…, Registry::…, Microsoft.PowerShell.Core\FileSystem::… (deux caractères au moins avant « : »)
40
+ if (/^[A-Za-z][\w.\\-]+:/.test(v))
41
+ return { kind: 'provider', absolute: v, inScope: false };
42
+ const expanded = v === '~' || /^~[\\/]/.test(v) ? ctx.home + v.slice(1) : v;
43
+ const absolute = win.resolve(ctx.cwd, expanded);
44
+ const realpath = ctx.realpath ?? defaultRealpath;
45
+ const real = realAncestor(absolute, realpath);
46
+ const inScope = ctx.roots.some((root) => isInside(real, realpath(root) ?? win.resolve(root)));
47
+ return { kind: 'file', absolute, inScope };
48
+ }
49
+ /**
50
+ * Valide un changement de dossier (« cd ») : le dossier doit exister et se trouver dans les dossiers autorisés.
51
+ * Renvoie le chemin réel, ou une raison lisible.
52
+ */
53
+ export function resolveDirectory(from, to, roots, home) {
54
+ const found = existingDirectory(from, to, home);
55
+ if (!found.ok)
56
+ return found;
57
+ if (!roots.some((root) => isInside(found.cwd, root)))
58
+ return { ok: false, code: 'cwd_out_of_scope', message: `« ${found.cwd} » est en dehors des dossiers autorisés (${roots.join(', ')}).` };
59
+ return found;
60
+ }
61
+ /** Chemin réel d'un dossier local existant (les jonctions sont suivies). Les chemins réseau sont refusés. */
62
+ export function existingDirectory(from, to, home) {
63
+ const expanded = to === '~' || /^~[\\/]/.test(to) ? home + to.slice(1) : to;
64
+ if (/^[\\/]{2}/.test(expanded) || /^[\\/]{2}/.test(from))
65
+ return { ok: false, code: 'bad_cwd', message: 'Les chemins réseau ne sont pas autorisés.' };
66
+ const wanted = win.resolve(from, expanded);
67
+ try {
68
+ const real = fs.realpathSync.native(wanted);
69
+ if (!fs.statSync(real).isDirectory())
70
+ throw new Error('pas un dossier');
71
+ if (/^[\\/]{2}/.test(real))
72
+ return { ok: false, code: 'bad_cwd', message: 'Les chemins réseau ne sont pas autorisés.' };
73
+ return { ok: true, cwd: real };
74
+ }
75
+ catch {
76
+ return { ok: false, code: 'bad_cwd', message: `Le dossier « ${wanted} » n’existe pas.` };
77
+ }
78
+ }
79
+ export function isLoopback(host) {
80
+ const h = host.toLowerCase().replace(/^\[|\]$/g, '');
81
+ return h === 'localhost' || h === '::1' || /^127(?:\.\d{1,3}){3}$/.test(h);
82
+ }
83
+ export function hostInScope(host, targets) {
84
+ const h = host.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
85
+ if (isLoopback(h))
86
+ return true;
87
+ return targets.some((t) => t.host === h || (t.includeSubdomains && h.endsWith(`.${t.host}`)));
88
+ }
89
+ const HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/i;
90
+ const IPV6 = /^[0-9a-f:]{2,39}$/i;
91
+ const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/;
92
+ /** Nom d'hôte ou adresse IP unique : ni plage, ni masque, ni joker. */
93
+ export function isSingleHost(value) {
94
+ const v = value.replace(/^\[|\]$/g, '');
95
+ // Que des chiffres, des points et des tirets : ce doit être une adresse IPv4 exacte, pas une plage (10.0.0.1-50).
96
+ if (/^[\d.-]+$/.test(v))
97
+ return IPV4.test(v);
98
+ return (HOSTNAME.test(v) && v.length <= 253) || (IPV6.test(v) && v.includes(':'));
99
+ }
100
+ const IPV4_INSIDE = /(?<![\d.])((?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d))(\/\d{1,2}|-\d{1,3})?(?![\d.])/g;
101
+ const URL_HOST = /\b[a-z][a-z0-9+.-]*:\/\/(?:[^\/\s@]*@)?(\[[0-9a-f:]+\]|[^\/\s:?#]+)/gi;
102
+ /**
103
+ * Machines qu'un argument désigne en clair : adresses IPv4 (seules, avec un port, un masque ou une plage) et
104
+ * hôtes d'URL. Sert à contrôler le périmètre d'une commande que la politique ne sait pas analyser
105
+ * (« python scan.py 10.0.0.5 »). Une plage ou un masque est rendu tel quel : il ne peut jamais être dans le périmètre.
106
+ */
107
+ export function mentionedHosts(value) {
108
+ const hosts = [];
109
+ for (const m of value.matchAll(URL_HOST))
110
+ if (!/^file:/i.test(m[0]))
111
+ hosts.push((m[1] ?? '').toLowerCase().replace(/^\[|\]$/g, ''));
112
+ for (const m of value.matchAll(IPV4_INSIDE))
113
+ hosts.push(`${m[1] ?? ''}${m[2] ?? ''}`);
114
+ return [...new Set(hosts.filter(Boolean))];
115
+ }
116
+ /** Hôte d'une URL http(s), ou null si ce n'en est pas une. */
117
+ export function hostFromUrl(value) {
118
+ const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`;
119
+ try {
120
+ const url = new URL(candidate);
121
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
122
+ return null;
123
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
124
+ return host && isSingleHost(host) ? host : null;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }