i-repo 2.15.0 → 2.16.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/dist/commands/deliver/orchestrator.js +60 -12
- package/dist/commands/deliver/spec.d.ts +7 -0
- package/dist/commands/deliver/spec.js +14 -0
- package/dist/plugins/capture.d.ts +7 -0
- package/dist/plugins/capture.js +13 -1
- package/dist/plugins/dispatch.d.ts +1 -0
- package/dist/plugins/dispatch.js +20 -0
- package/package.json +1 -1
- package/spec/gemba-adc/deliver.schema.json +5 -0
- package/spec/gemba-adc/spec.md +10 -0
|
@@ -44,6 +44,13 @@ function dryRunArgs(dryRun) {
|
|
|
44
44
|
async function runSink(sink, archivePath, ctx, resolver) {
|
|
45
45
|
const base = { id: sink.id, plugin: sink.plugin, kind: sink.kind, skipped: false, receipts: [] };
|
|
46
46
|
const extraArgs = sink.args ?? [];
|
|
47
|
+
// per-sink secret env(#94): deliver プロセスの env から名指しの変数だけを取り出し、
|
|
48
|
+
// この sink のプロセスにだけ注入する(argv 非掲載・他 sink へ漏れない)。
|
|
49
|
+
const extraEnv = resolveSecretEnv(sink.secretEnv);
|
|
50
|
+
// 転送した secret の**値**を receipt から伏せる(#94/#5)。プラグインが受領 secret を
|
|
51
|
+
// receipt へ echo しても、deliver-run/pluginReceipts に平文の鍵が残らないようにする。
|
|
52
|
+
const secretValues = Object.values(extraEnv ?? {});
|
|
53
|
+
const redact = (receipts) => redactSecretValues(receipts, secretValues);
|
|
47
54
|
if (sink.kind === "stream") {
|
|
48
55
|
const manifest = join(archivePath, "manifests", "reports.ndjson");
|
|
49
56
|
let stdin = createReadStream(manifest);
|
|
@@ -51,33 +58,48 @@ async function runSink(sink, archivePath, ctx, resolver) {
|
|
|
51
58
|
// resolver は orchestrator が bundle 着地後に組む(実パス or null)。
|
|
52
59
|
stdin = stdin.pipe(createLocatorEnrich(resolver ?? (() => null)));
|
|
53
60
|
}
|
|
54
|
-
const cap = await capturePlugin(sink.plugin, [...extraArgs], ctx, { stdin });
|
|
61
|
+
const cap = await capturePlugin(sink.plugin, [...extraArgs], ctx, { stdin, extraEnv });
|
|
55
62
|
if (!cap)
|
|
56
63
|
return { ...base, failure: `plugin "i-repo-${sink.plugin}" not found` };
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
code: cap.code,
|
|
60
|
-
receipts: cap.receipts,
|
|
61
|
-
...(sinkFailure(cap.code, cap.receipts)),
|
|
62
|
-
};
|
|
64
|
+
const receipts = redact(cap.receipts);
|
|
65
|
+
return { ...base, code: cap.code, receipts, ...(sinkFailure(cap.code, receipts)) };
|
|
63
66
|
}
|
|
64
67
|
// bundle sink: archive dir を位置引数で渡す(stdin 無し)。
|
|
65
|
-
const cap = await capturePlugin(sink.plugin, [sink.subcommand, archivePath, ...extraArgs], ctx);
|
|
68
|
+
const cap = await capturePlugin(sink.plugin, [sink.subcommand, archivePath, ...extraArgs], ctx, { extraEnv });
|
|
66
69
|
if (!cap)
|
|
67
70
|
return { ...base, failure: `plugin "i-repo-${sink.plugin}" not found` };
|
|
71
|
+
const receipts = redact(cap.receipts);
|
|
68
72
|
// bundle sink の receipt が焼いた canonical な store location(dest)/ storeKind を
|
|
69
73
|
// sink へ載せる(#89)。DC が catalog の dataset identity・store.location に使うため、
|
|
70
74
|
// **delivered(verify 成功)した sink のみ**に載せる — 失敗 sink の receipt も
|
|
71
75
|
// dest を持つが、未着地の宛先を identity として広告すると DC が phantom dataset を作る。
|
|
72
|
-
const verifiedOk = receiptVerifiedOk(
|
|
76
|
+
const verifiedOk = receiptVerifiedOk(receipts);
|
|
73
77
|
return {
|
|
74
78
|
...base,
|
|
75
79
|
code: cap.code,
|
|
76
|
-
receipts
|
|
77
|
-
...(verifiedOk ? sinkDestFromReceipts(
|
|
78
|
-
...(sinkFailure(cap.code,
|
|
80
|
+
receipts,
|
|
81
|
+
...(verifiedOk ? sinkDestFromReceipts(receipts) : {}),
|
|
82
|
+
...(sinkFailure(cap.code, receipts)),
|
|
79
83
|
};
|
|
80
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* 転送 secret の値を receipt 群から伏せる(部分一致も置換・#94)。
|
|
87
|
+
* 短い値は部分一致で**無関係なフィールド(canonical dest 等)を巻き込んで壊す**ため除外する
|
|
88
|
+
* (host が AWS_REGION 等を誤って secretEnv に入れた場合の collision を防ぐ)。実クラウド資格情報は
|
|
89
|
+
* 十分長い(AWS secret key=40・access key id=20・GCP creds=パス)ので閾値で取りこぼさない。
|
|
90
|
+
*/
|
|
91
|
+
const SECRET_REDACT_MIN_LEN = 8;
|
|
92
|
+
function redactSecretValues(receipts, values) {
|
|
93
|
+
const secrets = values.filter((v) => typeof v === "string" && v.length >= SECRET_REDACT_MIN_LEN);
|
|
94
|
+
if (!secrets.length)
|
|
95
|
+
return receipts;
|
|
96
|
+
const scrub = (s) => secrets.reduce((acc, sec) => acc.split(sec).join("***"), s);
|
|
97
|
+
const walk = (x) => typeof x === "string" ? scrub(x)
|
|
98
|
+
: Array.isArray(x) ? x.map(walk)
|
|
99
|
+
: x && typeof x === "object" ? Object.fromEntries(Object.entries(x).map(([k, v]) => [k, walk(v)]))
|
|
100
|
+
: x;
|
|
101
|
+
return receipts.map((r) => walk(r));
|
|
102
|
+
}
|
|
81
103
|
/** push receipt が焼いた canonical `dest` / `storeKind` を拾う(#89・bundle sink)。 */
|
|
82
104
|
function sinkDestFromReceipts(receipts) {
|
|
83
105
|
const out = {};
|
|
@@ -99,6 +121,32 @@ function locatorSourceId(sink, bundleIds) {
|
|
|
99
121
|
return sink.locatorFrom;
|
|
100
122
|
return bundleIds.length === 1 ? bundleIds[0] : undefined;
|
|
101
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* sink.secretEnv(env 名リスト)を deliver プロセスの env から解決する(#94)。
|
|
126
|
+
* 値は host が deliver プロセスへ設定したもの。spec に値は持たせない(disk 非掲載)。
|
|
127
|
+
* 未設定の名前は黙って飛ばす(host が用意していない=その sink は認証できず失敗するだけ)。
|
|
128
|
+
*
|
|
129
|
+
* 照合は **大文字小文字を無視**する(dispatch の env 取り扱い=parsePassEnv が env 名を
|
|
130
|
+
* 大文字化して照合するのに合わせる)。POSIX env は本来 case-sensitive だが資格情報は慣習的に
|
|
131
|
+
* 大文字(AWS_* や GOOGLE_… 等)で、spec 著者の lowercase 取り違えが**無診断で認証失敗**するのを防ぐ。
|
|
132
|
+
* 転送は **実在 env キー名**で行う(SDK が読む正準名を保つ)。
|
|
133
|
+
*/
|
|
134
|
+
function resolveSecretEnv(names) {
|
|
135
|
+
if (!names || names.length === 0)
|
|
136
|
+
return undefined;
|
|
137
|
+
const index = new Map(); // UPPER(name) -> [realName, value]
|
|
138
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
139
|
+
if (typeof v === "string")
|
|
140
|
+
index.set(k.toUpperCase(), [k, v]);
|
|
141
|
+
}
|
|
142
|
+
const out = {};
|
|
143
|
+
for (const n of names) {
|
|
144
|
+
const hit = index.get(n.toUpperCase());
|
|
145
|
+
if (hit)
|
|
146
|
+
out[hit[0]] = hit[1];
|
|
147
|
+
}
|
|
148
|
+
return Object.keys(out).length ? out : undefined;
|
|
149
|
+
}
|
|
102
150
|
function sinkFailure(code, receipts) {
|
|
103
151
|
if (code !== 0)
|
|
104
152
|
return { failure: `exit ${code}` };
|
|
@@ -13,6 +13,13 @@ export interface SinkSpec {
|
|
|
13
13
|
/** locator をどの bundle sink から組むか(bundle が複数のとき必須)。 */
|
|
14
14
|
locatorFrom?: string;
|
|
15
15
|
skipIfDelivered?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* この sink のプロセスへ転送する env 変数名のリスト(#94)。値は deliver プロセスの
|
|
18
|
+
* env(host が設定)から取り、この sink だけに注入する(argv 非掲載・他 sink へ漏れない)。
|
|
19
|
+
* 無人クラウド認証(AWS_* や GOOGLE_APPLICATION_CREDENTIALS 等)を fan-out 経由で渡す用。
|
|
20
|
+
* プラグインの envVar 宣言に依存しない(任意の第三者プラグインで動く)。
|
|
21
|
+
*/
|
|
22
|
+
secretEnv?: string[];
|
|
16
23
|
}
|
|
17
24
|
export interface ArchiveSpec {
|
|
18
25
|
plugin: string;
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import { ValidationError } from "../../core/errors.js";
|
|
10
|
+
import { isForwardableSecretEnvName } from "../../plugins/dispatch.js";
|
|
11
|
+
/** env 変数名として妥当か(POSIX 風・注入/取り違え防止)。 */
|
|
12
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
10
13
|
function isObject(v) {
|
|
11
14
|
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
12
15
|
}
|
|
@@ -73,6 +76,17 @@ export function validateDeliverSpec(value) {
|
|
|
73
76
|
bundleIds.add(s.id);
|
|
74
77
|
}
|
|
75
78
|
}
|
|
79
|
+
if ("secretEnv" in s) {
|
|
80
|
+
if (!Array.isArray(s.secretEnv) || !s.secretEnv.every((n) => typeof n === "string" && ENV_NAME_RE.test(n))) {
|
|
81
|
+
p.push(`sinks[${i}].secretEnv must be an array of env var names ([A-Za-z_][A-Za-z0-9_]*)`);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// contract 所有(IREPO_*)・ローダ/実行系乗っ取り(LD_*/DYLD_*/PATH/NODE_OPTIONS 等)は禁止。
|
|
85
|
+
const bad = s.secretEnv.filter((n) => !isForwardableSecretEnvName(n));
|
|
86
|
+
if (bad.length)
|
|
87
|
+
p.push(`sinks[${i}].secretEnv must not include protected/dangerous names: ${bad.join(", ")}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
76
90
|
});
|
|
77
91
|
// locatorFrom の整合(§9.4): enrichLocator の stream sink が複数 bundle 下で
|
|
78
92
|
// どの bundle を使うか曖昧にしない。順序依存の暗黙選択は禁止。
|
|
@@ -10,6 +10,13 @@ export interface CaptureOptions {
|
|
|
10
10
|
timeoutSec?: number;
|
|
11
11
|
/** Run-level abort — on abort the child is SIGTERM'd (then SIGKILL after grace). */
|
|
12
12
|
signal?: AbortSignal;
|
|
13
|
+
/**
|
|
14
|
+
* Extra env merged onto the allow-listed plugin env, AFTER buildPluginEnv
|
|
15
|
+
* (so it can re-add vars the allow-list would otherwise scrub). Used by
|
|
16
|
+
* `deliver` to forward per-sink secret env (gemba-deliver `secretEnv`, #94)
|
|
17
|
+
* into this sink's process only — never into argv, never to sibling sinks.
|
|
18
|
+
*/
|
|
19
|
+
extraEnv?: Record<string, string>;
|
|
13
20
|
}
|
|
14
21
|
export interface CaptureResult {
|
|
15
22
|
/** Exit code; signal deaths map to 128+n (SIGNUM), matching dispatchPlugin. */
|
package/dist/plugins/capture.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* identically whether invoked directly or under deliver.
|
|
16
16
|
*/
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
|
-
import { buildPluginEnv, prepareSpawn, GRACE_MS, SIGNUM, } from "./dispatch.js";
|
|
18
|
+
import { buildPluginEnv, isForwardableSecretEnvName, prepareSpawn, GRACE_MS, SIGNUM, } from "./dispatch.js";
|
|
19
19
|
import { parseLines } from "./verify.js";
|
|
20
20
|
/**
|
|
21
21
|
* Run a plugin capturing its stdout. Returns null when no `i-repo-<name>`
|
|
@@ -32,6 +32,18 @@ export async function capturePlugin(name, args, ctx, opts = {}) {
|
|
|
32
32
|
}
|
|
33
33
|
const { spawnBin, spawnArgs, windowsVerbatim } = prep.plan;
|
|
34
34
|
const env = buildPluginEnv(ctx);
|
|
35
|
+
// per-sink の追加 env(#94)。allow-list の後に重ねる=この sink のプロセス限定で
|
|
36
|
+
// host 指定の secret env を復活させる(他 sink・argv には出ない)。ただし
|
|
37
|
+
// **buildPluginEnv の trust 境界は迂回させない**: 契約所有(IREPO_*)・ローダ/実行系
|
|
38
|
+
// 乗っ取り(LD_*/DYLD_*/PATH/NODE_OPTIONS 等)の名前は defense-in-depth で弾く。
|
|
39
|
+
if (opts.extraEnv) {
|
|
40
|
+
for (const [k, v] of Object.entries(opts.extraEnv)) {
|
|
41
|
+
if (isForwardableSecretEnvName(k))
|
|
42
|
+
env[k] = v;
|
|
43
|
+
else
|
|
44
|
+
process.stderr.write(`deliver: refusing to forward protected env "${k}" to a sink\n`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
35
47
|
return await new Promise((resolve) => {
|
|
36
48
|
const child = spawn(spawnBin, spawnArgs, {
|
|
37
49
|
// stdout piped (we capture receipts); stderr inherited (progress flows to
|
|
@@ -20,6 +20,7 @@ export interface PluginContext {
|
|
|
20
20
|
*/
|
|
21
21
|
save?: string;
|
|
22
22
|
}
|
|
23
|
+
export declare function isForwardableSecretEnvName(name: string): boolean;
|
|
23
24
|
/** Plugin names are restricted to a safe charset to prevent path escaping. */
|
|
24
25
|
export declare function isValidPluginName(name: string): boolean;
|
|
25
26
|
/**
|
package/dist/plugins/dispatch.js
CHANGED
|
@@ -18,6 +18,26 @@ import { fingerprintEndpoint } from "../config/endpoint-fingerprint.js";
|
|
|
18
18
|
import { resolveSourceMachine } from "../config/provenance.js";
|
|
19
19
|
/** Prefix every plugin executable must carry. */
|
|
20
20
|
export const PLUGIN_PREFIX = "i-repo-";
|
|
21
|
+
/**
|
|
22
|
+
* deliver の per-sink `secretEnv`(#94)で sink へ転送してよい env 名か。
|
|
23
|
+
* buildPluginEnv の trust 境界を secretEnv で迂回させないための denylist:
|
|
24
|
+
* - `IREPO_*`: 契約/来歴は CLI が権威的に設定する正本(spoof・clobber 禁止。IREPO_PASSWORD 含む)。
|
|
25
|
+
* - `LD_*` / `DYLD_*`: ELF/Mach-O ローダ注入(任意コード実行)。
|
|
26
|
+
* - `PATH`/`NODE_OPTIONS`/`BASH_ENV`/`ENV`/`IFS`/`SHELL`/`PYTHON*PATH` 等: 実行系の乗っ取りベクタ。
|
|
27
|
+
* 大文字小文字無視で判定(case で denylist をすり抜けさせない)。
|
|
28
|
+
*/
|
|
29
|
+
const SECRET_ENV_DENY_EXACT = new Set([
|
|
30
|
+
"PATH", "NODE_OPTIONS", "NODE_REPL_EXTERNAL_MODULE", "BASH_ENV", "ENV", "IFS",
|
|
31
|
+
"SHELL", "PYTHONPATH", "PYTHONSTARTUP", "PERL5LIB", "PERL5OPT", "RUBYOPT", "GEM_PATH",
|
|
32
|
+
]);
|
|
33
|
+
export function isForwardableSecretEnvName(name) {
|
|
34
|
+
const u = String(name).toUpperCase();
|
|
35
|
+
if (u.startsWith("IREPO_"))
|
|
36
|
+
return false;
|
|
37
|
+
if (u.startsWith("LD_") || u.startsWith("DYLD_"))
|
|
38
|
+
return false;
|
|
39
|
+
return !SECRET_ENV_DENY_EXACT.has(u);
|
|
40
|
+
}
|
|
21
41
|
const isWin = process.platform === "win32";
|
|
22
42
|
/** Plugin names are restricted to a safe charset to prevent path escaping. */
|
|
23
43
|
export function isValidPluginName(name) {
|
package/package.json
CHANGED
|
@@ -107,6 +107,11 @@
|
|
|
107
107
|
"description": "true かつ id が alreadyDelivered に含まれるとき、spawn せず skipped(delivered:true) として集約する。",
|
|
108
108
|
"type": "boolean",
|
|
109
109
|
"default": false
|
|
110
|
+
},
|
|
111
|
+
"secretEnv": {
|
|
112
|
+
"description": "この sink のプロセスへ転送する env 変数名のリスト(§9.9・#94)。値は deliver プロセスの env(host が設定)から取り、この sink だけに注入する(argv 非掲載・他 sink へ漏れない)。fan-out 経由の無人クラウド認証用。プラグインの envVar 宣言に依存しない。",
|
|
113
|
+
"type": "array",
|
|
114
|
+
"items": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }
|
|
110
115
|
}
|
|
111
116
|
},
|
|
112
117
|
"allOf": [
|
package/spec/gemba-adc/spec.md
CHANGED
|
@@ -737,6 +737,16 @@ L3 カタログの単一書き手は GEMBA OS(§4.2)。`deliver` は **catal
|
|
|
737
737
|
- bundle sink の `--cleanup`(destructive)はハブを壊すため、`deliver` は**全 sink 成功時に最後に実行**するか、`--archive-keep` を推奨して警告する(黙ってハブを消して他 sink を取りこぼさない)。
|
|
738
738
|
- 既定は run-to-completion(全 sink を走らせ切り、再送集合を完全にする)。run-level の AbortSignal で最初の hard 失敗時に兄弟を中止する選択肢は持つが既定オフ。
|
|
739
739
|
|
|
740
|
+
### 9.9 per-sink `secretEnv`(無人クラウド認証の host 主導注入・#94)
|
|
741
|
+
|
|
742
|
+
fan-out(`deliver`)経由で sink を起動するとき、dispatch の env allow-list(system+`IREPO_*`+`pluginPassEnv`)が `AWS_*`/`GOOGLE_APPLICATION_CREDENTIALS`/`AZURE_STORAGE_*` 等を scrub するため、無人クラウド認証の資格情報が sink に届かない。これを **host 主導**で埋める。
|
|
743
|
+
|
|
744
|
+
- spec の各 sink に **`secretEnv: [<env 名>, …]`**(その sink へ転送する env 変数の**名前**リスト)。**値は `deliver` プロセスの env**(host=DC が設定)から取り、**その sink のプロセスにだけ**注入する。
|
|
745
|
+
- secret は **env のみ**で渡し **argv には出さない**。**他 sink・他プラグインには漏れない**(allow-list をグローバルに広げない)。spec ファイルに値は持たせない(名前だけ)。
|
|
746
|
+
- **プラグインの `envVar` 宣言には依存しない**=任意の第三者プラグイン(`i-repo-kintone` 等)が宣言なしで動く。プラグイン側 `envVar` は GUI の入力欄ヒント用。
|
|
747
|
+
- env 名は `^[A-Za-z_][A-Za-z0-9_]*$`。deliver プロセスに無い名前は黙って飛ばす(host 未設定=その sink は認証できず失敗するだけ)。
|
|
748
|
+
- 例: `{ "id":"s3-main","plugin":"archive","subcommand":"push-s3","secretEnv":["AWS_ACCESS_KEY_ID","AWS_SECRET_ACCESS_KEY"] }`(archive 0.6.3 の push-s3 が env から IAM キーを読む・#92)。
|
|
749
|
+
|
|
740
750
|
---
|
|
741
751
|
|
|
742
752
|
## 付録A: ウォークスルー(CLI 直叩き経路)
|