@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,397 @@
|
|
|
1
|
+
import { APPS, CMDLETS, appKey } from './catalog.js';
|
|
2
|
+
import { classifyPath, hostFromUrl, hostInScope, isInside, isSingleHost } from './paths.js';
|
|
3
|
+
const ALLOWED_NODES = new Set([
|
|
4
|
+
'ScriptBlockAst', 'NamedBlockAst', 'StatementBlockAst', 'PipelineAst', 'CommandAst', 'CommandExpressionAst', 'CommandParameterAst',
|
|
5
|
+
'StringConstantExpressionAst', 'ExpandableStringExpressionAst', 'ConstantExpressionAst', 'VariableExpressionAst', 'MemberExpressionAst',
|
|
6
|
+
'InvokeMemberExpressionAst', 'TypeExpressionAst', 'ConvertExpressionAst', 'TypeConstraintAst', 'ArrayLiteralAst', 'ArrayExpressionAst',
|
|
7
|
+
'HashtableAst', 'ParenExpressionAst', 'SubExpressionAst', 'BinaryExpressionAst', 'UnaryExpressionAst', 'IndexExpressionAst',
|
|
8
|
+
'ScriptBlockExpressionAst', 'AssignmentStatementAst', 'IfStatementAst', 'ForEachStatementAst', 'ForStatementAst', 'WhileStatementAst',
|
|
9
|
+
'DoWhileStatementAst', 'DoUntilStatementAst', 'SwitchStatementAst', 'TryStatementAst', 'CatchClauseAst', 'ThrowStatementAst',
|
|
10
|
+
'ReturnStatementAst', 'BreakStatementAst', 'ContinueStatementAst', 'ExitStatementAst', 'FileRedirectionAst', 'MergingRedirectionAst',
|
|
11
|
+
]);
|
|
12
|
+
const NODE_LABELS = {
|
|
13
|
+
FunctionDefinitionAst: 'définition de fonction',
|
|
14
|
+
ParamBlockAst: 'bloc param()',
|
|
15
|
+
ParameterAst: 'bloc param()',
|
|
16
|
+
AttributeAst: 'attribut',
|
|
17
|
+
TrapStatementAst: 'instruction trap',
|
|
18
|
+
DataStatementAst: 'section data',
|
|
19
|
+
TypeDefinitionAst: 'définition de classe',
|
|
20
|
+
UsingStatementAst: 'instruction using',
|
|
21
|
+
UsingExpressionAst: 'variable $using:',
|
|
22
|
+
DynamicKeywordStatementAst: 'mot-clé dynamique',
|
|
23
|
+
ConfigurationDefinitionAst: 'configuration DSC',
|
|
24
|
+
BlockStatementAst: 'bloc parallel/sequence',
|
|
25
|
+
};
|
|
26
|
+
const normType = (t) => t.toLowerCase().replace(/^system\./, '').replace(/\[\]$/, '');
|
|
27
|
+
const SAFE_TYPES = new Set([
|
|
28
|
+
'int', 'int32', 'int64', 'long', 'uint32', 'uint64', 'double', 'single', 'float', 'decimal', 'bool', 'boolean', 'string', 'char', 'byte',
|
|
29
|
+
'datetime', 'timespan', 'version', 'guid', 'ipaddress', 'net.ipaddress', 'uri', 'regex', 'text.regularexpressions.regex', 'math', 'convert',
|
|
30
|
+
'array', 'hashtable', 'pscustomobject', 'psobject', 'object', 'void', 'ordered', 'switch',
|
|
31
|
+
]);
|
|
32
|
+
/** Méthodes statiques autorisées, par type. « * » : toutes. */
|
|
33
|
+
const STATIC_MEMBERS = {
|
|
34
|
+
math: '*',
|
|
35
|
+
char: '*',
|
|
36
|
+
datetime: new Set(['now', 'utcnow', 'today', 'parse', 'parseexact', 'minvalue', 'maxvalue']),
|
|
37
|
+
timespan: new Set(['fromseconds', 'fromminutes', 'fromhours', 'fromdays', 'frommilliseconds', 'parse', 'zero']),
|
|
38
|
+
string: new Set(['isnullorempty', 'isnullorwhitespace', 'join', 'format', 'concat', 'empty']),
|
|
39
|
+
convert: new Set(['toint32', 'toint64', 'tostring', 'todouble', 'toboolean', 'tobyte', 'tochar', 'tobase64string', 'frombase64string']),
|
|
40
|
+
regex: new Set(['match', 'matches', 'ismatch', 'replace', 'escape', 'split']),
|
|
41
|
+
'text.regularexpressions.regex': new Set(['match', 'matches', 'ismatch', 'replace', 'escape', 'split']),
|
|
42
|
+
guid: new Set(['newguid', 'parse', 'empty']),
|
|
43
|
+
ipaddress: new Set(['parse', 'loopback', 'any']),
|
|
44
|
+
'net.ipaddress': new Set(['parse', 'loopback', 'any']),
|
|
45
|
+
version: new Set(['parse']),
|
|
46
|
+
int: new Set(['parse', 'maxvalue', 'minvalue']),
|
|
47
|
+
int32: new Set(['parse', 'maxvalue', 'minvalue']),
|
|
48
|
+
int64: new Set(['parse', 'maxvalue', 'minvalue']),
|
|
49
|
+
double: new Set(['parse', 'maxvalue', 'minvalue']),
|
|
50
|
+
};
|
|
51
|
+
const INSTANCE_METHODS = new Set([
|
|
52
|
+
'tostring', 'toupper', 'tolower', 'toupperinvariant', 'tolowerinvariant', 'trim', 'trimstart', 'trimend', 'split', 'replace', 'contains',
|
|
53
|
+
'startswith', 'endswith', 'substring', 'indexof', 'lastindexof', 'padleft', 'padright', 'equals', 'compareto', 'adddays', 'addhours',
|
|
54
|
+
'addminutes', 'addseconds', 'addmonths', 'addyears', 'tolocaltime', 'touniversaltime', 'toshortdatestring', 'toshorttimestring',
|
|
55
|
+
]);
|
|
56
|
+
const FORBIDDEN_VARIABLES = new Set(['executioncontext', 'psdefaultparametervalues', 'psmoduleautoloadingpreference']);
|
|
57
|
+
const VARIABLE_SCOPES = new Set(['global', 'local', 'script', 'private']);
|
|
58
|
+
const SCRIPT_HOST_EXTENSIONS = new Set(['.bat', '.cmd', '.ps1', '.psm1', '.vbs', '.vbe', '.js', '.jse', '.wsf', '.hta', '.msi', '.lnk']);
|
|
59
|
+
const block = (code, message) => ({ code, message, severity: 'block' });
|
|
60
|
+
/** Refus qui tient même en politique souple : destruction, cible hors périmètre, protections de Windows. */
|
|
61
|
+
const floor = (code, message) => ({ code, message, severity: 'block', floor: true });
|
|
62
|
+
/** Catégories interdites qui sont aussi des planchers. Les interpréteurs n'en sont pas : la politique souple les laisse à ta validation. */
|
|
63
|
+
const FLOOR_CATEGORIES = new Set(['protections', 'disques']);
|
|
64
|
+
const warn = (code, message) => ({ code, message, severity: 'warn' });
|
|
65
|
+
const info = (code, message) => ({ code, message, severity: 'info' });
|
|
66
|
+
/** Valeurs constantes d'un élément (une chaîne, ou une liste de chaînes). null : non vérifiable. */
|
|
67
|
+
function constants(el) {
|
|
68
|
+
if (!el)
|
|
69
|
+
return null;
|
|
70
|
+
if (el.k === 'p')
|
|
71
|
+
return el.a ? constants(el.a) : null;
|
|
72
|
+
if (el.k === 's' || el.k === 'n')
|
|
73
|
+
return [el.v ?? ''];
|
|
74
|
+
if (el.k === 'l' && el.i) {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const item of el.i) {
|
|
77
|
+
const values = constants(item);
|
|
78
|
+
if (!values)
|
|
79
|
+
return null;
|
|
80
|
+
out.push(...values);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
function boundParam(cmd, names) {
|
|
87
|
+
const wanted = new Set(names.map((n) => n.toLowerCase()));
|
|
88
|
+
return cmd.bound.filter((b) => wanted.has(b.name.toLowerCase()));
|
|
89
|
+
}
|
|
90
|
+
/** Arguments d'une application, en texte. null si l'un d'eux n'est pas une constante. */
|
|
91
|
+
function appArgv(cmd) {
|
|
92
|
+
const argv = [];
|
|
93
|
+
for (const el of cmd.elements) {
|
|
94
|
+
if (el.k === 's' || el.k === 'n')
|
|
95
|
+
argv.push(el.v ?? '');
|
|
96
|
+
else if (el.k === 'p') {
|
|
97
|
+
const raw = el.t ?? `-${el.n ?? ''}`;
|
|
98
|
+
if (el.a) {
|
|
99
|
+
const values = constants(el.a);
|
|
100
|
+
if (!values)
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
argv.push(raw);
|
|
104
|
+
}
|
|
105
|
+
else if (el.k === 'l') {
|
|
106
|
+
const values = constants(el);
|
|
107
|
+
if (!values)
|
|
108
|
+
return null;
|
|
109
|
+
argv.push(values.join(','));
|
|
110
|
+
}
|
|
111
|
+
else
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return argv;
|
|
115
|
+
}
|
|
116
|
+
function checkWritePath(value, label, ctx, draft) {
|
|
117
|
+
const resolved = classifyPath(value, ctx);
|
|
118
|
+
if (resolved.kind === 'unc')
|
|
119
|
+
draft.reasons.push(floor('unc-path', `${label} : les chemins réseau (\\\\serveur\\partage) sont refusés.`));
|
|
120
|
+
else if (resolved.kind === 'provider')
|
|
121
|
+
draft.reasons.push(block('provider-write', `${label} : écriture hors du système de fichiers (« ${value} ») refusée.`));
|
|
122
|
+
else if (!resolved.inScope)
|
|
123
|
+
draft.reasons.push(block('out-of-scope', `${label} : « ${resolved.absolute} » est en dehors des dossiers autorisés en écriture.`));
|
|
124
|
+
else if (isInside(resolved.absolute, ctx.systemRoot))
|
|
125
|
+
draft.reasons.push(block('out-of-scope', `${label} : écriture dans le dossier de Windows refusée.`));
|
|
126
|
+
else
|
|
127
|
+
draft.writes.push(resolved.absolute);
|
|
128
|
+
}
|
|
129
|
+
function checkReadPath(value, label, ctx, draft) {
|
|
130
|
+
const resolved = classifyPath(value, ctx);
|
|
131
|
+
// Les chemins réseau sont déjà refusés pour toute la commande (voir evaluateCmdlet).
|
|
132
|
+
if (resolved.kind === 'file' && !resolved.inScope)
|
|
133
|
+
draft.reasons.push(info('read-outside', `${label} lit en dehors de tes dossiers autorisés : ${resolved.absolute}`));
|
|
134
|
+
}
|
|
135
|
+
function checkScanHost(host, label, ctx, draft) {
|
|
136
|
+
draft.network.push(host);
|
|
137
|
+
if (!hostInScope(host, ctx.targets))
|
|
138
|
+
draft.reasons.push({ ...floor('target-out-of-scope', `${label} : « ${host} » n’est pas dans ton périmètre autorisé. Déclare cette cible dans Audits › Périmètre si elle t’appartient ou si tu as une autorisation écrite.`), plain: `${label} : « ${host} » n’est pas dans ton périmètre autorisé.`, subject: host });
|
|
139
|
+
}
|
|
140
|
+
function evaluateCmdlet(cmd, rule, label, ctx, draft) {
|
|
141
|
+
const writing = rule.level !== 'read';
|
|
142
|
+
const resolvedName = (cmd.resolved ?? '').toLowerCase();
|
|
143
|
+
if (cmd.bindErrors.length > 0) {
|
|
144
|
+
const text = `${label} : paramètres non reconnus ou ambigus (${cmd.bindErrors[0]}).`;
|
|
145
|
+
draft.reasons.push(writing ? block('bind-error', text) : warn('bind-error', text));
|
|
146
|
+
}
|
|
147
|
+
if (cmd.elements.some((el) => el.k === 'v' && el.splat))
|
|
148
|
+
draft.reasons.push(block('splat', `${label} : les arguments éclatés (@variable) ne sont pas vérifiables.`));
|
|
149
|
+
if (!rule.blocks && cmd.elements.some((el) => el.k === 'b' || (el.k === 'p' && el.a?.k === 'b')))
|
|
150
|
+
draft.reasons.push(block('scriptblock-arg', `${label} : cette commande ne doit pas recevoir de bloc de script.`));
|
|
151
|
+
// « ForEach-Object Kill » ou « -MemberName Delete » appellerait une méthode sans que l'analyse la voie.
|
|
152
|
+
if (resolvedName === 'foreach-object') {
|
|
153
|
+
const blockParam = (n) => ['process', 'begin', 'end'].some((full) => n.length > 0 && full.startsWith(n.toLowerCase()));
|
|
154
|
+
const onlyBlocks = cmd.elements.every((el) => el.k === 'b' || (el.k === 'p' && blockParam(el.n ?? '') && (!el.a || el.a.k === 'b')));
|
|
155
|
+
if (!onlyBlocks)
|
|
156
|
+
draft.reasons.push(block('member-name', 'ForEach-Object : seul un bloc de script { … } est accepté (pas de nom de propriété ou de méthode).'));
|
|
157
|
+
}
|
|
158
|
+
// Chemins réseau, où qu'ils apparaissent : ils enverraient les identifiants Windows au serveur visé.
|
|
159
|
+
const everyConstant = cmd.elements.flatMap((el) => constants(el) ?? []);
|
|
160
|
+
if (everyConstant.some((v) => /^[\\/]{2}/.test(v)))
|
|
161
|
+
draft.reasons.push(floor('unc-path', `${label} : les chemins réseau (\\\\serveur\\partage) sont refusés, ils enverraient tes identifiants Windows au serveur visé.`));
|
|
162
|
+
// Interrogation d'une autre machine par un paramètre générique.
|
|
163
|
+
for (const b of boundParam(cmd, ['ComputerName', 'CimSession']).filter((x) => !(rule.host ?? []).includes(x.name))) {
|
|
164
|
+
const values = constants(b.el);
|
|
165
|
+
draft.sensitive = true;
|
|
166
|
+
if (!values)
|
|
167
|
+
draft.reasons.push(floor('dynamic-target', `${label} : la machine visée par -${b.name} n’est pas une constante.`));
|
|
168
|
+
else {
|
|
169
|
+
draft.network.push(...values.map((v) => v.toLowerCase()));
|
|
170
|
+
draft.reasons.push(warn('remote-host', `${label} interroge une autre machine : ${values.join(', ')}.`));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const b of boundParam(cmd, rule.write ?? [])) {
|
|
174
|
+
const values = constants(b.el);
|
|
175
|
+
if (!values)
|
|
176
|
+
draft.reasons.push(block('dynamic-path', `${label} : le chemin de -${b.name} n’est pas une constante, il ne peut pas être vérifié.`));
|
|
177
|
+
else
|
|
178
|
+
for (const v of values)
|
|
179
|
+
checkWritePath(v, `${label} -${b.name}`, ctx, draft);
|
|
180
|
+
}
|
|
181
|
+
if (writing && rule.require && boundParam(cmd, rule.require).length === 0)
|
|
182
|
+
draft.reasons.push(block('missing-path', `${label} : la cible doit être donnée explicitement (-${rule.require[0]}), pas par le pipeline.`));
|
|
183
|
+
for (const b of boundParam(cmd, rule.read ?? [])) {
|
|
184
|
+
const values = constants(b.el);
|
|
185
|
+
if (values)
|
|
186
|
+
for (const v of values)
|
|
187
|
+
checkReadPath(v, label, ctx, draft);
|
|
188
|
+
}
|
|
189
|
+
if (resolvedName === 'rename-item') {
|
|
190
|
+
const newName = constants(boundParam(cmd, ['NewName'])[0]?.el);
|
|
191
|
+
if (!newName || newName.some((n) => /[\\/:]/.test(n)))
|
|
192
|
+
draft.reasons.push(block('dynamic-path', 'Rename-Item : -NewName doit être un simple nom de fichier constant.'));
|
|
193
|
+
}
|
|
194
|
+
const scanning = (rule.scan ?? []).length > 0 && boundParam(cmd, rule.scan ?? []).length > 0;
|
|
195
|
+
if (scanning)
|
|
196
|
+
draft.sensitive = true;
|
|
197
|
+
for (const b of boundParam(cmd, rule.host ?? [])) {
|
|
198
|
+
const values = constants(b.el);
|
|
199
|
+
if (!values) {
|
|
200
|
+
if (scanning)
|
|
201
|
+
draft.reasons.push(floor('dynamic-target', `${label} : la cible n’est pas une constante, elle ne peut pas être vérifiée.`));
|
|
202
|
+
else if (writing)
|
|
203
|
+
draft.reasons.push(warn('dynamic-target', `${label} : la cible est calculée, vérifie-la avant de confirmer.`));
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
for (const v of values) {
|
|
207
|
+
const host = b.name.toLowerCase() === 'uri' ? hostFromUrl(v) : isSingleHost(v) ? v.toLowerCase() : null;
|
|
208
|
+
if (!host)
|
|
209
|
+
draft.reasons.push(floor('bad-target', `${label} : « ${v} » n’est pas ${b.name.toLowerCase() === 'uri' ? 'une URL http(s)' : 'un hôte unique'}.`));
|
|
210
|
+
else if (scanning)
|
|
211
|
+
checkScanHost(host, label, ctx, draft);
|
|
212
|
+
else
|
|
213
|
+
draft.network.push(host);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (scanning && boundParam(cmd, rule.host ?? []).length === 0)
|
|
217
|
+
draft.reasons.push(floor('dynamic-target', `${label} : indique la cible avec -ComputerName.`));
|
|
218
|
+
// Start-Process débloqué : il ne doit quand même jamais lancer un interpréteur interdit ni un script.
|
|
219
|
+
if (resolvedName === 'start-process') {
|
|
220
|
+
const programs = constants(boundParam(cmd, ['FilePath'])[0]?.el);
|
|
221
|
+
if (!programs)
|
|
222
|
+
draft.reasons.push(block('dynamic-args', 'Start-Process : le programme à lancer doit être une constante.'));
|
|
223
|
+
else
|
|
224
|
+
for (const program of programs) {
|
|
225
|
+
const { key, ext } = appKey(program);
|
|
226
|
+
if (APPS.get(key)?.level === 'forbidden' || SCRIPT_HOST_EXTENSIONS.has(ext))
|
|
227
|
+
draft.reasons.push(block('forbidden', `Start-Process : « ${program} » est un interpréteur ou un script, interdit même par ce détour.`));
|
|
228
|
+
}
|
|
229
|
+
if (boundParam(cmd, ['Verb']).length > 0)
|
|
230
|
+
draft.reasons.push(warn('elevation', 'Start-Process -Verb peut demander une élévation de privilèges.'));
|
|
231
|
+
}
|
|
232
|
+
if (boundParam(cmd, ['InFile', 'Body', 'Form']).length > 0)
|
|
233
|
+
draft.reasons.push(warn('web-upload', `${label} envoie des données ou un fichier au serveur.`));
|
|
234
|
+
const method = constants(boundParam(cmd, ['Method'])[0]?.el);
|
|
235
|
+
if (method && method.some((m) => !/^(get|head|options|default)$/i.test(m)))
|
|
236
|
+
draft.reasons.push(warn('web-method', `${label} : la méthode ${method.join(', ')} envoie ou modifie des données sur le serveur.`));
|
|
237
|
+
}
|
|
238
|
+
function evaluateCommand(cmd, ctx, draft) {
|
|
239
|
+
const kind = (['Cmdlet', 'Function', 'Application', 'ExternalScript', 'Alias', 'NotFound', 'Dynamic'].includes(cmd.kind) ? cmd.kind : 'Other');
|
|
240
|
+
const finding = { name: cmd.name ?? cmd.text.slice(0, 60), resolved: cmd.resolved, kind, source: cmd.source, level: 'denied', note: '' };
|
|
241
|
+
const refuse = (code, message, note, hard = false, extra = {}) => {
|
|
242
|
+
draft.reasons.push({ ...(hard ? floor(code, message) : block(code, message)), ...extra });
|
|
243
|
+
return { ...finding, note };
|
|
244
|
+
};
|
|
245
|
+
if (!cmd.name)
|
|
246
|
+
return refuse('dynamic-command', `Commande calculée à l’exécution (« ${cmd.text.slice(0, 80)} ») : elle ne peut pas être vérifiée.`, 'commande dynamique');
|
|
247
|
+
const label = cmd.resolved ?? cmd.name;
|
|
248
|
+
if (cmd.op === 'Dot')
|
|
249
|
+
return refuse('dot-source', `« ${cmd.name} » : le chargement de script (point-source) est refusé.`, 'script chargé');
|
|
250
|
+
if (/[\\/]/.test(cmd.name) || kind === 'ExternalScript')
|
|
251
|
+
return refuse('path-invocation', `« ${cmd.name} » : lancer un programme ou un script par son chemin est refusé.`, 'programme désigné par chemin');
|
|
252
|
+
if (kind === 'NotFound')
|
|
253
|
+
return refuse('not-found', `« ${cmd.name} » n’est ni installé sur cette machine ni une commande PowerShell.`, 'introuvable');
|
|
254
|
+
if (kind === 'Other' || kind === 'Alias')
|
|
255
|
+
return refuse('not-allowlisted', `« ${cmd.name} » : type de commande non pris en charge.`, 'hors liste blanche');
|
|
256
|
+
const isApp = kind === 'Application';
|
|
257
|
+
const { key, ext } = isApp ? appKey(cmd.source ?? cmd.resolved ?? cmd.name) : { key: label.toLowerCase(), ext: '' };
|
|
258
|
+
if (isApp && SCRIPT_HOST_EXTENSIONS.has(ext))
|
|
259
|
+
return refuse('script-host', `« ${cmd.name} » est un script (${ext}) : il lancerait un interpréteur.`, 'script');
|
|
260
|
+
const rule = isApp ? APPS.get(key) : CMDLETS.get(key);
|
|
261
|
+
const custom = ctx.custom.find((c) => c.name.toLowerCase() === key);
|
|
262
|
+
const unlocked = ctx.unlocked.some((u) => u.toLowerCase() === key);
|
|
263
|
+
if (rule?.level === 'forbidden')
|
|
264
|
+
return refuse('forbidden', `${label} est interdit : ${rule.note ?? rule.category}.`, `interdit · ${rule.category}`, FLOOR_CATEGORIES.has(rule.category));
|
|
265
|
+
if (rule?.level === 'destructive' && !unlocked)
|
|
266
|
+
return refuse('destructive', `${label} est une commande destructrice (${rule.note ?? rule.category}), refusée par défaut. Tu peux la débloquer dans Réglages › Liste blanche.`, `destructif · ${rule.category}`, true, { plain: `${label} est une commande destructrice (${rule.note ?? rule.category}), refusée par défaut.`, subject: key });
|
|
267
|
+
if (!rule && !custom)
|
|
268
|
+
return refuse('not-allowlisted', `${label} n’est pas dans la liste blanche. Tu peux l’ajouter dans Réglages › Liste blanche.`, 'hors liste blanche', false, { plain: `${label} n’est pas dans la liste blanche.`, subject: key });
|
|
269
|
+
let level = rule ? (rule.level === 'read' ? 'read' : 'sensitive') : custom?.level === 'read' ? 'read' : 'sensitive';
|
|
270
|
+
const before = draft.reasons.length;
|
|
271
|
+
const wasSensitive = draft.sensitive;
|
|
272
|
+
draft.sensitive = false;
|
|
273
|
+
if (rule?.level === 'destructive')
|
|
274
|
+
draft.reasons.push(warn('unlocked', `${label} est une commande destructrice que tu as débloquée : ${rule.note ?? ''}.`));
|
|
275
|
+
if (!rule && custom)
|
|
276
|
+
draft.reasons.push(info('custom', `${label} vient de ta liste blanche personnelle : ses arguments ne sont pas contrôlés.`));
|
|
277
|
+
if (cmd.alias && /^(curl|wget)$/i.test(cmd.name))
|
|
278
|
+
draft.reasons.push(warn('alias', `Dans PowerShell 5.1, « ${cmd.name} » est un alias d’Invoke-WebRequest. Pour le vrai curl, écris curl.exe.`));
|
|
279
|
+
if (isApp) {
|
|
280
|
+
const appRule = APPS.get(key);
|
|
281
|
+
if (appRule?.system && !isInside(cmd.source ?? '', `${ctx.systemRoot}\\System32`))
|
|
282
|
+
draft.reasons.push(block('app-location', `${cmd.name} : l’exécutable trouvé (${cmd.source ?? 'inconnu'}) n’est pas celui de Windows.`));
|
|
283
|
+
const argv = appArgv(cmd);
|
|
284
|
+
if (!argv)
|
|
285
|
+
draft.reasons.push(block('dynamic-args', `${cmd.name} : ses arguments doivent être des constantes pour être vérifiés.`));
|
|
286
|
+
else if (appRule?.args) {
|
|
287
|
+
const result = appRule.args(argv);
|
|
288
|
+
draft.reasons.push(...result.reasons);
|
|
289
|
+
if (result.level)
|
|
290
|
+
level = result.level;
|
|
291
|
+
if (result.needsUnlock) {
|
|
292
|
+
if (unlocked)
|
|
293
|
+
draft.reasons.push(warn('unlocked', `${cmd.name} : ${result.needsUnlock} (commande que tu as débloquée).`));
|
|
294
|
+
else
|
|
295
|
+
draft.reasons.push({ ...floor('destructive', `${cmd.name} : ${result.needsUnlock}, refusé par défaut. Tu peux débloquer « ${key} » dans Réglages › Liste blanche.`), plain: `${cmd.name} : ${result.needsUnlock}, refusé par défaut.`, subject: key });
|
|
296
|
+
}
|
|
297
|
+
for (const host of result.hosts ?? [])
|
|
298
|
+
draft.network.push(host);
|
|
299
|
+
for (const host of result.scanHosts ?? [])
|
|
300
|
+
checkScanHost(host, cmd.name, ctx, draft);
|
|
301
|
+
for (const file of result.writes ?? [])
|
|
302
|
+
checkWritePath(file, `${cmd.name} (fichier de sortie)`, ctx, draft);
|
|
303
|
+
}
|
|
304
|
+
if (argv?.some((a) => /^[\\/]{2}/.test(a)))
|
|
305
|
+
draft.reasons.push(floor('unc-path', `${cmd.name} : les chemins réseau sont refusés.`));
|
|
306
|
+
}
|
|
307
|
+
else if (rule) {
|
|
308
|
+
evaluateCmdlet(cmd, rule, label, ctx, draft);
|
|
309
|
+
}
|
|
310
|
+
else if (cmd.elements.some((el) => el.k === 'b')) {
|
|
311
|
+
draft.reasons.push(block('scriptblock-arg', `${label} : cette commande ne doit pas recevoir de bloc de script.`));
|
|
312
|
+
}
|
|
313
|
+
if (draft.sensitive)
|
|
314
|
+
level = 'sensitive';
|
|
315
|
+
draft.sensitive = wasSensitive || level === 'sensitive';
|
|
316
|
+
if (draft.reasons.slice(before).some((r) => r.severity === 'block'))
|
|
317
|
+
level = 'denied';
|
|
318
|
+
const note = rule ? `${rule.category}${rule.note ? ` · ${rule.note}` : ''}` : 'liste personnelle';
|
|
319
|
+
return { ...finding, level, note };
|
|
320
|
+
}
|
|
321
|
+
export function evaluate(analysis, ctx) {
|
|
322
|
+
const draft = { reasons: [], writes: [], network: [], sensitive: false };
|
|
323
|
+
if (!analysis.ok) {
|
|
324
|
+
const first = analysis.errors[0] ?? 'erreur inconnue';
|
|
325
|
+
draft.reasons.push(floor('syntax', `Erreur de syntaxe PowerShell : ${first}`));
|
|
326
|
+
return { level: 'denied', reasons: draft.reasons, commands: [], writes: [], network: [] };
|
|
327
|
+
}
|
|
328
|
+
for (const node of analysis.nodes) {
|
|
329
|
+
if (!ALLOWED_NODES.has(node))
|
|
330
|
+
draft.reasons.push(block('construct', `Construction non autorisée : ${NODE_LABELS[node] ?? node}.`));
|
|
331
|
+
}
|
|
332
|
+
for (const type of analysis.types) {
|
|
333
|
+
if (!SAFE_TYPES.has(normType(type)))
|
|
334
|
+
draft.reasons.push(block('type', `Le type [${type}] n’est pas autorisé : seuls les types de base le sont.`));
|
|
335
|
+
}
|
|
336
|
+
for (const m of analysis.members) {
|
|
337
|
+
const member = m.member?.toLowerCase() ?? null;
|
|
338
|
+
if (m.static) {
|
|
339
|
+
const allowed = m.type ? STATIC_MEMBERS[normType(m.type)] : undefined;
|
|
340
|
+
if (!member || !allowed || (allowed !== '*' && !allowed.has(member)))
|
|
341
|
+
draft.reasons.push(block('static-member', `L’accès statique [${m.type ?? '?'}]::${m.member ?? '?'} n’est pas autorisé.`));
|
|
342
|
+
}
|
|
343
|
+
else if (m.call && (!member || !INSTANCE_METHODS.has(member))) {
|
|
344
|
+
draft.reasons.push(block('method-call', `L’appel de méthode .${m.member ?? '(calculée)'}() n’est pas autorisé : il échapperait à la liste blanche.`));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
for (const v of analysis.variables) {
|
|
348
|
+
const name = v.name.toLowerCase();
|
|
349
|
+
const drive = v.drive?.toLowerCase() ?? null;
|
|
350
|
+
if (FORBIDDEN_VARIABLES.has(name))
|
|
351
|
+
draft.reasons.push(block('variable', `La variable $${v.name} est réservée au moteur.`));
|
|
352
|
+
else if (drive === 'env') {
|
|
353
|
+
if (v.assigned)
|
|
354
|
+
draft.reasons.push(block('env-assign', `Modifier $${v.name} est refusé : cela pourrait changer le programme réellement lancé.`));
|
|
355
|
+
}
|
|
356
|
+
else if (drive !== null && !VARIABLE_SCOPES.has(drive)) {
|
|
357
|
+
draft.reasons.push(block('drive-variable', `La variable \${${v.name}} lit ou écrit directement dans un lecteur PowerShell : refusé.`));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// « (Get-Item x).IsReadOnly = $true » modifie un fichier sans passer par une commande d'écriture.
|
|
361
|
+
if (analysis.assignTargets.length > 0)
|
|
362
|
+
draft.reasons.push(block('member-assign', 'Affecter une propriété ou un élément d’objet est refusé : cela peut modifier le système sans passer par une commande contrôlée.'));
|
|
363
|
+
for (const r of analysis.redirections) {
|
|
364
|
+
const target = r.target;
|
|
365
|
+
if (target.k === 'v' && target.n?.toLowerCase() === 'null')
|
|
366
|
+
continue;
|
|
367
|
+
const values = constants(target);
|
|
368
|
+
if (!values) {
|
|
369
|
+
draft.reasons.push(block('dynamic-path', 'Redirection vers un fichier calculé : le chemin ne peut pas être vérifié.'));
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
draft.sensitive = true;
|
|
373
|
+
for (const v of values)
|
|
374
|
+
checkWritePath(v, 'Redirection', ctx, draft);
|
|
375
|
+
}
|
|
376
|
+
const commands = analysis.commands.map((cmd) => evaluateCommand(cmd, ctx, draft));
|
|
377
|
+
const seen = new Set();
|
|
378
|
+
const reasons = draft.reasons.filter((r) => {
|
|
379
|
+
const key = `${r.code}|${r.message}`;
|
|
380
|
+
if (seen.has(key))
|
|
381
|
+
return false;
|
|
382
|
+
seen.add(key);
|
|
383
|
+
return true;
|
|
384
|
+
});
|
|
385
|
+
const blocked = reasons.some((r) => r.severity === 'block');
|
|
386
|
+
const level = blocked ? 'denied' : draft.sensitive || commands.some((c) => c.level === 'sensitive') ? 'sensitive' : 'read';
|
|
387
|
+
return { level, reasons, commands, writes: [...new Set(draft.writes)], network: [...new Set(draft.network)] };
|
|
388
|
+
}
|
|
389
|
+
/** Modules à charger avant de passer en mode contraint (les modules de fonctions ne s'y chargent pas seuls). */
|
|
390
|
+
export function modulesToPreload(analysis) {
|
|
391
|
+
const names = new Set();
|
|
392
|
+
for (const cmd of analysis.commands) {
|
|
393
|
+
if (cmd.kind === 'Function' && cmd.module && /^[A-Za-z0-9._-]{1,80}$/.test(cmd.module))
|
|
394
|
+
names.add(cmd.module);
|
|
395
|
+
}
|
|
396
|
+
return [...names];
|
|
397
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const isPolicyMode = (value) => value === 'stricte' || value === 'souple';
|
|
2
|
+
export function decide(verdict, mode) {
|
|
3
|
+
const blocks = verdict.reasons.filter((r) => r.severity === 'block');
|
|
4
|
+
if (blocks.length === 0)
|
|
5
|
+
return { outcome: verdict.level === 'read' ? 'read' : 'sensitive', blocking: [] };
|
|
6
|
+
if (mode === 'stricte')
|
|
7
|
+
return { outcome: 'refused', blocking: blocks };
|
|
8
|
+
const floors = blocks.filter((r) => r.floor === true);
|
|
9
|
+
return floors.length > 0 ? { outcome: 'refused', blocking: floors } : { outcome: 'outside', blocking: [] };
|
|
10
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exécution réelle d'une commande PowerShell déjà validée par la politique.
|
|
3
|
+
*
|
|
4
|
+
* Le script complet (préambule, commande, épilogue) est transmis par l'entrée standard, encodé en
|
|
5
|
+
* base64 : ni fichier temporaire, ni contournement de la politique d'exécution, ni citation à échapper.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { createErrorTidier, createLineDecoder } from './decoder.js';
|
|
10
|
+
/** PATH de l'exécution : les dossiers de Windows d'abord, pour qu'un « whoami » soit bien celui de Windows. */
|
|
11
|
+
export function engineEnv(base) {
|
|
12
|
+
const root = base.SystemRoot ?? base.windir ?? 'C:\\Windows';
|
|
13
|
+
const current = base.PATH ?? base.Path ?? '';
|
|
14
|
+
const head = [`${root}\\System32`, root, `${root}\\System32\\Wbem`, `${root}\\System32\\WindowsPowerShell\\v1.0`];
|
|
15
|
+
const env = { ...base };
|
|
16
|
+
delete env.Path;
|
|
17
|
+
env.PATH = [...head, ...current.split(';').filter((p) => p && !head.some((h) => h.toLowerCase() === p.toLowerCase()))].join(';');
|
|
18
|
+
return env;
|
|
19
|
+
}
|
|
20
|
+
const b64 = (s) => Buffer.from(s, 'utf8').toString('base64');
|
|
21
|
+
/** Ligne unique envoyée sur l'entrée standard : décode le script puis l'exécute. */
|
|
22
|
+
const bootstrap = (script) => `& ([scriptblock]::Create([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${b64(script)}'))))\n`;
|
|
23
|
+
export function buildScript(request) {
|
|
24
|
+
const imports = request.modules.filter((m) => /^[A-Za-z0-9._-]{1,80}$/.test(m)).map((m) => `Import-Module -Name '${m}' -ErrorAction SilentlyContinue`);
|
|
25
|
+
return [
|
|
26
|
+
`$ProgressPreference = 'SilentlyContinue'`,
|
|
27
|
+
`$OutputEncoding = [Console]::OutputEncoding`,
|
|
28
|
+
...imports,
|
|
29
|
+
...(request.constrained ? [`$ExecutionContext.SessionState.LanguageMode = 'ConstrainedLanguage'`] : []),
|
|
30
|
+
request.command,
|
|
31
|
+
// Le code de sortie est seulement noté ici. « exit » n'est appelé qu'après la fin du bloc (voir runLines) :
|
|
32
|
+
// appelé dans le bloc, il couperait les tableaux que PowerShell met en forme à la fin du pipeline.
|
|
33
|
+
`$__ok = $?; $__code = $LASTEXITCODE`,
|
|
34
|
+
`$global:__prm_exit = 0`,
|
|
35
|
+
`if (-not $__ok) { if ($__code) { $global:__prm_exit = $__code } else { $global:__prm_exit = 1 } }`,
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
|
38
|
+
/** Lignes envoyées à PowerShell : le bloc, puis la sortie avec le code noté par le bloc. */
|
|
39
|
+
const runLines = (script) => `${bootstrap(script)}exit $global:__prm_exit\n`;
|
|
40
|
+
const POWERSHELL_ARGS = ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '-'];
|
|
41
|
+
function killTree(pid, env) {
|
|
42
|
+
if (pid === undefined)
|
|
43
|
+
return;
|
|
44
|
+
if (process.platform === 'win32') {
|
|
45
|
+
const root = env.SystemRoot ?? 'C:\\Windows';
|
|
46
|
+
spawn(path.win32.join(root, 'System32', 'taskkill.exe'), ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }).on('error', () => undefined);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(pid, 'SIGKILL');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* déjà terminé */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export class PowerShellRunner {
|
|
58
|
+
env;
|
|
59
|
+
oemTable;
|
|
60
|
+
executable;
|
|
61
|
+
constructor(options) {
|
|
62
|
+
this.env = options.env;
|
|
63
|
+
this.oemTable = options.oemTable;
|
|
64
|
+
this.executable = options.executable ?? 'powershell.exe';
|
|
65
|
+
}
|
|
66
|
+
run(request) {
|
|
67
|
+
const queue = [];
|
|
68
|
+
let wake = null;
|
|
69
|
+
let finished = false;
|
|
70
|
+
const push = (event) => {
|
|
71
|
+
queue.push(event);
|
|
72
|
+
wake?.();
|
|
73
|
+
wake = null;
|
|
74
|
+
};
|
|
75
|
+
const started = Date.now();
|
|
76
|
+
let cancelled = false;
|
|
77
|
+
let timedOut = false;
|
|
78
|
+
let truncated = false;
|
|
79
|
+
let bytes = 0;
|
|
80
|
+
const child = spawn(this.executable, POWERSHELL_ARGS, { cwd: request.cwd, env: this.env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
81
|
+
const stop = () => killTree(child.pid, this.env);
|
|
82
|
+
const timer = setTimeout(() => {
|
|
83
|
+
timedOut = true;
|
|
84
|
+
stop();
|
|
85
|
+
}, request.timeoutMs);
|
|
86
|
+
const tidy = createErrorTidier();
|
|
87
|
+
const wire = (stream, source) => {
|
|
88
|
+
const decoder = createLineDecoder(this.oemTable);
|
|
89
|
+
let partial = null;
|
|
90
|
+
const emit = (text) => {
|
|
91
|
+
if (!text || truncated)
|
|
92
|
+
return;
|
|
93
|
+
const shown = stream === 'stderr' ? tidy(text) : text;
|
|
94
|
+
if (shown)
|
|
95
|
+
push({ type: 'out', stream, text: shown });
|
|
96
|
+
};
|
|
97
|
+
source.on('data', (chunk) => {
|
|
98
|
+
bytes += chunk.length;
|
|
99
|
+
if (bytes > request.maxBytes && !truncated) {
|
|
100
|
+
emit(decoder.push(chunk));
|
|
101
|
+
truncated = true;
|
|
102
|
+
stop();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
emit(decoder.push(chunk));
|
|
106
|
+
// Une invite ou une barre de progression n'a pas de fin de ligne : on la montre après un court délai.
|
|
107
|
+
if (partial)
|
|
108
|
+
clearTimeout(partial);
|
|
109
|
+
partial = setTimeout(() => emit(decoder.flush()), 120);
|
|
110
|
+
});
|
|
111
|
+
source.on('end', () => {
|
|
112
|
+
if (partial)
|
|
113
|
+
clearTimeout(partial);
|
|
114
|
+
emit(decoder.flush());
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
wire('stdout', child.stdout);
|
|
118
|
+
wire('stderr', child.stderr);
|
|
119
|
+
const end = (exitCode) => {
|
|
120
|
+
if (finished)
|
|
121
|
+
return;
|
|
122
|
+
finished = true;
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
push({ type: 'end', exitCode: cancelled || timedOut ? null : exitCode, durationMs: Date.now() - started, cancelled, timedOut, truncated });
|
|
125
|
+
};
|
|
126
|
+
child.on('error', (err) => {
|
|
127
|
+
push({ type: 'out', stream: 'stderr', text: `PowerShell n’a pas pu démarrer : ${err.message}\n` });
|
|
128
|
+
end(null);
|
|
129
|
+
});
|
|
130
|
+
child.on('close', (code) => end(code));
|
|
131
|
+
child.stdin.on('error', () => undefined);
|
|
132
|
+
child.stdin.end(runLines(buildScript(request)));
|
|
133
|
+
const events = {
|
|
134
|
+
[Symbol.asyncIterator]: () => ({
|
|
135
|
+
next: async () => {
|
|
136
|
+
for (;;) {
|
|
137
|
+
const event = queue.shift();
|
|
138
|
+
if (event)
|
|
139
|
+
return { value: event, done: false };
|
|
140
|
+
if (finished)
|
|
141
|
+
return { value: undefined, done: true };
|
|
142
|
+
await new Promise((resolve) => {
|
|
143
|
+
wake = resolve;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
events,
|
|
151
|
+
cancel: () => {
|
|
152
|
+
if (finished)
|
|
153
|
+
return;
|
|
154
|
+
cancelled = true;
|
|
155
|
+
stop();
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Interroge PowerShell une fois au démarrage : version, page de codes, table de décodage OEM. */
|
|
161
|
+
export function probePowerShell(env, executable = 'powershell.exe', timeoutMs = 20_000) {
|
|
162
|
+
const script = [
|
|
163
|
+
`$t = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([Console]::OutputEncoding.GetString([byte[]](128..255))))`,
|
|
164
|
+
`[Console]::Out.WriteLine('PRM|' + $PSVersionTable.PSVersion.ToString() + '|' + [Console]::OutputEncoding.CodePage + '|' + $t)`,
|
|
165
|
+
].join('\n');
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const fail = (error) => resolve({ available: false, version: null, codePage: null, oemTable: null, error });
|
|
168
|
+
let out = '';
|
|
169
|
+
const child = spawn(executable, POWERSHELL_ARGS, { env, windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
170
|
+
const timer = setTimeout(() => {
|
|
171
|
+
child.kill();
|
|
172
|
+
fail('PowerShell ne répond pas.');
|
|
173
|
+
}, timeoutMs);
|
|
174
|
+
child.on('error', (err) => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
fail(`PowerShell est introuvable (${err.message}).`);
|
|
177
|
+
});
|
|
178
|
+
child.stdout.on('data', (chunk) => {
|
|
179
|
+
out += chunk.toString('latin1');
|
|
180
|
+
});
|
|
181
|
+
child.on('close', () => {
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
const line = out.split(/\r?\n/).find((l) => l.startsWith('PRM|'));
|
|
184
|
+
const [, version, codePage, table] = line?.split('|') ?? [];
|
|
185
|
+
if (!version || !codePage || !table)
|
|
186
|
+
return fail('Réponse inattendue de PowerShell.');
|
|
187
|
+
const oemTable = Buffer.from(table, 'base64').toString('utf8');
|
|
188
|
+
resolve({ available: true, version, codePage: Number(codePage), oemTable: oemTable.length === 128 ? oemTable : null, error: null });
|
|
189
|
+
});
|
|
190
|
+
child.stdin.on('error', () => undefined);
|
|
191
|
+
child.stdin.end(bootstrap(script));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Erreur applicative : un code stable, un statut HTTP, un message lisible par l'utilisateur. */
|
|
2
|
+
export class AppError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
status;
|
|
5
|
+
constructor(code, status, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'AppError';
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export const badRequest = (message, code = 'bad_request') => new AppError(code, 400, message);
|
|
13
|
+
export const notFound = (message) => new AppError('not_found', 404, message);
|
|
14
|
+
export const forbidden = (message, code = 'forbidden') => new AppError(code, 403, message);
|
|
15
|
+
/** Ramène n'importe quelle erreur à un couple code + message sûr à afficher. */
|
|
16
|
+
export function describeError(err) {
|
|
17
|
+
if (err instanceof AppError)
|
|
18
|
+
return { code: err.code, message: err.message, status: err.status };
|
|
19
|
+
if (err instanceof Error && err.name === 'AbortError')
|
|
20
|
+
return { code: 'aborted', message: 'Opération interrompue.', status: 499 };
|
|
21
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
22
|
+
return { code: 'internal', message: `Erreur interne : ${message}`, status: 500 };
|
|
23
|
+
}
|