@elding/cli 0.8.0 → 0.8.1

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.
@@ -35,28 +35,28 @@ async function init() {
35
35
  console.log(chalk.yellow("Aucun set trouvé. Créez-en un sur le vault."));
36
36
  process.exit(0);
37
37
  }
38
- // Étape 1 : choisir l'organisation (sauf s'il n'y en a qu'une)
38
+ // Étape 1 : choisir l'organisation (toujours, pour une approche projet cohérente)
39
39
  const orgs = [...new Map(sets.map((s) => [s.workspaceId, s.workspaceName])).entries()];
40
- let workspaceId = orgs[0][0];
41
- if (orgs.length > 1) {
42
- const ans = await inquirer.prompt([
43
- {
44
- type: "list",
45
- name: "workspaceId",
46
- message: "Quelle organisation ?",
47
- choices: orgs.map(([id, name]) => ({ name: (0, terminal_js_1.safeText)(name), value: id })),
48
- },
49
- ]);
50
- workspaceId = ans.workspaceId;
51
- }
52
- // Étape 2 : choisir le set parmi ceux de l'org
40
+ const { workspaceId } = await inquirer.prompt([
41
+ {
42
+ type: "select",
43
+ name: "workspaceId",
44
+ message: "Quelle organisation ?",
45
+ choices: orgs.map(([id, name]) => ({ name: (0, terminal_js_1.safeText)(name), value: id })),
46
+ },
47
+ ]);
48
+ // Étape 2 : choisir le set parmi ceux de l'org (env affiché en français)
49
+ const ENV_LABELS = { DEV: "Dev", STAGING: "Staging", PROD: "Prod" };
53
50
  const orgSets = sets.filter((s) => s.workspaceId === workspaceId);
54
51
  const { setId } = await inquirer.prompt([
55
52
  {
56
- type: "list",
53
+ type: "select",
57
54
  name: "setId",
58
55
  message: "Quel set utiliser pour ce projet ?",
59
- choices: orgSets.map((s) => ({ name: (0, terminal_js_1.safeText)(s.name), value: s.id })),
56
+ choices: orgSets.map((s) => {
57
+ const env = s.environment ? ENV_LABELS[s.environment] : undefined;
58
+ return { name: env ? `${(0, terminal_js_1.safeText)(s.name)} (${env})` : (0, terminal_js_1.safeText)(s.name), value: s.id };
59
+ }),
60
60
  },
61
61
  ]);
62
62
  const selected = orgSets.find((s) => s.id === setId);
@@ -69,5 +69,4 @@ async function init() {
69
69
  (0, config_js_1.writeProject)(project);
70
70
  (0, config_js_1.trustProject)(project);
71
71
  console.log(chalk.green(`✓ Set "${(0, terminal_js_1.safeText)(selected.name)}" (${(0, terminal_js_1.safeText)(selected.workspaceName)}) configuré dans .elding.json`));
72
- console.log(chalk.dim("Ajoutez .elding.json à votre .gitignore si besoin."));
73
72
  }
@@ -42,8 +42,19 @@ async function login() {
42
42
  "Referrer-Policy": "no-referrer",
43
43
  "Cache-Control": "no-store",
44
44
  });
45
- res.end(`<!DOCTYPE html><html><head><meta charset="utf-8"><script>history.replaceState(null,"","/")</script></head><body style="font-family:sans-serif;padding:2rem">
46
- <p><strong>Authentification réussie.</strong> Vous pouvez fermer cet onglet.</p>
45
+ res.end(`<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8"><title>Elding</title><script>history.replaceState(null,"","/")</script><style>
46
+ *{margin:0;padding:0;box-sizing:border-box}
47
+ html,body{height:100%}
48
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#fff;color:#000;display:flex;align-items:center;justify-content:center;text-align:center}
49
+ .wrap{display:flex;flex-direction:column;align-items:center;gap:.75rem;padding:2rem}
50
+ h1{font-size:2.25rem;font-weight:600;letter-spacing:-.02em}
51
+ p{font-size:1rem;color:#6b7280}
52
+ </style></head><body>
53
+ <div class="wrap">
54
+ <h1>Tout est prêt, codez l'esprit tranquille</h1>
55
+ <p>Vous êtes connecté à Elding.</p>
56
+ <p>Vous pouvez maintenant fermer cette fenêtre.</p>
57
+ </div>
47
58
  </body></html>`);
48
59
  clearTimeout(timeout);
49
60
  server.close();
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ const program = new commander_1.Command();
18
18
  program
19
19
  .name("elding")
20
20
  .description("Elding CLI — secrets depuis le vault, zéro .env")
21
- .version("0.3.0");
21
+ .version("0.8.1");
22
22
  program
23
23
  .command("login")
24
24
  .description("Authentifier le CLI via le navigateur")
package/dist/lib/api.d.ts CHANGED
@@ -2,6 +2,7 @@ export declare const BASE_URL: string;
2
2
  export type ApiKeySetItem = {
3
3
  id: string;
4
4
  name: string;
5
+ environment?: string;
5
6
  workspaceId: string;
6
7
  workspaceName: string;
7
8
  };
@@ -7,8 +7,11 @@ exports.startProxy = startProxy;
7
7
  const http_1 = __importDefault(require("http"));
8
8
  const https_1 = __importDefault(require("https"));
9
9
  const net_1 = __importDefault(require("net"));
10
+ const stream_1 = require("stream");
10
11
  const dns_1 = require("dns");
11
12
  const crypto_1 = require("crypto");
13
+ const zlib_1 = require("zlib");
14
+ const redact_js_1 = require("./redact.js");
12
15
  const PLACEHOLDER = /\{\{([A-Z0-9_]+)\}\}/g;
13
16
  const PROXY_TIMEOUT_MS = 60_000;
14
17
  const MAX_HOST_LENGTH = 253;
@@ -152,6 +155,8 @@ function startProxy(secrets, hosts = {}, verbose = false, onLog) {
152
155
  return null;
153
156
  };
154
157
  const substitute = (val) => val.replace(PLACEHOLDER, (_, name) => secrets[name] ?? "");
158
+ // Valeurs de secret a effacer des reponses (anti-echo).
159
+ const secretValues = Object.values(secrets).filter((v) => typeof v === "string" && v.length >= 8);
155
160
  const server = http_1.default.createServer(async (req, res) => {
156
161
  const started = Date.now();
157
162
  try {
@@ -218,6 +223,8 @@ function startProxy(secrets, hosts = {}, verbose = false, onLog) {
218
223
  continue;
219
224
  if (HOP_BY_HOP_HEADERS.has(lk))
220
225
  continue;
226
+ if (lk === "accept-encoding")
227
+ continue; // force identite : reponse en clair pour la redaction
221
228
  if (typeof v === "string") {
222
229
  rawHeaders[k] = v;
223
230
  headers[k] = substitute(v);
@@ -227,10 +234,13 @@ function startProxy(secrets, hosts = {}, verbose = false, onLog) {
227
234
  headers[k] = v.map(substitute);
228
235
  }
229
236
  }
237
+ // Empeche l'upstream de compresser : sinon impossible de rediger les secrets reflechis.
238
+ if (secretValues.length > 0)
239
+ headers["accept-encoding"] = "identity";
230
240
  // Streame le corps vers l'upstream sans le bufferiser en mémoire (évite un DoS sur gros upload)
231
241
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
232
242
  const used = placeholdersIn(rawHeaders);
233
- const status = await forwardHttps(req, res, upstream, headers, pinned, hasBody);
243
+ const status = await forwardHttps(req, res, upstream, headers, pinned, hasBody, secretValues);
234
244
  log(req.method ?? "?", targetHost, upstream.pathname, status, Date.now() - started, used, used);
235
245
  }
236
246
  catch {
@@ -261,16 +271,53 @@ function normalizeAllowedHost(value) {
261
271
  return null;
262
272
  }
263
273
  }
264
- function filterResponseHeaders(headers) {
274
+ function filterResponseHeaders(headers, secretValues, decodedBody) {
265
275
  const out = {};
276
+ const redacting = secretValues.length > 0;
266
277
  for (const [name, value] of Object.entries(headers)) {
267
- if (HOP_BY_HOP_HEADERS.has(name.toLowerCase()))
278
+ const lower = name.toLowerCase();
279
+ if (HOP_BY_HOP_HEADERS.has(lower))
268
280
  continue;
269
- out[name] = value;
281
+ if (decodedBody && lower === "content-encoding")
282
+ continue;
283
+ if ((redacting || decodedBody) && (lower === "content-md5" || lower === "digest" || lower === "etag"))
284
+ continue;
285
+ // La redaction modifie la taille du corps : on laisse Node re-calculer (chunked).
286
+ if (redacting && lower === "content-length")
287
+ continue;
288
+ if (typeof value === "string")
289
+ out[name] = (0, redact_js_1.redactString)(value, secretValues);
290
+ else if (Array.isArray(value))
291
+ out[name] = value.map((v) => (0, redact_js_1.redactString)(v, secretValues));
292
+ else
293
+ out[name] = value;
270
294
  }
271
295
  return out;
272
296
  }
273
- function forwardHttps(req, res, upstream, headers, pinned, hasBody) {
297
+ function responseBodyForRedaction(upstreamRes, shouldRedact) {
298
+ const raw = upstreamRes.headers["content-encoding"];
299
+ const encodings = (Array.isArray(raw) ? raw.join(",") : raw ?? "")
300
+ .toLowerCase()
301
+ .split(",")
302
+ .map((v) => v.trim())
303
+ .filter((v) => v && v !== "identity");
304
+ if (!shouldRedact || encodings.length === 0)
305
+ return { stream: upstreamRes, decoded: false };
306
+ if (encodings.length !== 1)
307
+ return null;
308
+ switch (encodings[0]) {
309
+ case "gzip":
310
+ case "x-gzip":
311
+ return { stream: upstreamRes.pipe((0, zlib_1.createGunzip)()), decoded: true };
312
+ case "deflate":
313
+ return { stream: upstreamRes.pipe((0, zlib_1.createInflate)()), decoded: true };
314
+ case "br":
315
+ return { stream: upstreamRes.pipe((0, zlib_1.createBrotliDecompress)()), decoded: true };
316
+ default:
317
+ return null;
318
+ }
319
+ }
320
+ function forwardHttps(req, res, upstream, headers, pinned, hasBody, secretValues) {
274
321
  return new Promise((resolve, reject) => {
275
322
  const upstreamHostname = normalizeHostname(upstream.hostname);
276
323
  const upstreamReq = https_1.default.request({
@@ -282,15 +329,32 @@ function forwardHttps(req, res, upstream, headers, pinned, hasBody) {
282
329
  headers: { ...headers, host: upstream.host },
283
330
  servername: net_1.default.isIP(upstreamHostname) ? undefined : upstreamHostname,
284
331
  timeout: PROXY_TIMEOUT_MS,
285
- lookup: (_hostname, _options, callback) => {
286
- callback(null, pinned.address, pinned.family);
332
+ // Node >=20 (autoSelectFamily) appelle lookup avec { all: true } et
333
+ // attend un tableau ; sinon la forme simple (address, family).
334
+ lookup: (_hostname, options, callback) => {
335
+ if (options && options.all)
336
+ callback(null, [{ address: pinned.address, family: pinned.family }]);
337
+ else
338
+ callback(null, pinned.address, pinned.family);
287
339
  },
288
340
  }, (upstreamRes) => {
289
341
  const status = upstreamRes.statusCode ?? 502;
290
- res.writeHead(status, filterResponseHeaders(upstreamRes.headers));
291
- upstreamRes.pipe(res);
292
- upstreamRes.on("end", () => resolve(status));
293
- upstreamRes.on("error", reject);
342
+ const body = responseBodyForRedaction(upstreamRes, secretValues.length > 0);
343
+ if (!body) {
344
+ upstreamRes.resume();
345
+ res.writeHead(502);
346
+ res.end("unsupported compressed response");
347
+ resolve(502);
348
+ return;
349
+ }
350
+ res.writeHead(status, filterResponseHeaders(upstreamRes.headers, secretValues, body.decoded));
351
+ const redactor = (0, redact_js_1.createRedactor)(secretValues);
352
+ (0, stream_1.pipeline)(body.stream, redactor, res, (err) => {
353
+ if (err)
354
+ reject(err);
355
+ else
356
+ resolve(status);
357
+ });
294
358
  });
295
359
  upstreamReq.on("timeout", () => upstreamReq.destroy(new Error("upstream timeout")));
296
360
  upstreamReq.on("error", reject);
@@ -0,0 +1,3 @@
1
+ import { Transform } from "stream";
2
+ export declare function createRedactor(secretValues: string[]): Transform;
3
+ export declare function redactString(value: string, secretValues: string[]): string;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createRedactor = createRedactor;
4
+ exports.redactString = redactString;
5
+ const stream_1 = require("stream");
6
+ const REDACTED = Buffer.from("[REDACTED]");
7
+ // Remplace toutes les occurrences de `search` dans `buf` (recherche binaire, ne
8
+ // corrompt pas les corps non-texte : sans correspondance, passthrough inchange).
9
+ function replaceAll(buf, search) {
10
+ const parts = [];
11
+ let idx = 0;
12
+ let found = buf.indexOf(search, idx);
13
+ if (found === -1)
14
+ return buf;
15
+ while (found !== -1) {
16
+ parts.push(buf.subarray(idx, found), REDACTED);
17
+ idx = found + search.length;
18
+ found = buf.indexOf(search, idx);
19
+ }
20
+ parts.push(buf.subarray(idx));
21
+ return Buffer.concat(parts);
22
+ }
23
+ // Transform qui efface les valeurs de secret du flux de reponse avant qu'il
24
+ // atteigne l'app. Tue le cas "domaine qui echo la cle". Gere les coupures de
25
+ // chunk en retenant les (maxLen-1) derniers octets entre deux passes.
26
+ function createRedactor(secretValues) {
27
+ const values = [...new Set(secretValues)]
28
+ .filter((v) => v.length >= 8)
29
+ .map((v) => Buffer.from(v, "utf8"));
30
+ const maxLen = values.reduce((m, v) => Math.max(m, v.length), 0);
31
+ let carry = Buffer.alloc(0);
32
+ const scrub = (b) => {
33
+ for (const v of values)
34
+ b = replaceAll(b, v);
35
+ return b;
36
+ };
37
+ return new stream_1.Transform({
38
+ transform(chunk, _enc, cb) {
39
+ if (values.length === 0)
40
+ return cb(null, chunk);
41
+ const buf = scrub(Buffer.concat([carry, chunk]));
42
+ const hold = Math.min(buf.length, maxLen - 1);
43
+ carry = Buffer.from(buf.subarray(buf.length - hold));
44
+ cb(null, buf.subarray(0, buf.length - hold));
45
+ },
46
+ flush(cb) {
47
+ cb(null, carry);
48
+ },
49
+ });
50
+ }
51
+ // Rediger aussi les valeurs de secret dans une chaine (entetes de reponse).
52
+ function redactString(value, secretValues) {
53
+ let out = value;
54
+ for (const v of secretValues)
55
+ if (v.length >= 8)
56
+ out = out.split(v).join("[REDACTED]");
57
+ return out;
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elding/cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Elding CLI — zero .env, secrets from vault",
5
5
  "bin": {
6
6
  "elding": "dist/index.js"