@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.
- package/LICENSE +21 -0
- package/README.md +191 -2
- package/bin/prmt.mjs +174 -0
- package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
- package/dist/assets/Audits-yUBm0EpW.js +1 -0
- package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
- package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
- package/dist/assets/Carte-CPuiR1Ha.css +1 -0
- package/dist/assets/Carte-xycu1mQn.js +1 -0
- package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
- package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
- package/dist/assets/Field-C7c2mCbb.js +2 -0
- package/dist/assets/Journal-BLIIlh80.css +1 -0
- package/dist/assets/Journal-BaelFjwV.js +1 -0
- package/dist/assets/Landing-B2jxV3zs.css +1 -0
- package/dist/assets/Landing-Dr612Zdu.js +26 -0
- package/dist/assets/Register-DA_Aq5f8.js +1 -0
- package/dist/assets/Reports-8wfDNSxH.js +1 -0
- package/dist/assets/Reports-B8GXN95N.css +1 -0
- package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
- package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
- package/dist/assets/SettingsPage-BElIP46c.css +1 -0
- package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
- package/dist/assets/Shell-BAyHVljp.css +1 -0
- package/dist/assets/Shell-CQRlx9-V.js +2 -0
- package/dist/assets/Table-DUmGel5e.js +1 -0
- package/dist/assets/Tabs-CfhbELZk.js +1 -0
- package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
- package/dist/assets/Terminal-CEfj38NV.css +1 -0
- package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
- package/dist/assets/auditViews-BMza2Sdz.js +1 -0
- package/dist/assets/auditViews-BePWj26h.css +1 -0
- package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
- package/dist/assets/charts-BcijSJ4J.js +1 -0
- package/dist/assets/circle-stop-C33BAcKW.js +1 -0
- package/dist/assets/command-ChA49Xk7.js +1 -0
- package/dist/assets/command-Ck4pdQCv.css +1 -0
- package/dist/assets/download-CeuKOenD.js +1 -0
- package/dist/assets/eye-CXXHBBpr.js +1 -0
- package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/assets/habillage-B5mMBadn.js +1 -0
- package/dist/assets/index-BH1etemR.css +1 -0
- package/dist/assets/index-COSiM-R9.js +14 -0
- package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
- package/dist/assets/layers-BWBAp2CZ.png +0 -0
- package/dist/assets/markdown-DF87LQh1.css +1 -0
- package/dist/assets/markdown-o5sMhPHN.js +4 -0
- package/dist/assets/marker-icon-hN30_KVU.png +0 -0
- package/dist/assets/play-Daw4K_73.js +1 -0
- package/dist/assets/plus-D_QHm8ud.js +1 -0
- package/dist/assets/trash-DLpHM-s8.js +1 -0
- package/dist/assets/web-6j1-aY67.js +1 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +18 -0
- package/docs/ANALYSE.md +87 -0
- package/docs/SECURITE.md +188 -0
- package/lib/scripts/lier.js +15 -0
- package/lib/scripts/searxng.js +91 -0
- package/lib/server/app.js +444 -0
- package/lib/server/audits/modules/local.js +429 -0
- package/lib/server/audits/modules/network.js +331 -0
- package/lib/server/audits/modules/web.js +302 -0
- package/lib/server/audits/net.js +141 -0
- package/lib/server/audits/registry.js +202 -0
- package/lib/server/audits/types.js +58 -0
- package/lib/server/brain/brain.js +624 -0
- package/lib/server/brain/conversations.js +82 -0
- package/lib/server/brain/ollama.js +197 -0
- package/lib/server/brain/prompt.js +188 -0
- package/lib/server/brain/provider.js +1 -0
- package/lib/server/cli/bridge.js +292 -0
- package/lib/server/cli/pont.js +62 -0
- package/lib/server/config.js +68 -0
- package/lib/server/engine/analyzer.js +205 -0
- package/lib/server/engine/catalog.js +229 -0
- package/lib/server/engine/decoder.js +91 -0
- package/lib/server/engine/engine.js +194 -0
- package/lib/server/engine/paths.js +129 -0
- package/lib/server/engine/policy.js +397 -0
- package/lib/server/engine/relax.js +10 -0
- package/lib/server/engine/runner.js +193 -0
- package/lib/server/errors.js +23 -0
- package/lib/server/http/guard.js +75 -0
- package/lib/server/http/router.js +154 -0
- package/lib/server/http/static.js +76 -0
- package/lib/server/index.js +40 -0
- package/lib/server/journal.js +139 -0
- package/lib/server/lien.js +33 -0
- package/lib/server/savoir/base.js +284 -0
- package/lib/server/savoir/lecture.js +406 -0
- package/lib/server/savoir/memoire.js +115 -0
- package/lib/server/search/categories.js +179 -0
- package/lib/server/search/fiche.js +326 -0
- package/lib/server/search/lieux.js +134 -0
- package/lib/server/search/overpass.js +216 -0
- package/lib/server/search/searxng.js +223 -0
- package/lib/server/search/tiles.js +103 -0
- package/lib/server/status.js +157 -0
- package/lib/server/stores.js +141 -0
- package/lib/server/telephone/acces.js +457 -0
- package/lib/server/telephone/certificat.js +79 -0
- package/lib/server/telephone/qr.js +308 -0
- package/lib/shared/analysis.js +40 -0
- package/lib/shared/schemas.js +105 -0
- package/lib/shared/types.js +10 -0
- package/package.json +81 -4
- package/searxng/settings.yml +35 -0
- package/tui/Modelfile +23 -0
- package/tui/README.md +103 -0
- package/tui/prmt_tui.py +65 -0
- package/tui/promethee/__init__.py +1 -0
- package/tui/promethee/__main__.py +4 -0
- package/tui/promethee/accueil.py +66 -0
- package/tui/promethee/agents.py +220 -0
- package/tui/promethee/app/__init__.py +4 -0
- package/tui/promethee/app/application.py +299 -0
- package/tui/promethee/app/clavier.py +99 -0
- package/tui/promethee/app/commandes_slash.py +164 -0
- package/tui/promethee/app/contexte.py +188 -0
- package/tui/promethee/app/definitions.py +518 -0
- package/tui/promethee/app/ecran.py +177 -0
- package/tui/promethee/app/envoi.py +141 -0
- package/tui/promethee/app/historique.py +373 -0
- package/tui/promethee/app/modele.py +108 -0
- package/tui/promethee/app/notes.py +63 -0
- package/tui/promethee/app/outils.py +589 -0
- package/tui/promethee/app/plateforme.py +409 -0
- package/tui/promethee/app/saisie.py +100 -0
- package/tui/promethee/app/taches.py +92 -0
- package/tui/promethee/app/tour.py +539 -0
- package/tui/promethee/app/validation.py +210 -0
- package/tui/promethee/appels.py +35 -0
- package/tui/promethee/commandes.py +154 -0
- package/tui/promethee/completion.py +140 -0
- package/tui/promethee/config.py +12 -0
- package/tui/promethee/consignes.py +278 -0
- package/tui/promethee/conversation.py +94 -0
- package/tui/promethee/etat.py +253 -0
- package/tui/promethee/fichiers.py +354 -0
- package/tui/promethee/journal.py +116 -0
- package/tui/promethee/logo.py +132 -0
- package/tui/promethee/memoire.py +50 -0
- package/tui/promethee/ollama.py +52 -0
- package/tui/promethee/plan.py +91 -0
- package/tui/promethee/politique.py +429 -0
- package/tui/promethee/preferences.py +65 -0
- package/tui/promethee/presse_papiers.py +44 -0
- package/tui/promethee/recherche.py +581 -0
- package/tui/promethee/securite.py +204 -0
- package/tui/promethee/shell.py +190 -0
- package/tui/promethee/textes.py +190 -0
- package/tui/promethee/theme.py +92 -0
- package/tui/promethee/widgets.py +493 -0
- package/tui/requirements.txt +1 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cœur du pont entre la TUI « prmt » et le moteur de politique de la console.
|
|
3
|
+
*
|
|
4
|
+
* La TUI pose une question par ligne (JSON), le pont répond par ligne. Il n'exécute jamais rien :
|
|
5
|
+
* il analyse une commande, rend un verdict, et tient à jour la liste blanche et le périmètre que
|
|
6
|
+
* les deux interfaces partagent.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { scopeInput } from '../../shared/schemas.js';
|
|
11
|
+
import { APPS, CMDLETS, appKey } from '../engine/catalog.js';
|
|
12
|
+
import { existingDirectory, hostInScope, isInside, mentionedHosts } from '../engine/paths.js';
|
|
13
|
+
import { evaluate, modulesToPreload } from '../engine/policy.js';
|
|
14
|
+
import { decide, isPolicyMode } from '../engine/relax.js';
|
|
15
|
+
export class BridgeError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const MAX_COMMAND = 4000;
|
|
23
|
+
const MAX_SCRIPT = 256 * 1024;
|
|
24
|
+
/** Toutes les constantes d'une commande, quel que soit leur niveau (paramètres, listes). */
|
|
25
|
+
function constants(elements) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const el of elements) {
|
|
28
|
+
if ((el.k === 's' || el.k === 'n' || el.k === 'x') && el.v)
|
|
29
|
+
out.push(el.v);
|
|
30
|
+
if (el.a)
|
|
31
|
+
out.push(...constants([el.a]));
|
|
32
|
+
if (el.i)
|
|
33
|
+
out.push(...constants(el.i));
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/** Machines citées en clair dans les arguments d'une commande (adresses IPv4, hôtes d'URL). */
|
|
38
|
+
export function explicitTargets(analysis) {
|
|
39
|
+
const hosts = new Set();
|
|
40
|
+
for (const cmd of analysis.commands) {
|
|
41
|
+
for (const value of [cmd.name ?? '', ...constants(cmd.elements)])
|
|
42
|
+
for (const host of mentionedHosts(value))
|
|
43
|
+
hosts.add(host);
|
|
44
|
+
}
|
|
45
|
+
return [...hosts];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Script PowerShell que la commande lance par son chemin : « .\\scan.ps1 », « & C:\\x\\scan.ps1 »,
|
|
49
|
+
* « powershell -File scan.ps1 ». Renvoie le chemin tel qu'il est écrit, ou null.
|
|
50
|
+
*/
|
|
51
|
+
export function scriptPath(analysis) {
|
|
52
|
+
if (!analysis.ok || analysis.commands.length !== 1)
|
|
53
|
+
return null;
|
|
54
|
+
const cmd = analysis.commands[0];
|
|
55
|
+
if (!cmd?.name)
|
|
56
|
+
return null;
|
|
57
|
+
if (/\.ps1$/i.test(cmd.name))
|
|
58
|
+
return cmd.name;
|
|
59
|
+
if (!/^(?:powershell|pwsh)(?:\.exe)?$/i.test(cmd.name))
|
|
60
|
+
return null;
|
|
61
|
+
const values = cmd.elements.filter((el) => el.k === 's' && typeof el.v === 'string').map((el) => el.v ?? '');
|
|
62
|
+
return values.find((v) => /\.ps1$/i.test(v)) ?? null;
|
|
63
|
+
}
|
|
64
|
+
const CD_NODES = new Set(['ScriptBlockAst', 'NamedBlockAst', 'PipelineAst', 'CommandAst', 'CommandParameterAst', 'StringConstantExpressionAst']);
|
|
65
|
+
const text = (value, name) => {
|
|
66
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
67
|
+
throw new BridgeError('bad_request', `Champ « ${name} » manquant.`);
|
|
68
|
+
return value;
|
|
69
|
+
};
|
|
70
|
+
const names = (value) => {
|
|
71
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 20)
|
|
72
|
+
throw new BridgeError('bad_request', 'Champ « names » : une liste de 1 à 20 noms est attendue.');
|
|
73
|
+
return value.map((v) => text(v, 'names').trim());
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* « cd dossier » (ou « D: ») seul sur sa ligne : renvoie le dossier demandé, sinon null.
|
|
77
|
+
* Dans une commande composée, Set-Location reste interdit : les chemins relatifs qui suivent ne seraient plus vérifiables.
|
|
78
|
+
*/
|
|
79
|
+
export function soleCd(analysis) {
|
|
80
|
+
if (!analysis.ok || analysis.commands.length !== 1)
|
|
81
|
+
return null;
|
|
82
|
+
if (analysis.redirections.length + analysis.variables.length + analysis.members.length + analysis.types.length + analysis.assignTargets.length > 0)
|
|
83
|
+
return null;
|
|
84
|
+
if (analysis.nodes.some((node) => !CD_NODES.has(node)))
|
|
85
|
+
return null;
|
|
86
|
+
const cmd = analysis.commands[0];
|
|
87
|
+
if (!cmd || cmd.bindErrors.length > 0)
|
|
88
|
+
return null;
|
|
89
|
+
if (cmd.kind === 'Function' && /^[A-Za-z]:$/.test(cmd.name ?? '') && cmd.elements.length <= 1)
|
|
90
|
+
return `${(cmd.name ?? '').toUpperCase()}\\`;
|
|
91
|
+
if ((cmd.resolved ?? '').toLowerCase() !== 'set-location' || cmd.bound.length !== 1)
|
|
92
|
+
return null;
|
|
93
|
+
const bound = cmd.bound[0];
|
|
94
|
+
if (!bound || !['path', 'literalpath'].includes(bound.name.toLowerCase()))
|
|
95
|
+
return null;
|
|
96
|
+
return bound.el?.k === 's' && bound.el.v ? bound.el.v : null;
|
|
97
|
+
}
|
|
98
|
+
export class Bridge {
|
|
99
|
+
deps;
|
|
100
|
+
constructor(deps) {
|
|
101
|
+
this.deps = deps;
|
|
102
|
+
}
|
|
103
|
+
summary() {
|
|
104
|
+
const s = this.deps.settings.get();
|
|
105
|
+
const counts = { read: 0, sensitive: 0, destructive: 0, forbidden: 0 };
|
|
106
|
+
for (const rule of [...CMDLETS.values(), ...APPS.values()])
|
|
107
|
+
counts[rule.level]++;
|
|
108
|
+
return {
|
|
109
|
+
counts,
|
|
110
|
+
constrained: s.engine.constrainedLanguage,
|
|
111
|
+
custom: s.allowlist.custom,
|
|
112
|
+
unlocked: s.allowlist.unlocked,
|
|
113
|
+
roots: s.engine.roots,
|
|
114
|
+
targets: this.deps.scope.list().map((t) => ({ host: t.host, includeSubdomains: t.includeSubdomains, note: t.note, attestedAt: t.attestedAt })),
|
|
115
|
+
dataDir: this.deps.dataDir,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
async verdict(command, cwd, mode) {
|
|
119
|
+
if (command.length > MAX_COMMAND)
|
|
120
|
+
throw new BridgeError('too_long', `Commande trop longue (${command.length} caractères, ${MAX_COMMAND} au plus).`);
|
|
121
|
+
const { settings, scope, home, systemRoot, analyzer } = this.deps;
|
|
122
|
+
const s = settings.get();
|
|
123
|
+
const dir = cwd && cwd.trim() ? cwd : (s.engine.roots[0] ?? home);
|
|
124
|
+
const analysis = await analyzer.analyze(command.trim());
|
|
125
|
+
const target = soleCd(analysis);
|
|
126
|
+
if (target !== null) {
|
|
127
|
+
const cmd = analysis.commands[0];
|
|
128
|
+
const finding = { name: cmd?.name ?? 'cd', resolved: 'Set-Location', kind: 'Cmdlet', source: null, level: 'read', note: 'dossier courant' };
|
|
129
|
+
const found = existingDirectory(dir, target, home);
|
|
130
|
+
if (!found.ok) {
|
|
131
|
+
const reason = { code: 'bad-cwd', message: found.message, severity: 'block', floor: true };
|
|
132
|
+
return { level: 'denied', reasons: [reason], commands: [{ ...finding, level: 'denied' }], writes: [], network: [], outcome: 'refused', mode, blocking: [reason], modules: [], constrained: false };
|
|
133
|
+
}
|
|
134
|
+
const reasons = [{ code: 'cd', message: `Change le dossier courant : ${found.cwd}`, severity: 'info' }];
|
|
135
|
+
if (!s.engine.roots.some((root) => isInside(found.cwd, root)))
|
|
136
|
+
reasons.push({ code: 'cd-outside', message: 'Ce dossier est en dehors de tes dossiers autorisés : les écritures y seront refusées.', severity: 'info' });
|
|
137
|
+
return { level: 'read', reasons, commands: [finding], writes: [], network: [], outcome: 'read', mode, blocking: [], modules: [], constrained: s.engine.constrainedLanguage, cd: found.cwd };
|
|
138
|
+
}
|
|
139
|
+
const ctx = { cwd: dir, roots: s.engine.roots, home, custom: s.allowlist.custom, unlocked: s.allowlist.unlocked, targets: scope.list(), systemRoot };
|
|
140
|
+
const verdict = evaluate(analysis, ctx);
|
|
141
|
+
const decision = decide(verdict, mode);
|
|
142
|
+
const result = { ...verdict, ...decision, mode, modules: modulesToPreload(analysis), constrained: s.engine.constrainedLanguage && decision.outcome !== 'outside' };
|
|
143
|
+
if (decision.outcome !== 'outside')
|
|
144
|
+
return result;
|
|
145
|
+
// Hors politique (mode souple) : l'analyse ne sait pas ce que fait la commande. Deux contrôles restent possibles.
|
|
146
|
+
// 1. Une machine citée en clair dans ses arguments doit être dans le périmètre.
|
|
147
|
+
const foreign = explicitTargets(analysis).filter((host) => !hostInScope(host, scope.list()));
|
|
148
|
+
if (foreign.length > 0) {
|
|
149
|
+
const blocking = foreign.map((host) => ({
|
|
150
|
+
code: 'target-out-of-scope',
|
|
151
|
+
severity: 'block',
|
|
152
|
+
floor: true,
|
|
153
|
+
subject: host,
|
|
154
|
+
message: `« ${host} » n’est pas dans ton périmètre autorisé, et cette commande n’est pas analysable : elle est refusée.`,
|
|
155
|
+
plain: `« ${host} » n’est pas dans ton périmètre autorisé, et cette commande n’est pas analysable : elle est refusée.`,
|
|
156
|
+
}));
|
|
157
|
+
return { ...result, outcome: 'refused', blocking, reasons: [...verdict.reasons, ...blocking], network: [...new Set([...verdict.network, ...foreign])] };
|
|
158
|
+
}
|
|
159
|
+
// 2. Un script PowerShell lancé par son chemin : son contenu, lui, s'analyse.
|
|
160
|
+
const script = scriptPath(analysis);
|
|
161
|
+
if (script)
|
|
162
|
+
return this.withScript(result, script, dir, mode, ctx);
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
/** Analyse le contenu d'un script .ps1 : ses planchers refusent la commande, le reste s'ajoute à ce que tu lis avant de valider. */
|
|
166
|
+
async withScript(outer, script, dir, mode, ctx) {
|
|
167
|
+
const file = path.win32.resolve(dir, script.replace(/^~(?=[\\/])/, this.deps.home));
|
|
168
|
+
const name = path.win32.basename(file);
|
|
169
|
+
const note = (message) => ({ ...outer, reasons: [...outer.reasons, { code: 'script', message, severity: 'warn' }] });
|
|
170
|
+
let content;
|
|
171
|
+
try {
|
|
172
|
+
if (/^[\\/]{2}/.test(file))
|
|
173
|
+
return outer;
|
|
174
|
+
if (fs.statSync(file).size > MAX_SCRIPT)
|
|
175
|
+
return note(`${name} est trop long pour être analysé : son contenu n’est pas contrôlé.`);
|
|
176
|
+
content = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '').trim();
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return note(`${name} est introuvable ou illisible : son contenu n’est pas contrôlé.`);
|
|
180
|
+
}
|
|
181
|
+
if (!content)
|
|
182
|
+
return outer;
|
|
183
|
+
const inner = evaluate(await this.deps.analyzer.analyze(content), ctx);
|
|
184
|
+
const floors = decide(inner, mode).blocking;
|
|
185
|
+
if (floors.length > 0) {
|
|
186
|
+
const blocking = floors.map((r) => ({ ...r, message: `Dans ${name} : ${r.plain ?? r.message}`, plain: `Dans ${name} : ${r.plain ?? r.message}` }));
|
|
187
|
+
return { ...outer, outcome: 'refused', blocking, reasons: [...outer.reasons, ...blocking], writes: inner.writes, network: inner.network };
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
...note(`Contenu de ${name} analysé : aucune commande destructrice verrouillée ni cible hors périmètre.`),
|
|
191
|
+
writes: [...new Set([...outer.writes, ...inner.writes])],
|
|
192
|
+
network: [...new Set([...outer.network, ...inner.network])],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/** Autorise des commandes : débloque une destructrice, ou ajoute une commande absente de la liste. Les interdites le restent. */
|
|
196
|
+
allow(list) {
|
|
197
|
+
const s = this.deps.settings.get();
|
|
198
|
+
const custom = [...s.allowlist.custom];
|
|
199
|
+
const unlocked = [...s.allowlist.unlocked];
|
|
200
|
+
const result = { added: [], unlocked: [], already: [], refused: [] };
|
|
201
|
+
for (const raw of list) {
|
|
202
|
+
if (!/^[A-Za-z][\w.-]{0,63}$/.test(raw)) {
|
|
203
|
+
result.refused.push({ name: raw, reason: 'nom de commande invalide' });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const lower = raw.toLowerCase();
|
|
207
|
+
const key = appKey(raw).key;
|
|
208
|
+
const cmdlet = CMDLETS.get(lower);
|
|
209
|
+
const app = cmdlet ? undefined : APPS.get(key);
|
|
210
|
+
const rule = cmdlet ?? app;
|
|
211
|
+
const id = cmdlet ? lower : key;
|
|
212
|
+
if (rule?.level === 'forbidden')
|
|
213
|
+
result.refused.push({ name: raw, reason: `interdite (${rule.note ?? rule.category})` });
|
|
214
|
+
else if (rule?.level === 'destructive' || app?.unlockable) {
|
|
215
|
+
if (unlocked.includes(id))
|
|
216
|
+
result.already.push(raw);
|
|
217
|
+
else {
|
|
218
|
+
unlocked.push(id);
|
|
219
|
+
result.unlocked.push(raw);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else if (rule || custom.some((c) => c.name.toLowerCase() === id))
|
|
223
|
+
result.already.push(raw);
|
|
224
|
+
else if (custom.length >= 60)
|
|
225
|
+
result.refused.push({ name: raw, reason: 'la liste personnelle est limitée à 60 commandes' });
|
|
226
|
+
else {
|
|
227
|
+
custom.push({ name: id, level: 'sensitive' });
|
|
228
|
+
result.added.push(raw);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (result.added.length + result.unlocked.length > 0) {
|
|
232
|
+
this.deps.settings.patch({ allowlist: { custom, unlocked } });
|
|
233
|
+
this.deps.journal.add({
|
|
234
|
+
kind: 'settings',
|
|
235
|
+
outcome: 'info',
|
|
236
|
+
title: `Liste blanche modifiée depuis la TUI : ${[...result.added, ...result.unlocked].join(', ')}`,
|
|
237
|
+
data: { origin: 'tui', added: result.added, unlocked: result.unlocked },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
revoke(list) {
|
|
243
|
+
const s = this.deps.settings.get();
|
|
244
|
+
const wanted = new Set(list.flatMap((n) => [n.toLowerCase(), appKey(n).key]));
|
|
245
|
+
const custom = s.allowlist.custom.filter((c) => !wanted.has(c.name.toLowerCase()));
|
|
246
|
+
const unlocked = s.allowlist.unlocked.filter((u) => !wanted.has(u.toLowerCase()));
|
|
247
|
+
const removed = [...s.allowlist.custom.map((c) => c.name), ...s.allowlist.unlocked].filter((n) => wanted.has(n.toLowerCase()));
|
|
248
|
+
if (removed.length > 0) {
|
|
249
|
+
this.deps.settings.patch({ allowlist: { custom, unlocked } });
|
|
250
|
+
this.deps.journal.add({ kind: 'settings', outcome: 'info', title: `Autorisation retirée depuis la TUI : ${removed.join(', ')}`, data: { origin: 'tui', removed } });
|
|
251
|
+
}
|
|
252
|
+
return { removed };
|
|
253
|
+
}
|
|
254
|
+
async handle(req) {
|
|
255
|
+
const { scope, journal } = this.deps;
|
|
256
|
+
switch (req.op) {
|
|
257
|
+
case 'verdict':
|
|
258
|
+
return this.verdict(text(req.command, 'command'), typeof req.cwd === 'string' ? req.cwd : null, isPolicyMode(req.mode) ? req.mode : 'stricte');
|
|
259
|
+
case 'politique':
|
|
260
|
+
return this.summary();
|
|
261
|
+
case 'autoriser':
|
|
262
|
+
return { ...this.allow(names(req.names)), policy: this.summary() };
|
|
263
|
+
case 'retirer':
|
|
264
|
+
return { ...this.revoke(names(req.names)), policy: this.summary() };
|
|
265
|
+
case 'cible-ajouter': {
|
|
266
|
+
const parsed = scopeInput.safeParse({ host: req.host, note: typeof req.note === 'string' ? req.note : '', includeSubdomains: req.includeSubdomains === true, attest: req.attest });
|
|
267
|
+
if (!parsed.success)
|
|
268
|
+
throw new BridgeError('bad_request', parsed.error.issues[0]?.message ?? 'Cible invalide.');
|
|
269
|
+
const target = scope.add(parsed.data);
|
|
270
|
+
journal.add({
|
|
271
|
+
kind: 'scope',
|
|
272
|
+
outcome: 'info',
|
|
273
|
+
title: `Cible ajoutée au périmètre depuis la TUI : ${target.host}`,
|
|
274
|
+
detail: 'Attestation : la cible t’appartient ou tu as une autorisation écrite.',
|
|
275
|
+
data: { origin: 'tui', host: target.host, includeSubdomains: target.includeSubdomains, note: target.note },
|
|
276
|
+
});
|
|
277
|
+
return { added: target.host, policy: this.summary() };
|
|
278
|
+
}
|
|
279
|
+
case 'cible-retirer': {
|
|
280
|
+
const host = text(req.host, 'host').trim().toLowerCase();
|
|
281
|
+
const target = scope.list().find((t) => t.host === host);
|
|
282
|
+
if (!target)
|
|
283
|
+
throw new BridgeError('not_found', `« ${host} » n’est pas dans le périmètre.`);
|
|
284
|
+
scope.remove(target.id);
|
|
285
|
+
journal.add({ kind: 'scope', outcome: 'info', title: `Cible retirée du périmètre depuis la TUI : ${host}`, data: { origin: 'tui', host } });
|
|
286
|
+
return { removed: host, policy: this.summary() };
|
|
287
|
+
}
|
|
288
|
+
default:
|
|
289
|
+
throw new BridgeError('unknown_op', `Opération inconnue : ${String(req.op)}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pont entre la TUI « prmt » (Python) et le moteur de politique de la console.
|
|
3
|
+
*
|
|
4
|
+
* La TUI lance ce programme et lui parle en JSON, une demande par ligne sur l'entrée standard,
|
|
5
|
+
* une réponse par ligne sur la sortie standard. Les deux interfaces partagent ainsi la même
|
|
6
|
+
* liste blanche, le même périmètre et les mêmes réglages, sans que le serveur web ait besoin de tourner.
|
|
7
|
+
*
|
|
8
|
+
* {"id":1,"op":"verdict","command":"Get-Date","cwd":"C:\\Users\\moi","mode":"stricte"}
|
|
9
|
+
* {"id":1,"ok":true,"result":{"level":"read","outcome":"read","reasons":[],…}}
|
|
10
|
+
*
|
|
11
|
+
* Opérations : verdict, politique, autoriser, retirer, cible-ajouter, cible-retirer.
|
|
12
|
+
* Ce programme n'exécute jamais la commande : il l'analyse et rend un verdict.
|
|
13
|
+
*/
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import readline from 'node:readline';
|
|
17
|
+
import { VERSION, defaultSettings, loadConfig } from '../config.js';
|
|
18
|
+
import { PowerShellAnalyzer } from '../engine/analyzer.js';
|
|
19
|
+
import { engineEnv } from '../engine/runner.js';
|
|
20
|
+
import { describeError } from '../errors.js';
|
|
21
|
+
import { Journal } from '../journal.js';
|
|
22
|
+
import { ScopeStore, SettingsStore } from '../stores.js';
|
|
23
|
+
import { Bridge, BridgeError } from './bridge.js';
|
|
24
|
+
const config = loadConfig();
|
|
25
|
+
const analyzer = new PowerShellAnalyzer({ env: engineEnv(process.env), cwd: os.tmpdir() });
|
|
26
|
+
const bridge = new Bridge({
|
|
27
|
+
analyzer,
|
|
28
|
+
settings: new SettingsStore(path.join(config.dataDir, 'reglages.json'), defaultSettings(config)),
|
|
29
|
+
scope: new ScopeStore(path.join(config.dataDir, 'perimetre.json')),
|
|
30
|
+
journal: new Journal(path.join(config.dataDir, 'journal.jsonl')),
|
|
31
|
+
home: config.home,
|
|
32
|
+
systemRoot: config.systemRoot,
|
|
33
|
+
dataDir: config.dataDir,
|
|
34
|
+
});
|
|
35
|
+
const send = (message) => void process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
36
|
+
send({ ready: true, version: VERSION, dataDir: config.dataDir });
|
|
37
|
+
const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
38
|
+
lines.on('line', (line) => {
|
|
39
|
+
if (!line.trim())
|
|
40
|
+
return;
|
|
41
|
+
let req;
|
|
42
|
+
try {
|
|
43
|
+
req = JSON.parse(line);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
send({ id: null, ok: false, error: { code: 'bad_json', message: 'Demande illisible.' } });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const id = typeof req.id === 'string' || typeof req.id === 'number' ? req.id : null;
|
|
50
|
+
bridge.handle(req).then((result) => send({ id, ok: true, result }), (err) => {
|
|
51
|
+
if (err instanceof BridgeError)
|
|
52
|
+
send({ id, ok: false, error: { code: err.code, message: err.message } });
|
|
53
|
+
else {
|
|
54
|
+
const described = describeError(err);
|
|
55
|
+
send({ id, ok: false, error: { code: described.code, message: described.code === 'internal' && err instanceof Error ? err.message : described.message } });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
lines.on('close', () => {
|
|
60
|
+
analyzer.dispose();
|
|
61
|
+
process.exit(0);
|
|
62
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Configuration du serveur local : ports, dossiers, valeurs par défaut. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
export const VERSION = '2.0.0';
|
|
6
|
+
/**
|
|
7
|
+
* La racine du paquet, où vivent dist/ et searxng/. On remonte jusqu'au package.json, pour que ça marche aussi bien depuis la
|
|
8
|
+
* source (server/) que depuis le JS compilé (lib/server/), car la profondeur n'est pas la même.
|
|
9
|
+
*/
|
|
10
|
+
function findRoot() {
|
|
11
|
+
let dir = import.meta.dirname;
|
|
12
|
+
for (let i = 0; i < 6; i++) {
|
|
13
|
+
if (fs.existsSync(path.join(dir, 'package.json')))
|
|
14
|
+
return dir;
|
|
15
|
+
const up = path.dirname(dir);
|
|
16
|
+
if (up === dir)
|
|
17
|
+
break;
|
|
18
|
+
dir = up;
|
|
19
|
+
}
|
|
20
|
+
return path.resolve(import.meta.dirname, '..');
|
|
21
|
+
}
|
|
22
|
+
export const PACKAGE_ROOT = findRoot();
|
|
23
|
+
/** SearXNG tel que l'installe « npm run searxng » : publié sur la boucle locale seulement. */
|
|
24
|
+
export const DEFAULT_SEARXNG_URL = 'http://127.0.0.1:8888';
|
|
25
|
+
/** Même règle que la TUI : OLLAMA_HOST, sinon 127.0.0.1:11434. */
|
|
26
|
+
export function ollamaUrlFromEnv(env) {
|
|
27
|
+
let host = (env.OLLAMA_HOST ?? '').trim() || '127.0.0.1:11434';
|
|
28
|
+
if (!host.includes('://'))
|
|
29
|
+
host = `http://${host}`;
|
|
30
|
+
host = host.replace('0.0.0.0', '127.0.0.1').replace(/\/+$/, '');
|
|
31
|
+
if ((host.match(/:/g) ?? []).length === 1)
|
|
32
|
+
host += ':11434';
|
|
33
|
+
return host;
|
|
34
|
+
}
|
|
35
|
+
export function loadConfig(env = process.env, overrides = {}) {
|
|
36
|
+
const root = PACKAGE_ROOT;
|
|
37
|
+
const home = os.homedir();
|
|
38
|
+
return {
|
|
39
|
+
host: '127.0.0.1',
|
|
40
|
+
port: Number(env.PROMETHEE_PORT) || 4317,
|
|
41
|
+
dataDir: env.PROMETHEE_DATA ? path.resolve(env.PROMETHEE_DATA) : path.join(root, 'data'),
|
|
42
|
+
distDir: path.join(root, 'dist'),
|
|
43
|
+
dev: env.PROMETHEE_DEV === '1',
|
|
44
|
+
home,
|
|
45
|
+
systemRoot: env.SystemRoot ?? env.windir ?? 'C:\\Windows',
|
|
46
|
+
tuiDir: env.PROMETHEE_TUI ? path.resolve(env.PROMETHEE_TUI) : path.join(home, 'Documents', 'Promethee'),
|
|
47
|
+
defaultOllamaUrl: ollamaUrlFromEnv(env),
|
|
48
|
+
searchEnabled: env.PROMETHEE_SEARCH !== '0',
|
|
49
|
+
phonePort: Number(env.PROMETHEE_PORT_TELEPHONE) || 4318,
|
|
50
|
+
phoneAddresses: env.PROMETHEE_TELEPHONE_ADRESSES
|
|
51
|
+
? env.PROMETHEE_TELEPHONE_ADRESSES.split(',')
|
|
52
|
+
.map((a) => a.trim())
|
|
53
|
+
.filter((a) => /^\d{1,3}(\.\d{1,3}){3}$/.test(a))
|
|
54
|
+
: null,
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function defaultSettings(config) {
|
|
59
|
+
return {
|
|
60
|
+
model: null,
|
|
61
|
+
engine: { timeoutSec: 120, constrainedLanguage: true, confirmRead: true, roots: [config.home] },
|
|
62
|
+
allowlist: { custom: [], unlocked: [] },
|
|
63
|
+
ollama: { baseUrl: config.defaultOllamaUrl, replyTimeoutSec: 180 },
|
|
64
|
+
search: { enabled: config.searchEnabled, url: DEFAULT_SEARXNG_URL, learn: true },
|
|
65
|
+
fullCyber: false,
|
|
66
|
+
autoRun: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analyse d'une commande par le vrai parseur de PowerShell, sans l'exécuter.
|
|
3
|
+
*
|
|
4
|
+
* Un processus PowerShell reste ouvert et ne fait que deux choses : construire l'arbre
|
|
5
|
+
* syntaxique de la commande, et demander à Get-Command ce que chaque nom désigne.
|
|
6
|
+
* Le texte de la commande ne voyage qu'en base64 : il n'est jamais interprété ici.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { analysisSchema } from '../../shared/analysis.js';
|
|
11
|
+
import { AppError } from '../errors.js';
|
|
12
|
+
const L = 'System.Management.Automation.Language.';
|
|
13
|
+
/** Script d'analyse. Aucun accent grave ni « ${ » : il est écrit dans un gabarit brut. */
|
|
14
|
+
export const ANALYZER_SCRIPT = String.raw `param($id, $b64)
|
|
15
|
+
$r = [ordered]@{ ok = $true; errors = @(); nodes = @(); commands = @(); members = @(); types = @(); redirections = @(); variables = @(); assignTargets = @() }
|
|
16
|
+
function PrmElem($e) {
|
|
17
|
+
if ($null -eq $e) { return $null }
|
|
18
|
+
$t = $e.GetType().Name
|
|
19
|
+
$raw = [string]$e.Extent.Text
|
|
20
|
+
if ($raw.Length -gt 400) { $raw = $raw.Substring(0, 400) }
|
|
21
|
+
switch ($t) {
|
|
22
|
+
'CommandParameterAst' {
|
|
23
|
+
$o = [ordered]@{ k = 'p'; n = [string]$e.ParameterName; t = $raw }
|
|
24
|
+
if ($e.Argument) { $o.a = PrmElem $e.Argument }
|
|
25
|
+
return $o }
|
|
26
|
+
'StringConstantExpressionAst' { return [ordered]@{ k = 's'; v = [string]$e.Value; t = $raw; bare = ($e.StringConstantType -eq 'BareWord') } }
|
|
27
|
+
'ConstantExpressionAst' { return [ordered]@{ k = 'n'; v = [string]$e.Value; t = $raw } }
|
|
28
|
+
'ExpandableStringExpressionAst' { return [ordered]@{ k = 'x'; t = $raw } }
|
|
29
|
+
'ScriptBlockExpressionAst' { return [ordered]@{ k = 'b' } }
|
|
30
|
+
'VariableExpressionAst' { return [ordered]@{ k = 'v'; n = [string]$e.VariablePath.UserPath; t = $raw; splat = [bool]$e.Splatted } }
|
|
31
|
+
'HashtableAst' { return [ordered]@{ k = 'h' } }
|
|
32
|
+
'ArrayLiteralAst' { return [ordered]@{ k = 'l'; t = $raw; i = @($e.Elements | ForEach-Object { PrmElem $_ }) } }
|
|
33
|
+
default { return [ordered]@{ k = 'e'; t = $t } }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
$src = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b64))
|
|
38
|
+
$tokens = $null; $errs = $null
|
|
39
|
+
$ast = [@L@Parser]::ParseInput($src, [ref]$tokens, [ref]$errs)
|
|
40
|
+
if ($errs.Count -gt 0) { $r.ok = $false; $r.errors = @($errs | ForEach-Object { [string]$_.Message }) }
|
|
41
|
+
else {
|
|
42
|
+
$all = @($ast.FindAll({ $true }, $true))
|
|
43
|
+
$r.nodes = @($all | ForEach-Object { $_.GetType().Name } | Sort-Object -Unique)
|
|
44
|
+
$assigned = @{}
|
|
45
|
+
foreach ($a in $all) {
|
|
46
|
+
if ($a -is [@L@AssignmentStatementAst]) {
|
|
47
|
+
foreach ($v in @($a.Left.FindAll({ $args[0] -is [@L@VariableExpressionAst] }, $true))) { $assigned[[string]$v.VariablePath.UserPath] = $true }
|
|
48
|
+
$left = $a.Left
|
|
49
|
+
while ($left -is [@L@AttributedExpressionAst]) { $left = $left.Child }
|
|
50
|
+
$targets = @($left)
|
|
51
|
+
if ($left -is [@L@ArrayLiteralAst]) { $targets = @($left.Elements) }
|
|
52
|
+
foreach ($t in $targets) {
|
|
53
|
+
$inner = $t
|
|
54
|
+
while ($inner -is [@L@AttributedExpressionAst]) { $inner = $inner.Child }
|
|
55
|
+
if ($inner -isnot [@L@VariableExpressionAst]) { $r.assignTargets += [string]$inner.GetType().Name }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
foreach ($c in $all) {
|
|
60
|
+
if ($c -is [@L@CommandAst]) {
|
|
61
|
+
$name = $c.GetCommandName()
|
|
62
|
+
$o = [ordered]@{ name = $name; resolved = $null; kind = 'Dynamic'; source = $null; module = $null; alias = $false; op = [string]$c.InvocationOperator; text = [string]$c.Extent.Text; elements = @(); bound = @(); bindErrors = @() }
|
|
63
|
+
$info = $null
|
|
64
|
+
if ($name) {
|
|
65
|
+
$o.kind = 'NotFound'
|
|
66
|
+
$lookup = [System.Management.Automation.WildcardPattern]::Escape($name)
|
|
67
|
+
$info = Get-Command $lookup -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
68
|
+
if ($info -and $info.CommandType -eq 'Alias') {
|
|
69
|
+
$o.alias = $true
|
|
70
|
+
$target = $info.ResolvedCommand
|
|
71
|
+
if (-not $target -and $info.Definition) { $target = Get-Command $info.Definition -ErrorAction SilentlyContinue | Select-Object -First 1 }
|
|
72
|
+
$info = $target
|
|
73
|
+
}
|
|
74
|
+
if ($info) {
|
|
75
|
+
$o.resolved = [string]$info.Name; $o.kind = [string]$info.CommandType
|
|
76
|
+
if ($info.CommandType -eq 'Application' -or $info.CommandType -eq 'ExternalScript') { $o.source = [string]$info.Source }
|
|
77
|
+
else { $o.module = [string]$info.ModuleName }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
$els = @($c.CommandElements)
|
|
81
|
+
if ($els.Count -gt 1) { $o.elements = @($els[1..($els.Count - 1)] | ForEach-Object { PrmElem $_ }) }
|
|
82
|
+
if ($info -and ($info.CommandType -eq 'Cmdlet' -or $info.CommandType -eq 'Function')) {
|
|
83
|
+
try {
|
|
84
|
+
$b = [@L@StaticParameterBinder]::BindCommand($c, $true)
|
|
85
|
+
foreach ($k in @($b.BoundParameters.Keys)) { $o.bound += [ordered]@{ name = [string]$k; el = (PrmElem $b.BoundParameters[$k].Value) } }
|
|
86
|
+
foreach ($k in @($b.BindingExceptions.Keys)) { $o.bindErrors += ([string]$k + ' : ' + [string]$b.BindingExceptions[$k].BindingException.Message) }
|
|
87
|
+
} catch { $o.bindErrors += ('liaison : ' + [string]$_.Exception.Message) }
|
|
88
|
+
}
|
|
89
|
+
$r.commands += $o
|
|
90
|
+
}
|
|
91
|
+
elseif ($c -is [@L@MemberExpressionAst]) {
|
|
92
|
+
$member = $null
|
|
93
|
+
if ($c.Member -is [@L@StringConstantExpressionAst]) { $member = [string]$c.Member.Value }
|
|
94
|
+
$type = $null
|
|
95
|
+
if ($c.Expression -is [@L@TypeExpressionAst]) { $type = [string]$c.Expression.TypeName.FullName }
|
|
96
|
+
$r.members += [ordered]@{ member = $member; call = ($c -is [@L@InvokeMemberExpressionAst]); static = [bool]$c.Static; type = $type }
|
|
97
|
+
}
|
|
98
|
+
elseif ($c -is [@L@TypeExpressionAst]) { $r.types += [string]$c.TypeName.FullName }
|
|
99
|
+
elseif ($c -is [@L@ConvertExpressionAst]) { $r.types += [string]$c.Type.TypeName.FullName }
|
|
100
|
+
elseif ($c -is [@L@TypeConstraintAst]) { $r.types += [string]$c.TypeName.FullName }
|
|
101
|
+
elseif ($c -is [@L@FileRedirectionAst]) { $r.redirections += [ordered]@{ target = (PrmElem $c.Location); append = [bool]$c.Append; stream = [string]$c.FromStream } }
|
|
102
|
+
elseif ($c -is [@L@VariableExpressionAst]) {
|
|
103
|
+
$vp = $c.VariablePath
|
|
104
|
+
$drive = $null
|
|
105
|
+
if ($vp.IsDriveQualified) { $drive = [string]$vp.DriveName }
|
|
106
|
+
$r.variables += [ordered]@{ name = [string]$vp.UserPath; drive = $drive; assigned = $assigned.ContainsKey([string]$vp.UserPath); splat = [bool]$c.Splatted }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
$r.types = @($r.types | Sort-Object -Unique)
|
|
110
|
+
}
|
|
111
|
+
} catch { $r.ok = $false; $r.errors = @('ANALYSE ' + [string]$_.Exception.Message) }
|
|
112
|
+
$json = $r | ConvertTo-Json -Depth 10 -Compress
|
|
113
|
+
[Console]::Out.WriteLine('PRM|' + $id + '|' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)))
|
|
114
|
+
`.replaceAll('@L@', L);
|
|
115
|
+
const b64 = (s) => Buffer.from(s, 'utf8').toString('base64');
|
|
116
|
+
/** Analyseur réel : garde un processus PowerShell ouvert, les demandes passent une par une. */
|
|
117
|
+
export class PowerShellAnalyzer {
|
|
118
|
+
child = null;
|
|
119
|
+
buffer = '';
|
|
120
|
+
waiting = null;
|
|
121
|
+
chain = Promise.resolve();
|
|
122
|
+
options;
|
|
123
|
+
constructor(options) {
|
|
124
|
+
this.options = options;
|
|
125
|
+
}
|
|
126
|
+
analyze(command) {
|
|
127
|
+
const next = this.chain.then(() => this.request(command));
|
|
128
|
+
this.chain = next.catch(() => undefined);
|
|
129
|
+
return next;
|
|
130
|
+
}
|
|
131
|
+
dispose() {
|
|
132
|
+
this.child?.kill();
|
|
133
|
+
this.child = null;
|
|
134
|
+
}
|
|
135
|
+
start() {
|
|
136
|
+
const child = spawn(this.options.executable ?? 'powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '-'], {
|
|
137
|
+
cwd: this.options.cwd,
|
|
138
|
+
env: this.options.env,
|
|
139
|
+
windowsHide: true,
|
|
140
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
141
|
+
});
|
|
142
|
+
child.stdout.setEncoding('utf8');
|
|
143
|
+
child.stdout.on('data', (chunk) => this.onData(chunk));
|
|
144
|
+
child.stderr.resume();
|
|
145
|
+
const fail = (message) => {
|
|
146
|
+
if (this.child === child)
|
|
147
|
+
this.child = null;
|
|
148
|
+
this.waiting?.reject(new AppError('analyzer_down', 503, message));
|
|
149
|
+
this.waiting = null;
|
|
150
|
+
};
|
|
151
|
+
child.on('error', (err) => fail(`PowerShell est introuvable : ${err.message}`));
|
|
152
|
+
child.on('exit', () => fail('Le processus d’analyse s’est arrêté.'));
|
|
153
|
+
child.stdin.on('error', () => undefined);
|
|
154
|
+
child.stdin.write(`$global:PrmA = [scriptblock]::Create([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${b64(ANALYZER_SCRIPT)}')))\n`);
|
|
155
|
+
this.buffer = '';
|
|
156
|
+
return child;
|
|
157
|
+
}
|
|
158
|
+
onData(chunk) {
|
|
159
|
+
this.buffer += chunk;
|
|
160
|
+
let index = this.buffer.indexOf('\n');
|
|
161
|
+
while (index >= 0) {
|
|
162
|
+
const line = this.buffer.slice(0, index).trim();
|
|
163
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
164
|
+
const w = this.waiting;
|
|
165
|
+
if (w && line.startsWith(`PRM|${w.id}|`)) {
|
|
166
|
+
this.waiting = null;
|
|
167
|
+
w.resolve(line.slice(w.id.length + 5));
|
|
168
|
+
}
|
|
169
|
+
index = this.buffer.indexOf('\n');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async request(command) {
|
|
173
|
+
if (!this.child)
|
|
174
|
+
this.child = this.start();
|
|
175
|
+
const child = this.child;
|
|
176
|
+
const id = randomBytes(8).toString('hex');
|
|
177
|
+
const timeoutMs = this.options.timeoutMs ?? 25_000;
|
|
178
|
+
const line = await new Promise((resolve, reject) => {
|
|
179
|
+
const timer = setTimeout(() => {
|
|
180
|
+
this.waiting = null;
|
|
181
|
+
child.kill();
|
|
182
|
+
if (this.child === child)
|
|
183
|
+
this.child = null;
|
|
184
|
+
reject(new AppError('analyzer_timeout', 504, 'L’analyse de la commande a dépassé le délai.'));
|
|
185
|
+
}, timeoutMs);
|
|
186
|
+
this.waiting = {
|
|
187
|
+
id,
|
|
188
|
+
resolve: (value) => { clearTimeout(timer); resolve(value); },
|
|
189
|
+
reject: (err) => { clearTimeout(timer); reject(err); },
|
|
190
|
+
};
|
|
191
|
+
child.stdin.write(`& $global:PrmA '${id}' '${b64(command)}'\n`);
|
|
192
|
+
});
|
|
193
|
+
let raw;
|
|
194
|
+
try {
|
|
195
|
+
raw = JSON.parse(Buffer.from(line, 'base64').toString('utf8'));
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
throw new AppError('analyzer_output', 502, 'Réponse illisible de l’analyseur PowerShell.');
|
|
199
|
+
}
|
|
200
|
+
const parsed = analysisSchema.safeParse(raw);
|
|
201
|
+
if (!parsed.success)
|
|
202
|
+
throw new AppError('analyzer_output', 502, 'Réponse inattendue de l’analyseur PowerShell.');
|
|
203
|
+
return parsed.data;
|
|
204
|
+
}
|
|
205
|
+
}
|