@bluelamp/secret-exec 0.1.0 → 0.2.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/README.md CHANGED
@@ -5,7 +5,8 @@
5
5
  > 値を標準出力・標準エラー・戻り値・ファイル・コマンドの引数に出す口は、このパッケージには**ありません**。今後も作りません。
6
6
  > 値を見たい用途(表示・コピー・ファイルへの書き出し)には使えません。それは設計上の意図です。
7
7
 
8
- 金庫(password-manager)に置いた値を、**子プロセスの環境変数にだけ**入れてコマンドを実行します。終了コードは子のものをそのまま返します。
8
+ 金庫(password-manager)に置いた値を、**子プロセスの環境変数にだけ**入れてコマンドを実行します(`exec`)。終了コードは子のものをそのまま返します。
9
+ 値を会話に出さずに金庫へ**入れる**口(`put`)も同梱しています(下の「値を金庫へ入れる」)。
9
10
 
10
11
  ## 使い方
11
12
 
@@ -31,7 +32,34 @@ bluelamp-secret exec X=my-db -- sh -c 'test -n "$X"'
31
32
  - 引いたことは金庫の監査に残ります(誰が・どの秘密を)。
32
33
  - ⚠️ 子のコマンドが自分で値を印字すれば、それは出ます(`echo "$PGURL"` のように書かない)。
33
34
 
34
- ## 終了コード
35
+ ## 値を金庫へ入れる(put)
36
+
37
+ 値を**標準入力**(か `--file` のファイル)から読んで金庫へ入れます。値はコマンドの引数に書かないので、プロセス一覧・会話の転写・シェルの履歴に残りません(`password-manager` の `store_secret` は値を道具の引数で渡すため、転写に残ります)。
38
+
39
+ ```bash
40
+ <値を出すコマンド> | bluelamp-secret put <金庫の名前> [--note 文] [--username 名] [--url URL] [--file パス] [--force]
41
+
42
+ # 例: 別の道具が出した鍵をそのまま入れる(値は画面に出ない)
43
+ some-cli issue-key | bluelamp-secret put some-api --note "some-api の本番鍵"
44
+
45
+ # 例: ファイルから入れる(入れた後はファイルを消す)
46
+ bluelamp-secret put my-db --file ./db-url.txt --url https://db.example.com
47
+ ```
48
+
49
+ - 末尾の改行 1 つ(`\n` か `\r\n`)は落とします。空の値は受けません。
50
+ - 自分の金庫に**既に在る名前は `--force` が無ければ止まります**(金庫は同じ名前を黙って上書きするため)。
51
+ - **標準入力が端末なら受けません**(打ち込んだ値が画面と端末の記録に残るため)。パイプか `--file` で渡してください。
52
+ - 保存の後に**読み戻して一致を確かめ**、`保存しました(N 文字・読み戻して一致)` だけを標準エラーへ出します。値と金庫の応答本文はどこにも出しません。
53
+ - `--url` はログイン先のメモです(`https://host` に畳んで保存。scheme を省くと https を補います)。
54
+
55
+ | コード | 意味(put) |
56
+ |---|---|
57
+ | 0 | 保存して、読み戻した値が一致した |
58
+ | 1 | 未ログイン・保存できない・読み戻せない/一致しない・ファイルを読めない |
59
+ | 2 | 引数の形が違う・標準入力が端末・値が空 |
60
+ | 3 | 既に在る名前で `--force` が無い(何も保存していない) |
61
+
62
+ ## 終了コード(exec)
35
63
 
36
64
  | コード | 意味 |
37
65
  |---|---|
@@ -2,36 +2,61 @@
2
2
  /**
3
3
  * bluelamp-secret — 金庫の値を子プロセスの環境変数にだけ入れてコマンドを走らせる(箱 n=2018 設計④′)。
4
4
  * bluelamp-secret exec <ENV名>=<金庫の名前> [...] -- <コマンド> [引数…]
5
+ * <値を出すコマンド> | bluelamp-secret put <金庫の名前> [--note 文] [--username 名] [--url URL] [--file パス] [--force](箱 n=2498)
5
6
  * 🔒 値を返さない口: 値は stdout・stderr・戻り値・ファイルに出ない(README の冒頭・src/index.ts の頭注)。
6
7
  * 判定と組み立ては dist/index.js(unit テスト済み)。ここは実物の網・トークン・子プロセス・シグナルを繋ぐだけ。
7
8
  */
8
9
  import { spawn } from 'node:child_process';
10
+ import { readFile } from 'node:fs/promises';
9
11
  import { constants as osConstants } from 'node:os';
10
12
  import { tryReadCliToken } from '@bluelamp/cli-token';
11
- import { runSecretExec } from '../dist/index.js';
13
+ import { runSecretExec, runSecretPut } from '../dist/index.js';
12
14
 
13
15
  // 端末の Ctrl-C は同じプロセスグループの子にも届く=親は受け流して子の終了を待つ。
14
16
  // SIGTERM / SIGHUP は親だけに来うるので子へ回す。
15
17
  const FORWARDED = ['SIGINT', 'SIGTERM', 'SIGHUP'];
16
18
 
17
- runSecretExec(process.argv.slice(2), {
18
- env: process.env,
19
- loadToken: () => tryReadCliToken(),
20
- fetch: (url, init) => globalThis.fetch(url, init),
21
- spawn: (command, args, options) => spawn(command, args, options),
22
- stderr: (text) => process.stderr.write(text),
23
- signalNumbers: osConstants.signals,
24
- onParentSignal: (forward) => {
25
- const handlers = FORWARDED.map((sig) => {
26
- const h = () => {
27
- if (sig !== 'SIGINT') forward(sig);
28
- };
29
- process.on(sig, h);
30
- return [sig, h];
31
- });
32
- return () => handlers.forEach(([sig, h]) => process.off(sig, h));
33
- },
34
- }).then(
19
+ const argv = process.argv.slice(2);
20
+
21
+ const run =
22
+ argv[0] === 'put'
23
+ ? runSecretPut(argv, {
24
+ env: process.env,
25
+ loadToken: () => tryReadCliToken(),
26
+ fetch: (url, init) => globalThis.fetch(url, init),
27
+ stdinIsTTY: () => process.stdin.isTTY === true,
28
+ readStdin: async () => {
29
+ const chunks = [];
30
+ for await (const c of process.stdin) chunks.push(c);
31
+ return Buffer.concat(chunks).toString('utf8');
32
+ },
33
+ readFile: (p) => readFile(p, 'utf8'),
34
+ stderr: (text) => process.stderr.write(text),
35
+ })
36
+ : runExec();
37
+
38
+ function runExec() {
39
+ return runSecretExec(argv, {
40
+ env: process.env,
41
+ loadToken: () => tryReadCliToken(),
42
+ fetch: (url, init) => globalThis.fetch(url, init),
43
+ spawn: (command, args, options) => spawn(command, args, options),
44
+ stderr: (text) => process.stderr.write(text),
45
+ signalNumbers: osConstants.signals,
46
+ onParentSignal: (forward) => {
47
+ const handlers = FORWARDED.map((sig) => {
48
+ const h = () => {
49
+ if (sig !== 'SIGINT') forward(sig);
50
+ };
51
+ process.on(sig, h);
52
+ return [sig, h];
53
+ });
54
+ return () => handlers.forEach(([sig, h]) => process.off(sig, h));
55
+ },
56
+ });
57
+ }
58
+
59
+ run.then(
35
60
  (code) => process.exit(code),
36
61
  () => {
37
62
  // ⛔ 例外の文言は出さない(値や要求を引用しうる)
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @bluelamp/secret-exec — `bluelamp-secret exec`(箱 n=2018 設計④′)
2
+ * @bluelamp/secret-exec — `bluelamp-secret exec`(箱 n=2018 設計④′)と `bluelamp-secret put`(箱 n=2498・下の put の節)
3
3
  *
4
4
  * 🔒 これは「値を返さない口」である(実装より先に書いた線・README の冒頭と同じ)。
5
5
  * 取れるのは「金庫にその名前が在るか無いか」と「値を使えるか(子プロセスが env で受けて走れたか=終了コード)」だけ。
@@ -25,14 +25,21 @@ export const ENV_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
25
25
  export const DEFAULT_PORTAL_API_BASE_URL = 'https://api.motohi.jp/api/bluelamp';
26
26
  export const USAGE = '使い方: bluelamp-secret exec <ENV名>=<金庫の名前> [<ENV名>=<金庫の名前> …] -- <コマンド> [引数…]\n' +
27
27
  ' 金庫の値を子プロセスの環境変数にだけ入れて <コマンド> を実行し、その終了コードで終わります。\n' +
28
- ' 値は画面・引数・ログに出ません。シェルの展開が要るときは -- sh -c \'psql "$PGURL" -c "select 1"\' のように渡します。\n';
28
+ ' 値は画面・引数・ログに出ません。シェルの展開が要るときは -- sh -c \'psql "$PGURL" -c "select 1"\' のように渡します。\n' +
29
+ '使い方: <値を出すコマンド> | bluelamp-secret put <金庫の名前> [--note 文] [--username 名] [--url URL] [--file パス] [--force]\n' +
30
+ ' 値を標準入力(または --file のファイル)から読んで金庫へ入れ、読み戻して一致を確かめます。末尾の改行 1 つは落とします。\n' +
31
+ ' 既に在る名前は --force が無ければ止まります。標準入力が端末なら受けません(打ち込んだ値が画面に残るため)。\n';
32
+ /** `.`/`..` は URL の区間として解決され、金庫の外の経路を指してしまう(%2E も URL 側で同じに扱われる) */
33
+ function isUsableVaultName(name) {
34
+ return name !== '' && name !== '.' && name !== '..';
35
+ }
29
36
  /** 引数を解析する(`process.argv.slice(2)` を受ける)。値はここに来ない=金庫の名前だけ */
30
37
  export function parseExecArgs(argv) {
31
38
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help' || argv[0] === 'help') {
32
39
  return { ok: false, help: true, error: '' };
33
40
  }
34
41
  if (argv[0] !== 'exec') {
35
- return { ok: false, help: false, error: `不明な操作です(受けるのは exec だけ)` };
42
+ return { ok: false, help: false, error: `不明な操作です(受けるのは exec と put だけ)` };
36
43
  }
37
44
  const sep = argv.indexOf('--');
38
45
  if (sep === -1) {
@@ -62,8 +69,7 @@ export function parseExecArgs(argv) {
62
69
  if (name === '') {
63
70
  return { ok: false, help: false, error: `${env}: 金庫の名前が空です` };
64
71
  }
65
- // `.`/`..` は URL の区間として解決され、金庫の外の経路を指してしまう(%2E も URL 側で同じに扱われる)
66
- if (name === '.' || name === '..') {
72
+ if (!isUsableVaultName(name)) {
67
73
  return { ok: false, help: false, error: `${env}: 金庫の名前に . / .. は使えません` };
68
74
  }
69
75
  if (seen.has(env)) {
@@ -127,6 +133,33 @@ export function exitCodeOf(code, signal, signalNumbers) {
127
133
  return 1;
128
134
  }
129
135
  const PREFIX = 'bluelamp-secret: ';
136
+ /** 金庫の 1 件を引いて値を取り出す(⛔ 値はこのファイルの中だけで使う・export しない) */
137
+ async function fetchSecretValue(fetchImpl, baseUrl, token, name) {
138
+ let res;
139
+ try {
140
+ res = await fetchImpl(buildVaultUrl(baseUrl, name), { method: 'GET', headers: cliHeaders(token) });
141
+ }
142
+ catch {
143
+ // ⛔ 例外の文言は出さない(要求 URL・ヘッダを引用する実装がある)
144
+ return { ok: false, reason: 'ポータルに届きませんでした' };
145
+ }
146
+ if (!res.ok)
147
+ return { ok: false, reason: describeVaultStatus(res.status) };
148
+ let body;
149
+ try {
150
+ body = await res.json();
151
+ }
152
+ catch {
153
+ return { ok: false, reason: '金庫の応答を読めませんでした' };
154
+ }
155
+ const value = extractSecretValue(body);
156
+ if (value === null)
157
+ return { ok: false, reason: '金庫の応答に値がありませんでした' };
158
+ return { ok: true, value };
159
+ }
160
+ function cliHeaders(token) {
161
+ return { 'X-CLI-Token': token, Accept: 'application/json', 'User-Agent': 'bluelamp-secret/1' };
162
+ }
130
163
  /** 実行する。戻り値は終了コード */
131
164
  export async function runSecretExec(argv, deps) {
132
165
  const parsed = parseExecArgs(argv);
@@ -147,36 +180,12 @@ export async function runSecretExec(argv, deps) {
147
180
  const values = {};
148
181
  // 🔑 全部そろってから子を起こす(一部だけ入った env で走らせない)
149
182
  for (const { env, name } of parsed.bindings) {
150
- let res;
151
- try {
152
- res = await deps.fetch(buildVaultUrl(baseUrl, name), {
153
- method: 'GET',
154
- headers: { 'X-CLI-Token': token, Accept: 'application/json', 'User-Agent': 'bluelamp-secret/1' },
155
- });
156
- }
157
- catch {
158
- // ⛔ 例外の文言は出さない(要求 URL・ヘッダを引用する実装がある)
159
- deps.stderr(`${PREFIX}${env}=${name}: ポータルに届きませんでした\n`);
160
- return 1;
161
- }
162
- if (!res.ok) {
163
- deps.stderr(`${PREFIX}${env}=${name}: ${describeVaultStatus(res.status)}\n`);
164
- return 1;
165
- }
166
- let body;
167
- try {
168
- body = await res.json();
169
- }
170
- catch {
171
- deps.stderr(`${PREFIX}${env}=${name}: 金庫の応答を読めませんでした\n`);
172
- return 1;
173
- }
174
- const value = extractSecretValue(body);
175
- if (value === null) {
176
- deps.stderr(`${PREFIX}${env}=${name}: 金庫の応答に値がありませんでした\n`);
183
+ const got = await fetchSecretValue(deps.fetch, baseUrl, token, name);
184
+ if (!got.ok) {
185
+ deps.stderr(`${PREFIX}${env}=${name}: ${got.reason}\n`);
177
186
  return 1;
178
187
  }
179
- values[env] = value;
188
+ values[env] = got.value;
180
189
  }
181
190
  const childEnv = composeChildEnv(deps.env, values);
182
191
  return new Promise((resolve) => {
@@ -216,3 +225,200 @@ export async function runSecretExec(argv, deps) {
216
225
  child.on('exit', (code, signal) => finish(exitCodeOf(code, signal, deps.signalNumbers)));
217
226
  });
218
227
  }
228
+ /** 裸のホスト(scheme 省略)。backend の deriveOrigin と同じく https を補う */
229
+ const BARE_HOST = /^[A-Za-z0-9.-]+(:\d+)?$/;
230
+ /** --url を origin に畳む(http/https だけ受ける)。畳めなければ null */
231
+ export function deriveOrigin(raw) {
232
+ const trimmed = raw.trim();
233
+ const tryParse = (s) => {
234
+ try {
235
+ const u = new URL(s);
236
+ return u.protocol === 'https:' || u.protocol === 'http:' ? u.origin : null;
237
+ }
238
+ catch {
239
+ return null;
240
+ }
241
+ };
242
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed))
243
+ return tryParse(trimmed);
244
+ return BARE_HOST.test(trimmed) && trimmed.includes('.') ? tryParse(`https://${trimmed}`) : null;
245
+ }
246
+ const PUT_VALUE_OPTIONS = ['--note', '--username', '--url', '--file'];
247
+ /**
248
+ * put の引数を解析する(`process.argv.slice(2)` を受ける)。
249
+ * ⛔ 失敗の文言に、知らない引数や余分な位置引数の中身を引用しない(値を引数に書き間違えた場合に値が出るため)。
250
+ */
251
+ export function parsePutArgs(argv) {
252
+ if (argv[0] !== 'put')
253
+ return { ok: false, help: false, error: '不明な操作です(受けるのは exec と put だけ)' };
254
+ const rest = argv.slice(1);
255
+ if (rest.length === 0 || rest.includes('-h') || rest.includes('--help'))
256
+ return { ok: false, help: true, error: '' };
257
+ const given = {};
258
+ const positional = [];
259
+ let force = false;
260
+ for (let i = 0; i < rest.length; i++) {
261
+ const a = rest[i];
262
+ if (a === '--force') {
263
+ force = true;
264
+ continue;
265
+ }
266
+ const eq = a.indexOf('=');
267
+ const flag = (eq > 0 ? a.slice(0, eq) : a);
268
+ if (a.startsWith('--') && PUT_VALUE_OPTIONS.includes(flag)) {
269
+ if (given[flag] !== undefined)
270
+ return { ok: false, help: false, error: `${flag} が 2 回あります` };
271
+ const v = eq > 0 ? a.slice(eq + 1) : rest[++i];
272
+ if (v === undefined)
273
+ return { ok: false, help: false, error: `${flag} の後に中身がありません` };
274
+ given[flag] = v;
275
+ continue;
276
+ }
277
+ if (a.startsWith('-'))
278
+ return { ok: false, help: false, error: `知らないオプションがあります(${i + 2} 番目の引数)` };
279
+ positional.push(a);
280
+ }
281
+ if (positional.length !== 1) {
282
+ return {
283
+ ok: false,
284
+ help: false,
285
+ error: positional.length === 0
286
+ ? '金庫の名前を書いてください'
287
+ : '金庫の名前の後に余分な引数があります(値は引数でなく標準入力で渡してください)',
288
+ };
289
+ }
290
+ const name = positional[0];
291
+ if (!isUsableVaultName(name))
292
+ return { ok: false, help: false, error: '金庫の名前に . / .. は使えません' };
293
+ let origin;
294
+ if (given['--url'] !== undefined) {
295
+ const o = deriveOrigin(given['--url']);
296
+ if (o === null)
297
+ return { ok: false, help: false, error: '--url を解釈できません(https://host の形で書いてください)' };
298
+ origin = o;
299
+ }
300
+ if (given['--file'] === '')
301
+ return { ok: false, help: false, error: '--file のパスが空です' };
302
+ return {
303
+ ok: true,
304
+ options: {
305
+ name,
306
+ note: given['--note'],
307
+ username: given['--username'],
308
+ origin,
309
+ file: given['--file'],
310
+ force,
311
+ },
312
+ };
313
+ }
314
+ /** 末尾の改行 1 つ(\n か \r\n)だけを落とす(⛔ 値を返すので export しない) */
315
+ function dropOneTrailingNewline(raw) {
316
+ if (raw.endsWith('\r\n'))
317
+ return raw.slice(0, -2);
318
+ if (raw.endsWith('\n'))
319
+ return raw.slice(0, -1);
320
+ return raw;
321
+ }
322
+ /** 一覧の応答に「自分の金庫の同じ名前」が在るか。形が違えば null(⛔ 一覧の中身は出さない) */
323
+ function ownEntryExists(body, name) {
324
+ if (!body || typeof body !== 'object')
325
+ return null;
326
+ const b = body;
327
+ if (b.success !== true || !Array.isArray(b.data))
328
+ return null;
329
+ return b.data.some((e) => !!e && typeof e === 'object' && e.service === name && e.shared !== true);
330
+ }
331
+ /** put を実行する。戻り値は終了コード(0 保存して一致/1 失敗/2 引数・入力の形/3 既に在る) */
332
+ export async function runSecretPut(argv, deps) {
333
+ const parsed = parsePutArgs(argv);
334
+ if (!parsed.ok) {
335
+ if (parsed.help) {
336
+ deps.stderr(USAGE);
337
+ return argv.length <= 1 ? 2 : 0;
338
+ }
339
+ deps.stderr(`${PREFIX}${parsed.error}\n${USAGE}`);
340
+ return 2;
341
+ }
342
+ const { name, note, username, origin, file, force } = parsed.options;
343
+ // 🔑 端末の打ち込みは画面と端末の記録に残る=受けない(--file のときは標準入力を読まない)
344
+ if (file === undefined && deps.stdinIsTTY()) {
345
+ deps.stderr(`${PREFIX}標準入力が端末です。値はパイプか --file で渡してください(例: <値を出すコマンド> | bluelamp-secret put ${name})\n`);
346
+ return 2;
347
+ }
348
+ const token = deps.loadToken();
349
+ if (!token) {
350
+ deps.stderr(`${PREFIX}未ログインです(bluelamp1 などでログインしてから撃ってください)\n`);
351
+ return 1;
352
+ }
353
+ let raw;
354
+ try {
355
+ raw = file === undefined ? await deps.readStdin() : await deps.readFile(file);
356
+ }
357
+ catch (e) {
358
+ const code = e?.code;
359
+ deps.stderr(`${PREFIX}${file === undefined ? '標準入力' : '--file のファイル'}を読めませんでした${code ? `(${code})` : ''}\n`);
360
+ return 1;
361
+ }
362
+ const value = dropOneTrailingNewline(raw);
363
+ if (value === '') {
364
+ deps.stderr(`${PREFIX}値が空です(何も保存していません)\n`);
365
+ return 2;
366
+ }
367
+ const baseUrl = resolvePortalApiBaseUrl(deps.env);
368
+ if (!force) {
369
+ let exists = null;
370
+ try {
371
+ const res = await deps.fetch(`${baseUrl}/vault/list`, { method: 'GET', headers: cliHeaders(token) });
372
+ if (!res.ok) {
373
+ deps.stderr(`${PREFIX}${name}: 既に在るかを確かめられませんでした(${describeVaultStatus(res.status)})\n`);
374
+ return 1;
375
+ }
376
+ exists = ownEntryExists(await res.json(), name);
377
+ }
378
+ catch {
379
+ exists = null;
380
+ }
381
+ if (exists === null) {
382
+ deps.stderr(`${PREFIX}${name}: 既に在るかを確かめられませんでした(何も保存していません)\n`);
383
+ return 1;
384
+ }
385
+ if (exists) {
386
+ deps.stderr(`${PREFIX}${name}: 金庫に既に在ります。上書きするなら --force を付けてください(何も保存していません)\n`);
387
+ return 3;
388
+ }
389
+ }
390
+ const payload = { service: name, secret: value };
391
+ if (note !== undefined)
392
+ payload.note = note;
393
+ if (username !== undefined)
394
+ payload.username = username;
395
+ if (origin !== undefined)
396
+ payload.bound_origins = [origin];
397
+ try {
398
+ const res = await deps.fetch(`${baseUrl}/vault`, {
399
+ method: 'POST',
400
+ headers: { ...cliHeaders(token), 'Content-Type': 'application/json' },
401
+ body: JSON.stringify(payload),
402
+ });
403
+ // ⛔ 本文は読まない・出さない
404
+ if (!res.ok) {
405
+ deps.stderr(`${PREFIX}${name}: 保存できませんでした(${describeVaultStatus(res.status)})\n`);
406
+ return 1;
407
+ }
408
+ }
409
+ catch {
410
+ deps.stderr(`${PREFIX}${name}: ポータルに届きませんでした(保存されたかは不明です。もう一度撃つときは --force を付けてください)\n`);
411
+ return 1;
412
+ }
413
+ const back = await fetchSecretValue(deps.fetch, baseUrl, token, name);
414
+ if (!back.ok) {
415
+ deps.stderr(`${PREFIX}${name}: 保存の後に読み戻せませんでした(${back.reason})\n`);
416
+ return 1;
417
+ }
418
+ if (back.value !== value) {
419
+ deps.stderr(`${PREFIX}${name}: 読み戻した値が入れた値と一致しません\n`);
420
+ return 1;
421
+ }
422
+ deps.stderr(`${PREFIX}${name}: 保存しました(${value.length} 文字・読み戻して一致)\n`);
423
+ return 0;
424
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bluelamp/secret-exec",
3
- "version": "0.1.0",
4
- "description": "金庫の値を子プロセスの環境変数にだけ入れてコマンドを実行する(値を返さない口)",
3
+ "version": "0.2.0",
4
+ "description": "金庫の値を子プロセスの環境変数にだけ入れてコマンドを実行し(exec)、標準入力から金庫へ入れる(put)。値を画面・引数・ログに出さない口",
5
5
  "keywords": [
6
6
  "bluelamp",
7
7
  "cli",