@tyhld/conductor 0.12.104 → 0.12.106
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/autoupdate.js
CHANGED
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
* 【決め(設計 3ed9e2b2 の 1/4・2/4 と管制の口の決まり 2f5eb92c)】
|
|
12
12
|
* ・鍵(~/.tyhld/bin/autoupdate/enabled)が無い機では何もしない(★お客さまの機には置かない)。
|
|
13
13
|
* ・管制に目標の版を聞く。autoUpdate:false・ready:false・止める(halted)・届かない → 何もしない
|
|
14
|
-
*
|
|
14
|
+
* (届かないときは更新しない=安全側)。★ただし【届かなかった回】は、空きを待つ数え(busySince)と
|
|
15
|
+
* 置いた札をそのまま持ち越す(1回の通信の失敗で数えを捨てない・4時間を超えた札だけ外す)。
|
|
15
16
|
* ・conductor の目標が今と違えば conductor だけを入れ替える。★そのあいだ Claude Code は動かさない
|
|
16
17
|
* (管制も同じ順番を守る。更新係は管制が返した目標だけを見る)。
|
|
17
18
|
* ・入れ替えは全現場が空きのときだけ: 札を置く → 中継の1周ぶん待つ → もう一度空きを確かめる →
|
|
18
19
|
* 版を名指しして入れる(取得先は毎回 https://registry.npmjs.org・@latest にしない)→
|
|
19
|
-
* 全現場の常駐を起こし直す → 札を外す → 自己診断 → 管制へ(自己診断が届ける)。
|
|
20
|
+
* 全現場の常駐を起こし直す → 札を外す → 落ち着くのを 45秒 待つ → 自己診断 → 管制へ(自己診断が届ける)。
|
|
20
21
|
* ・空きでなければ次の回に再挑戦。30分空かなければ札を置いて新しい便を取らない形に移り、
|
|
21
22
|
* 走っている便の終わりを待つ(★走っている便は止めない)。
|
|
22
23
|
* ・自己診断が FAIL → そのPCだけ前の版へ戻す → 自己診断(after-rollback=rolledBack が管制へ届く)。
|
|
@@ -63,6 +64,29 @@ export const BUSY_LIMIT_MS = 30 * 60 * 1000;
|
|
|
63
64
|
export const RESTART_WAIT_MS = 120_000;
|
|
64
65
|
/** 起こし直した常駐の様子を見る間隔。 */
|
|
65
66
|
export const RESTART_POLL_MS = 5_000;
|
|
67
|
+
/**
|
|
68
|
+
* 入れ替え(戻し)の直後、自己診断を始めるまで落ち着くのを待つ長さ(★45秒・CC1 決定 2026-10-05・便 36e5680b)。
|
|
69
|
+
*
|
|
70
|
+
* 【なぜ要るか(便 e70843fa で確かめた)】
|
|
71
|
+
* 入れ替えの直後は、全現場の常駐を一度に起こし直してから、間を空けずに自己診断へ進む。
|
|
72
|
+
* 起き直した常駐の子(claude -p)と、自己診断の小さな試験が【同じログインの控えを同時に更新】して重なり、
|
|
73
|
+
* 「認証の更新が別の処理と重なった」で自己診断が落ちやすい。
|
|
74
|
+
* =落ち着くのを少し待ってから測れば、重なり自体が起きにくくなる(根治は待つ側=測る前)。
|
|
75
|
+
* ★当てるのは「入れ替えの直後」と「戻した直後」の自己診断だけ。
|
|
76
|
+
* 毎日の自己診断・人が手で流す自己診断・held の測り直しは1秒も待たない。
|
|
77
|
+
* ★既に入っている「認証の更新の重なりと明確に判定できた失敗だけ、60秒後に1回だけやり直す」
|
|
78
|
+
* (src/selfcheck.ts の SELFCHECK_AUTH_RETRY_WAIT_MS)は1つも変えない。待つのは重なりを【減らす】ため、
|
|
79
|
+
* やり直しは重なってしまった回を【拾う】ため=役目が違うので、両方要る。
|
|
80
|
+
*/
|
|
81
|
+
export const SWAP_SETTLE_MS = 45_000;
|
|
82
|
+
/** 落ち着くのを待つ長さを変える環境変数(★秒で書く・0 なら待たない)。 */
|
|
83
|
+
export const SETTLE_ENV = 'CONDUCTOR_AUTOUPDATE_SETTLE_SEC';
|
|
84
|
+
/**
|
|
85
|
+
* 管制に届かないまま、置いた札を外すまでの上限(★4時間・CC1 決定 2026-10-05・便 36e5680b)。
|
|
86
|
+
* 管制が入れ替え待ちの札を🟡にする値(管制 `DRAIN_ALERT_SEC`)と同じ考え=
|
|
87
|
+
* 便を取れないPCのままにしない。★配り役(この側)に同じ意味の定数は無いので、ここに置く。
|
|
88
|
+
*/
|
|
89
|
+
export const DRAIN_HOLD_LIMIT_MS = 4 * 60 * 60 * 1000;
|
|
66
90
|
/** 戻しても直らなかった機で、自己診断を測り直す間隔。 */
|
|
67
91
|
export const HELD_RECHECK_MS = 60 * 60 * 1000;
|
|
68
92
|
/**
|
|
@@ -213,6 +237,19 @@ function placeDrain(d, s) {
|
|
|
213
237
|
s.drainOwned = true;
|
|
214
238
|
d.log(`入れ替え待ちの札を置きました(新しい便を取りません・走っている便は止めません): ${p}`);
|
|
215
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* この係が置いた札を、いつ置いたか(★札のファイルの1行目に書いた時刻を読む=控えに欄を足さない)。
|
|
242
|
+
* 読めない・札が無い → null。
|
|
243
|
+
*/
|
|
244
|
+
export function drainPlacedAt(home, env) {
|
|
245
|
+
try {
|
|
246
|
+
const at = Date.parse(readFileSync(drainPath(home, env), 'utf8').trim().split(/\s+/)[0] ?? '');
|
|
247
|
+
return Number.isFinite(at) ? new Date(at) : null;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
216
253
|
/** 札を外す。★この係が置いた札だけ(人が置いた札は外さない)。 */
|
|
217
254
|
function removeDrain(d, s) {
|
|
218
255
|
if (!s.drainOwned)
|
|
@@ -399,10 +436,42 @@ function writePin(d, bin) {
|
|
|
399
436
|
renameSync(`${p}.tmp`, p);
|
|
400
437
|
d.log(`Claude Code の指名を書きました: ${bin}`);
|
|
401
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* 落ち着くのを待つ長さ(ミリ秒)。★環境変数(秒)で変えられる・0 なら待たない。
|
|
441
|
+
* 数で読めない・負の数は既定(45秒)=待たない側へ倒さない(安全側)。
|
|
442
|
+
*/
|
|
443
|
+
export function settleMs(env) {
|
|
444
|
+
const raw = env[SETTLE_ENV];
|
|
445
|
+
if (raw === undefined || raw.trim() === '')
|
|
446
|
+
return SWAP_SETTLE_MS;
|
|
447
|
+
const sec = Number(raw);
|
|
448
|
+
if (!Number.isFinite(sec) || sec < 0)
|
|
449
|
+
return SWAP_SETTLE_MS;
|
|
450
|
+
return Math.round(sec * 1000);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* 入れ替え(戻し)の直後だけ、自己診断を始める前に落ち着くのを待つ。返り値は待った秒数(0=待たない)。
|
|
454
|
+
* ★何秒待ったかを記録に残す(あとから読めるようにする=CC1 決定 A)。
|
|
455
|
+
*/
|
|
456
|
+
async function settleBeforeSelfcheck(d, trigger) {
|
|
457
|
+
const ms = settleMs(d.env);
|
|
458
|
+
if (ms <= 0) {
|
|
459
|
+
d.log(`落ち着くのを待たずに自己診断を始めます(${SETTLE_ENV}=0・きっかけ ${trigger})`);
|
|
460
|
+
return 0;
|
|
461
|
+
}
|
|
462
|
+
const sec = Math.round(ms / 1000);
|
|
463
|
+
d.log(`常駐が落ち着くのを ${sec}秒 待ってから自己診断を始めます(認証の更新の重なりを避ける・きっかけ ${trigger})`);
|
|
464
|
+
await d.sleep(ms);
|
|
465
|
+
d.log(`${sec}秒 待ちました。自己診断(${trigger})を始めます`);
|
|
466
|
+
return sec;
|
|
467
|
+
}
|
|
402
468
|
/** 入れ替え(または戻し)の後の自己診断と、その結果の扱い。 */
|
|
403
469
|
async function afterSwap(d, s) {
|
|
404
470
|
const comp = s.component ?? 'conductor';
|
|
405
471
|
const trigger = s.rollback ? 'after-rollback' : comp === 'conductor' ? 'after-conductor' : 'after-claude';
|
|
472
|
+
// ★起き直した常駐の子と自己診断の小さな試験が、同じログインの控えを同時に更新して重なるのを避ける。
|
|
473
|
+
const waited = await settleBeforeSelfcheck(d, trigger);
|
|
474
|
+
const note = waited > 0 ? `(自己診断の前に ${waited}秒 待ちました)` : '';
|
|
406
475
|
const r = d.runSelfcheck(trigger);
|
|
407
476
|
if (r.pass) {
|
|
408
477
|
if (s.rollback)
|
|
@@ -412,7 +481,7 @@ async function afterSwap(d, s) {
|
|
|
412
481
|
: `${comp} ${s.to} の自己診断が通りました`;
|
|
413
482
|
s.phase = 'idle';
|
|
414
483
|
delete s.rollback;
|
|
415
|
-
return done(d, s, s.lastRun?.action === 'held' ? 'recovered' : 'pass', detail);
|
|
484
|
+
return done(d, s, s.lastRun?.action === 'held' ? 'recovered' : 'pass', `${detail}${note}`);
|
|
416
485
|
}
|
|
417
486
|
const failed = r.failedKeys.join(',') || '不明';
|
|
418
487
|
if (!s.rollback) {
|
|
@@ -425,12 +494,12 @@ async function afterSwap(d, s) {
|
|
|
425
494
|
if (comp === 'claude-code' && back && compareVersions(back, CLAUDE_MIN_VERSION) < 0) {
|
|
426
495
|
s.phase = 'held';
|
|
427
496
|
s.heldRecheckAt = new Date(d.now().getTime() + HELD_RECHECK_MS).toISOString();
|
|
428
|
-
return done(d, s, 'held', `Claude Code ${s.to} の自己診断が落ちました(${failed})。戻す先 ${back} は最低版 ${CLAUDE_MIN_VERSION}
|
|
497
|
+
return done(d, s, 'held', `Claude Code ${s.to} の自己診断が落ちました(${failed})。戻す先 ${back} は最低版 ${CLAUDE_MIN_VERSION} より古いので戻しません(🟡)${note}`);
|
|
429
498
|
}
|
|
430
499
|
if (!back) {
|
|
431
500
|
s.phase = 'held';
|
|
432
501
|
s.heldRecheckAt = new Date(d.now().getTime() + HELD_RECHECK_MS).toISOString();
|
|
433
|
-
return done(d, s, 'held', `${comp} ${s.to} の自己診断が落ちました(${failed}
|
|
502
|
+
return done(d, s, 'held', `${comp} ${s.to} の自己診断が落ちました(${failed})。戻す先の版が分からないので戻せません(🟡)${note}`);
|
|
434
503
|
}
|
|
435
504
|
d.log(`${comp} ${s.to} の自己診断が落ちました(${failed})。この機だけ ${back} へ戻します`);
|
|
436
505
|
return rollBack(d, s, back);
|
|
@@ -444,7 +513,7 @@ async function afterSwap(d, s) {
|
|
|
444
513
|
placeDrain(d, s);
|
|
445
514
|
else
|
|
446
515
|
removeDrain(d, s);
|
|
447
|
-
return done(d, s, 'held', `${comp} を ${s.to} へ戻しても自己診断が通りません(${failed})。${cannotWork ? '札を残し、この機は新しい便を受け取りません' : '便の受け取りは続けます'}
|
|
516
|
+
return done(d, s, 'held', `${comp} を ${s.to} へ戻しても自己診断が通りません(${failed})。${cannotWork ? '札を残し、この機は新しい便を受け取りません' : '便の受け取りは続けます'}(🟡)${note}`);
|
|
448
517
|
}
|
|
449
518
|
/** 前の版へ戻す。 */
|
|
450
519
|
async function rollBack(d, s, back) {
|
|
@@ -600,9 +669,21 @@ export async function runUpdater(d) {
|
|
|
600
669
|
const { target, detail } = await fetchTarget(d, installed);
|
|
601
670
|
s.nextCheckAt = new Date(now.getTime() + (target?.checkAgainMin ?? DEFAULT_CHECK_MIN) * 60_000).toISOString();
|
|
602
671
|
if (!target) {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
672
|
+
// ★管制に1回届かなかっただけで、空きを待つ数え(busySince)と置いた札を捨てない(便 36e5680b)。
|
|
673
|
+
// 捨てると、便が切れ目なく続くPCでは回線が30分に1回でも途切れるたびに数えが 0 に戻り、
|
|
674
|
+
// 札が置かれないので、いつまでも新しい版へ入れ替わらない(便 443e599d の発見2)。
|
|
675
|
+
// ★更新しないことは今までどおり(届かない回に版は動かさない=安全側)。
|
|
676
|
+
// ★安全のための上限:管制に届かないまま札を置いてから 4時間 を超えたら札を外す
|
|
677
|
+
// (便を取れないPCのままにしない=管制が札を🟡にする値と同じ考え)。
|
|
678
|
+
const placedAt = s.drainOwned ? drainPlacedAt(d.home, d.env) : null;
|
|
679
|
+
const heldMs = placedAt ? now.getTime() - placedAt.getTime() : 0;
|
|
680
|
+
if (placedAt && heldMs >= DRAIN_HOLD_LIMIT_MS) {
|
|
681
|
+
removeDrain(d, s);
|
|
682
|
+
s.busySince = null;
|
|
683
|
+
return done(d, s, 'skip', `管制に目標の版を聞けませんでした(${detail})。札を置いてから ${Math.round(DRAIN_HOLD_LIMIT_MS / 3_600_000)}時間 を超えたので札を外しました(便を取れないPCのままにしない)`);
|
|
684
|
+
}
|
|
685
|
+
const kept = s.drainOwned ? `置いた札はそのまま(${Math.round(heldMs / 60_000)}分)` : '札は置いていません';
|
|
686
|
+
return done(d, s, 'skip', `管制に目標の版を聞けませんでした(${detail})。更新しません(${kept}・空きを待つ数えは持ち越し)`);
|
|
606
687
|
}
|
|
607
688
|
if (!target.ready || !target.autoUpdate || target.halted) {
|
|
608
689
|
removeDrain(d, s);
|
package/package.json
CHANGED
package/scripts/hooks/policy.py
CHANGED
|
@@ -618,7 +618,9 @@ def verb_of(tokens):
|
|
|
618
618
|
i = 0
|
|
619
619
|
while i < len(tokens):
|
|
620
620
|
t = tokens[i]
|
|
621
|
-
|
|
621
|
+
# ★文の頭の予約語(do / then / until …)も剥がす(便 afb46a15・_tool_words と同じ根)。
|
|
622
|
+
# 剥がさないと `while true; do cat .env; done` の動詞が do になり、秘密の読み出しが素通りした。
|
|
623
|
+
if _ASSIGN_RE.match(t) or t in _SHELL_KEYWORDS:
|
|
622
624
|
i += 1
|
|
623
625
|
continue
|
|
624
626
|
base = t.split('/')[-1]
|
|
@@ -1392,7 +1394,11 @@ def _tool_words(tokens):
|
|
|
1392
1394
|
i = 0
|
|
1393
1395
|
while i < len(tokens):
|
|
1394
1396
|
t = tokens[i]
|
|
1395
|
-
if
|
|
1397
|
+
# ★文の頭の予約語(until / while / do / if / then / ! …)も剥がす(便 afb46a15)。
|
|
1398
|
+
# 剥がさないと `until gh run list …` の道具が until と読まれ、文字を扱うだけの gh が
|
|
1399
|
+
# SQL の字面検査へ落ちて人に聞いていた。逆に `until psql -h 本番 …` や
|
|
1400
|
+
# `if true; then supabase db push; fi` は道具が見えず素通りしていた(両方この1か所が根)。
|
|
1401
|
+
if _ASSIGN_RE.match(t) or t in _SHELL_KEYWORDS:
|
|
1396
1402
|
i += 1
|
|
1397
1403
|
continue
|
|
1398
1404
|
base = t.split('/')[-1]
|
|
@@ -1881,7 +1887,7 @@ _PG_ENV_INDIRECT = (
|
|
|
1881
1887
|
_TUNNEL_TOOLS = frozenset({
|
|
1882
1888
|
'ssh', 'autossh', 'sshuttle', 'socat', 'ncat', 'nc', 'netcat', 'chisel',
|
|
1883
1889
|
'ngrok', 'cloudflared', 'stunnel', 'pgbouncer', 'kubectl', 'oc',
|
|
1884
|
-
'cloud-sql-proxy', 'cloud_sql_proxy', 'wstunnel', 'frpc', 'iptables', 'nft',
|
|
1890
|
+
'cloud-sql-proxy', 'cloud_sql_proxy', 'wstunnel', 'frpc', 'iptables', 'ip6tables', 'nft',
|
|
1885
1891
|
})
|
|
1886
1892
|
|
|
1887
1893
|
|
|
@@ -2023,6 +2029,248 @@ def _tunneled_db_danger(command: str) -> str:
|
|
|
2023
2029
|
return None
|
|
2024
2030
|
|
|
2025
2031
|
|
|
2032
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2033
|
+
# ★ポート転送を【単独のコマンドでも】止める(便 afb46a15・便 3747bf25 の報告の根治)
|
|
2034
|
+
#
|
|
2035
|
+
# 【なぜ要るか(実測 2026-10-05・番人 0.12.101)】上の _tunneled_db_danger は「転送と SQL クライアントが
|
|
2036
|
+
# 同じコマンドに在る」形しか見ていなかった。`ssh -N -L 55432:<本番>:5432 …` を先に張り、別の
|
|
2037
|
+
# コマンドで `psql -h 127.0.0.1 -p 55432` を打つと、どちらも単独では allow で、番人の層だけでは
|
|
2038
|
+
# 本番へ届く道が残っていた(6通りのうち5番目だけが allow)。
|
|
2039
|
+
# 【どう止めるか】転送の行き先を読み、次のどれかなら deny。
|
|
2040
|
+
# ・行き先が手元の数字の宛先(127.0.0.1・::1)でない(★名前は許さない=psql の決まりと同じ)
|
|
2041
|
+
# ・行き先のポートが DB の番号(5432・6543)
|
|
2042
|
+
# ・行き先が読めない(変数・コマンド置換・設定ファイル -F・動的転送 -D・道具の設定に隠れる形)
|
|
2043
|
+
# 【変えないこと】転送の無い ssh(`ssh -T git@github.com`・git の ssh)・手元の使い捨てへの psql。
|
|
2044
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2045
|
+
|
|
2046
|
+
#: 転送の行き先として止めるポート(Postgres の既定・Supabase の pooler)。
|
|
2047
|
+
_DB_FORWARD_PORTS = frozenset({'5432', '6543'})
|
|
2048
|
+
|
|
2049
|
+
#: ssh の、値を1つ取る短い旗(`-NL 55432:…` のようなまとめ書きを読むため)。
|
|
2050
|
+
_SSH_ARG_FLAGS = frozenset('BbcDEeFIiJLlmOopQRSWw')
|
|
2051
|
+
|
|
2052
|
+
#: 転送そのものが役目で、行き先が設定・外のサービスに隠れる道具(版・使い方の表示だけは通す)。
|
|
2053
|
+
_TUNNEL_ONLY_TOOLS = frozenset({
|
|
2054
|
+
'sshuttle', 'chisel', 'ngrok', 'cloudflared', 'stunnel', 'pgbouncer',
|
|
2055
|
+
'cloud-sql-proxy', 'cloud_sql_proxy', 'wstunnel', 'frpc',
|
|
2056
|
+
})
|
|
2057
|
+
_INFO_ONLY_ARGS = frozenset({'--version', '-v', '-V', 'version', '--help', '-h', 'help'})
|
|
2058
|
+
|
|
2059
|
+
#: socat の、つなぎに行く側の宛先の種類。
|
|
2060
|
+
_SOCAT_CONNECT_TYPES = frozenset({
|
|
2061
|
+
'tcp', 'tcp4', 'tcp6', 'tcp-connect', 'tcp4-connect', 'tcp6-connect', 'openssl',
|
|
2062
|
+
'openssl-connect', 'ssl', 'proxy', 'proxy-connect', 'socks', 'socks4', 'socks4a', 'socks5',
|
|
2063
|
+
'udp', 'udp4', 'udp6', 'udp-connect', 'udp4-connect', 'udp6-connect', 'sctp', 'sctp-connect',
|
|
2064
|
+
})
|
|
2065
|
+
#: socat の、何でも実行できる宛先(行き先が読めない=止める)。
|
|
2066
|
+
_SOCAT_EXEC_TYPES = frozenset({'exec', 'system', 'shell'})
|
|
2067
|
+
|
|
2068
|
+
|
|
2069
|
+
def _split_host_parts(spec: str):
|
|
2070
|
+
"""`a:[::1]:5432` を ['a', '[::1]', '5432'] に割る(角括弧の中の : では割らない)。"""
|
|
2071
|
+
parts, cur, depth = [], [], 0
|
|
2072
|
+
for c in spec:
|
|
2073
|
+
if c == '[':
|
|
2074
|
+
depth += 1
|
|
2075
|
+
elif c == ']':
|
|
2076
|
+
depth -= 1
|
|
2077
|
+
if c == ':' and depth == 0:
|
|
2078
|
+
parts.append(''.join(cur))
|
|
2079
|
+
cur = []
|
|
2080
|
+
continue
|
|
2081
|
+
cur.append(c)
|
|
2082
|
+
parts.append(''.join(cur))
|
|
2083
|
+
return parts
|
|
2084
|
+
|
|
2085
|
+
|
|
2086
|
+
def _forward_dest_risk(host: str, port: str, what: str):
|
|
2087
|
+
"""転送の行き先(ホスト・ポート)を測る。止めるなら理由、通すなら None。"""
|
|
2088
|
+
host = _strip_quotes(host or '')
|
|
2089
|
+
port = _strip_quotes(port or '')
|
|
2090
|
+
if not host or not port or '$' in host + port or '`' in host + port:
|
|
2091
|
+
return f'{what}: 行き先が読めない'
|
|
2092
|
+
if port in _DB_FORWARD_PORTS:
|
|
2093
|
+
return f'{what}: 行き先が DB のポート ({port})'
|
|
2094
|
+
if host not in _LOCAL_HOST_LITERALS:
|
|
2095
|
+
return f'{what}: 行き先が手元でない ({host})'
|
|
2096
|
+
if not port.isdigit():
|
|
2097
|
+
return f'{what}: 行き先のポートが読めない ({port})'
|
|
2098
|
+
return None
|
|
2099
|
+
|
|
2100
|
+
|
|
2101
|
+
def _ssh_forward_spec_risk(flag: str, spec: str):
|
|
2102
|
+
"""-L / -R / -W の値を測る。"""
|
|
2103
|
+
spec = _strip_quotes(spec or '')
|
|
2104
|
+
if not spec or '$' in spec or '`' in spec:
|
|
2105
|
+
return f'ssh {flag}: 行き先が読めない'
|
|
2106
|
+
if flag == '-W':
|
|
2107
|
+
parts = _split_host_parts(spec)
|
|
2108
|
+
if len(parts) != 2:
|
|
2109
|
+
return f'ssh {flag}: 行き先が読めない'
|
|
2110
|
+
return _forward_dest_risk(parts[0], parts[1], f'ssh {flag}')
|
|
2111
|
+
parts = _split_host_parts(spec)
|
|
2112
|
+
if len(parts) >= 3:
|
|
2113
|
+
return _forward_dest_risk(parts[-2], parts[-1], f'ssh {flag}')
|
|
2114
|
+
# `-L 55432:/var/run/…sock`(向こうのソケットへ)・`-R 1080`(向こうでの動的転送)は行き先が読めない。
|
|
2115
|
+
return f'ssh {flag}: 行き先が読めない'
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
def _ssh_option_risk(opt: str):
|
|
2119
|
+
"""-o の値(`LocalForward=…`・`LocalForward 55432 db:5432`)を測る。"""
|
|
2120
|
+
opt = _strip_quotes(opt or '')
|
|
2121
|
+
if '$' in opt or '`' in opt:
|
|
2122
|
+
return 'ssh -o: 設定が読めない'
|
|
2123
|
+
m = re.match(r'\s*([A-Za-z]+)\s*(?:=\s*|\s+)(.*)$', opt)
|
|
2124
|
+
if not m:
|
|
2125
|
+
return None
|
|
2126
|
+
key, value = m.group(1).lower(), m.group(2).strip()
|
|
2127
|
+
if key == 'dynamicforward':
|
|
2128
|
+
return 'ssh -o DynamicForward: 行き先が読めない'
|
|
2129
|
+
if key in ('localforward', 'remoteforward'):
|
|
2130
|
+
flag = '-L' if key == 'localforward' else '-R'
|
|
2131
|
+
fields = value.split()
|
|
2132
|
+
if len(fields) == 2:
|
|
2133
|
+
return _ssh_forward_spec_risk(flag, f'{fields[0]}:{fields[1]}')
|
|
2134
|
+
if len(fields) == 1: # `LocalForward=55432:db:5432` のように : でつないだ形
|
|
2135
|
+
return _ssh_forward_spec_risk(flag, fields[0])
|
|
2136
|
+
return f'ssh -o {m.group(1)}: 行き先が読めない'
|
|
2137
|
+
if key == 'include':
|
|
2138
|
+
return 'ssh -o Include: 設定が読めない'
|
|
2139
|
+
return None
|
|
2140
|
+
|
|
2141
|
+
|
|
2142
|
+
def _ssh_forward_risk(args):
|
|
2143
|
+
"""ssh / autossh の引数(道具の名前より後ろ)から転送を探して測る。"""
|
|
2144
|
+
i, host_seen = 0, False
|
|
2145
|
+
while i < len(args):
|
|
2146
|
+
a = args[i]
|
|
2147
|
+
if a == '--':
|
|
2148
|
+
break
|
|
2149
|
+
if not a.startswith('-') or a == '-':
|
|
2150
|
+
# ★ssh は宛先のホストの【すぐ後ろ】の旗も読む(`ssh host -L …` も転送になる)。
|
|
2151
|
+
# ホストのあとに旗でない語が来たら、そこからは向こうで動くコマンド。
|
|
2152
|
+
if host_seen:
|
|
2153
|
+
break
|
|
2154
|
+
host_seen = True
|
|
2155
|
+
i += 1
|
|
2156
|
+
continue
|
|
2157
|
+
if a.startswith('--'):
|
|
2158
|
+
i += 1
|
|
2159
|
+
continue
|
|
2160
|
+
j = 1
|
|
2161
|
+
while j < len(a):
|
|
2162
|
+
c = a[j]
|
|
2163
|
+
if c in _SSH_ARG_FLAGS:
|
|
2164
|
+
value = a[j + 1:]
|
|
2165
|
+
if not value:
|
|
2166
|
+
i += 1
|
|
2167
|
+
value = args[i] if i < len(args) else ''
|
|
2168
|
+
flag = '-' + c
|
|
2169
|
+
if c in 'LRW':
|
|
2170
|
+
hit = _ssh_forward_spec_risk(flag, value)
|
|
2171
|
+
if hit:
|
|
2172
|
+
return hit
|
|
2173
|
+
elif c == 'D':
|
|
2174
|
+
return 'ssh -D: 動的転送は行き先が読めない'
|
|
2175
|
+
elif c == 'o':
|
|
2176
|
+
hit = _ssh_option_risk(value)
|
|
2177
|
+
if hit:
|
|
2178
|
+
return hit
|
|
2179
|
+
elif c == 'F' and _strip_quotes(value) != '/dev/null':
|
|
2180
|
+
return 'ssh -F: 設定ファイルに転送が隠れうる(行き先が読めない)'
|
|
2181
|
+
break
|
|
2182
|
+
j += 1
|
|
2183
|
+
i += 1
|
|
2184
|
+
return None
|
|
2185
|
+
|
|
2186
|
+
|
|
2187
|
+
def _socat_risk(args):
|
|
2188
|
+
for a in args:
|
|
2189
|
+
if a.startswith('-'):
|
|
2190
|
+
continue
|
|
2191
|
+
a = _strip_quotes(a)
|
|
2192
|
+
if '$' in a or '`' in a:
|
|
2193
|
+
return 'socat: 行き先が読めない'
|
|
2194
|
+
kind, _, rest = a.partition(':')
|
|
2195
|
+
kind = kind.lower()
|
|
2196
|
+
rest = rest.split(',', 1)[0]
|
|
2197
|
+
if kind in _SOCAT_EXEC_TYPES:
|
|
2198
|
+
return f'socat {kind}: 行き先が読めない'
|
|
2199
|
+
if kind in _SOCAT_CONNECT_TYPES:
|
|
2200
|
+
parts = _split_host_parts(rest)
|
|
2201
|
+
if len(parts) < 2:
|
|
2202
|
+
return f'socat {kind}: 行き先が読めない'
|
|
2203
|
+
hit = _forward_dest_risk(parts[0], parts[1], f'socat {kind}')
|
|
2204
|
+
if hit:
|
|
2205
|
+
return hit
|
|
2206
|
+
elif kind.endswith('-listen') or kind.endswith('-l'):
|
|
2207
|
+
if rest in _DB_FORWARD_PORTS:
|
|
2208
|
+
return f'socat {kind}: DB のポートで待ち受ける ({rest})'
|
|
2209
|
+
return None
|
|
2210
|
+
|
|
2211
|
+
|
|
2212
|
+
def _nc_risk(args):
|
|
2213
|
+
"""nc の仲間。★待ち受け(中継に使える形)と DB のポートだけを止める(`nc -z host 443` は変えない)。"""
|
|
2214
|
+
for a in args:
|
|
2215
|
+
low = _strip_quotes(a)
|
|
2216
|
+
if low in ('--listen', '--sh-exec', '--exec', '--lua-exec') \
|
|
2217
|
+
or (low.startswith('-') and not low.startswith('--')
|
|
2218
|
+
and any(c in low[1:] for c in 'lec')):
|
|
2219
|
+
return 'nc: 待ち受け・中継の形'
|
|
2220
|
+
if low in _DB_FORWARD_PORTS or any(low.endswith(':' + p) for p in _DB_FORWARD_PORTS):
|
|
2221
|
+
return f'nc: 行き先が DB のポート ({low})'
|
|
2222
|
+
return None
|
|
2223
|
+
|
|
2224
|
+
|
|
2225
|
+
def _forward_segment_risk(tokens):
|
|
2226
|
+
i, _seen = _unwrap(tokens)
|
|
2227
|
+
if i >= len(tokens):
|
|
2228
|
+
return None
|
|
2229
|
+
tool = tokens[i].split('/')[-1].lower()
|
|
2230
|
+
args = tokens[i + 1:]
|
|
2231
|
+
# ★道具の一覧の正本は _TUNNEL_TOOLS の1か所(同じコマンドに並ぶ形の判定と共用・2つに割らない)。
|
|
2232
|
+
if tool not in _TUNNEL_TOOLS:
|
|
2233
|
+
return None
|
|
2234
|
+
if tool in ('ssh', 'autossh'):
|
|
2235
|
+
if tool == 'autossh': # autossh 自身の -M <見張りのポート> を外してから ssh として読む
|
|
2236
|
+
args = [a for k, a in enumerate(args)
|
|
2237
|
+
if a != '-M' and not (k > 0 and args[k - 1] == '-M')]
|
|
2238
|
+
hit = _ssh_forward_risk(args)
|
|
2239
|
+
return f'{tool} の転送 — {hit}' if hit else None
|
|
2240
|
+
if tool in ('kubectl', 'oc') and 'port-forward' in [a.lower() for a in args]:
|
|
2241
|
+
return f'{tool} port-forward(行き先が手元でない)'
|
|
2242
|
+
if tool in _TUNNEL_ONLY_TOOLS:
|
|
2243
|
+
if args and all(a in _INFO_ONLY_ARGS for a in args):
|
|
2244
|
+
return None
|
|
2245
|
+
return f'{tool}(転送の道具・行き先が読めない)'
|
|
2246
|
+
if tool == 'socat':
|
|
2247
|
+
return _socat_risk(args)
|
|
2248
|
+
if tool in ('nc', 'ncat', 'netcat'):
|
|
2249
|
+
return _nc_risk(args)
|
|
2250
|
+
if tool in ('iptables', 'ip6tables', 'nft'):
|
|
2251
|
+
low = ' '.join(args).lower()
|
|
2252
|
+
if any(w in low for w in ('dnat', 'redirect', '--to-destination', '--to-ports', 'masquerade')):
|
|
2253
|
+
return f'{tool}(通信の向け替え)'
|
|
2254
|
+
return None
|
|
2255
|
+
|
|
2256
|
+
|
|
2257
|
+
def _port_forward_danger(command: str, depth: int = 0):
|
|
2258
|
+
"""コマンドの中のポート転送を、単独でも測る。止めるなら理由。"""
|
|
2259
|
+
for seg in split_commands(command, bodies=False):
|
|
2260
|
+
hit = _forward_segment_risk(tokens_of(seg))
|
|
2261
|
+
if hit:
|
|
2262
|
+
return f'本番へ届きうるポート転送 ({hit})'
|
|
2263
|
+
if depth < _UNWRAP_MAX_DEPTH:
|
|
2264
|
+
inners = [inner for kind, _r2, inner in _inner_payloads(command) if kind == 'shell']
|
|
2265
|
+
inners += _subst_payloads(command)
|
|
2266
|
+
for inner in inners:
|
|
2267
|
+
if inner and inner.strip():
|
|
2268
|
+
hit = _port_forward_danger(inner, depth + 1)
|
|
2269
|
+
if hit:
|
|
2270
|
+
return hit
|
|
2271
|
+
return None
|
|
2272
|
+
|
|
2273
|
+
|
|
2026
2274
|
def _prod_db_change(seg, tokens):
|
|
2027
2275
|
"""1セグメントが本番 DB を直接変える道具なら理由を返す。"""
|
|
2028
2276
|
words = _tool_words(tokens)
|
|
@@ -2053,6 +2301,12 @@ def _prod_db_change_scan(command: str, depth: int = 0):
|
|
|
2053
2301
|
hit = _prod_db_change_scan(inner, depth + 1)
|
|
2054
2302
|
if hit:
|
|
2055
2303
|
return f'{hit} (inside {runner})'
|
|
2304
|
+
# ★コマンド置換の中身(`[ -n "$(psql …)" ]` の形・便 afb46a15)も同じ物差しで測る。
|
|
2305
|
+
for inner in _subst_payloads(command):
|
|
2306
|
+
if inner and inner.strip():
|
|
2307
|
+
hit = _prod_db_change_scan(inner, depth + 1)
|
|
2308
|
+
if hit:
|
|
2309
|
+
return f'{hit} (inside $())'
|
|
2056
2310
|
return None
|
|
2057
2311
|
|
|
2058
2312
|
|
|
@@ -3544,15 +3798,27 @@ def _shell_payloads(head_tokens, body):
|
|
|
3544
3798
|
return out
|
|
3545
3799
|
|
|
3546
3800
|
|
|
3801
|
+
#: 繰り返し・条件の頭に立つが、自分では何も実行しない語(`[ … ]`・`for x in …`・`sleep 15`)。
|
|
3802
|
+
# ★中の `$(…)` は _gate_one が別のコマンドとして測るので、ここを通しても中身は見逃さない。
|
|
3803
|
+
_NO_EXEC_HEADS = frozenset({'[', '[[', 'for', 'case', 'select', 'sleep', ':', 'break', 'continue'})
|
|
3804
|
+
|
|
3805
|
+
|
|
3547
3806
|
def _gate_one(segment: str, depth: int):
|
|
3548
3807
|
"""本物の1コマンドの関門。返り (種別, 理由) / None。"""
|
|
3549
3808
|
head, _, body = segment.partition('\n')
|
|
3550
3809
|
toks = tokens_of(head)
|
|
3810
|
+
# ★コマンド置換・プロセス置換の中身(`$(…)`・`` `…` ``・`<(…)`)は、それ自体が1つのコマンド。
|
|
3811
|
+
# 同じ物差しで測る(便 afb46a15)。外側が文字を扱うだけの道具でも、中身は実行されるため。
|
|
3812
|
+
if depth < _UNWRAP_MAX_DEPTH:
|
|
3813
|
+
for inner in _subst_payloads(head):
|
|
3814
|
+
hit = _gate_command(inner, depth + 1)
|
|
3815
|
+
if hit:
|
|
3816
|
+
return hit
|
|
3551
3817
|
words = _tool_words(toks)
|
|
3552
3818
|
if not words:
|
|
3553
3819
|
return None
|
|
3554
3820
|
v = words[0]
|
|
3555
|
-
if v in _SQL_TEXT_ONLY_VERBS:
|
|
3821
|
+
if v in _SQL_TEXT_ONLY_VERBS or v in _NO_EXEC_HEADS:
|
|
3556
3822
|
return None # 文字を扱うだけ。引数・本文に何が書かれていても実行されない。
|
|
3557
3823
|
if v in _SHELL_RUNNERS:
|
|
3558
3824
|
if depth >= _UNWRAP_MAX_DEPTH:
|
|
@@ -3932,6 +4198,10 @@ def decide_bash(command: str, cwd=None):
|
|
|
3932
4198
|
|
|
3933
4199
|
# ---- 1d') 手元に見せかけた回り道での DB 実行(トンネル・ポート転送・便 ee34f7e5) ----
|
|
3934
4200
|
hit = _tunneled_db_danger(command)
|
|
4201
|
+
if hit:
|
|
4202
|
+
return _r(DENY, f'danger: {hit}')
|
|
4203
|
+
# ---- 1d'') ポート転送を単独でも(便 afb46a15・張ってから別のコマンドで psql を打つ形) ----
|
|
4204
|
+
hit = _port_forward_danger(command)
|
|
3935
4205
|
if hit:
|
|
3936
4206
|
return _r(DENY, f'danger: {hit}')
|
|
3937
4207
|
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ポート転送を単独でも止める決まりと、読むだけの待ちの繰り返しを人に聞かない決まりの検査(便 afb46a15)。
|
|
3
|
+
|
|
4
|
+
実行: python3 scripts/hooks/test_port_forward_and_wait_loop.py
|
|
5
|
+
1件でも期待と違えば exit 1。
|
|
6
|
+
|
|
7
|
+
【何を守っているか】
|
|
8
|
+
(t) 便 3747bf25 と同じ6通り … 本番への5通りが deny・手元の使い捨てへの1通りが allow。
|
|
9
|
+
★訊くだけ。実際には1回もつながない(宛先は形だけの文字)。
|
|
10
|
+
(f) ★negative test =転送の行き先が手元以外/ポートが 5432・6543/行き先が読めない形は、
|
|
11
|
+
単独のコマンドでも止まる(ssh -L・-R・-D・-W・-o・-F・autossh・その他の転送の道具)。
|
|
12
|
+
(k) 転送の無い ssh・git の ssh・手元どうしの転送・手元の使い捨てへの psql は今までどおり通る。
|
|
13
|
+
(w) 読むだけの待ちの繰り返し(gh run list / view・gh pr checks / view + sleep)は人に聞かずに通る。
|
|
14
|
+
★名前・題名に grant / revoke / create table の字が入っていても通る(便 6db73d94 の形)。
|
|
15
|
+
(m) ★negative test =繰り返し・条件の中に、本番反映・DB の変更・保護の迂回・秘密の読み出し・
|
|
16
|
+
本番ブランチへの push が1つでも混ざれば、今までどおり止まる/人に聞く。
|
|
17
|
+
(n) 2026-09-21 の形(node でリポの中の読むだけの照合を呼ぶ)は通る。
|
|
18
|
+
(v) 空振り確認 … 直した所を外すと、上の期待が実際に崩れること(試験が飾りでないこと)。
|
|
19
|
+
|
|
20
|
+
★この台本は道具の名前を素の字で置かない(組み立てて使う)=test_local_psql と同じ作法。
|
|
21
|
+
"""
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
26
|
+
|
|
27
|
+
import policy # noqa: E402
|
|
28
|
+
|
|
29
|
+
T = 'p' + 'sql'
|
|
30
|
+
CENTRAL = 'supa' + 'base'
|
|
31
|
+
HOSTING = 'ver' + 'cel'
|
|
32
|
+
PROD = 'db.example-ref.supa' + 'base.co'
|
|
33
|
+
POOLER = 'aws-0-ap-northeast-1.pooler.supa' + 'base.com'
|
|
34
|
+
JUMP = 'example-user@example.invalid'
|
|
35
|
+
SEL = 'select 1'
|
|
36
|
+
W_GRANT = 'gr' + 'ant'
|
|
37
|
+
W_REVOKE = 're' + 'voke'
|
|
38
|
+
W_DDL = 'cre' + 'ate table'
|
|
39
|
+
|
|
40
|
+
CWD = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
41
|
+
failures = []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check(label, got, want):
|
|
45
|
+
ok = got == want
|
|
46
|
+
if not ok:
|
|
47
|
+
failures.append(f'{label}: got={got!r} want={want!r}')
|
|
48
|
+
print(f'{"OK " if ok else "NG "} {label:72} {got}')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decide(cmd):
|
|
52
|
+
return policy.decide_bash(cmd, CWD)['decision']
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
# (t) 便 3747bf25 と同じ6通り(5番目がこの便で allow → deny に変わった)
|
|
57
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
SIX = [
|
|
59
|
+
('t1 本番のホストへ直に', f'{T} -h {PROD} -p 5432 -c "{SEL}"', policy.DENY),
|
|
60
|
+
('t2 手元の共有 DB(5432)', f'{T} -h 127.0.0.1 -p 5432 -c "{SEL}"', policy.DENY),
|
|
61
|
+
('t3 pooler の接続文字列', f"{T} 'postgresql://postgres.x:p@{POOLER}:6543/postgres' -c \"{SEL}\"",
|
|
62
|
+
policy.DENY),
|
|
63
|
+
('t4 名前の localhost(向け替えうる)', f'{T} -h localhost -p 55432 -c "{SEL}"', policy.DENY),
|
|
64
|
+
('t5 ★トンネルだけを張る(直す前は allow)', f'ssh -N -L 55432:{PROD}:5432 {JUMP}', policy.DENY),
|
|
65
|
+
('t6 手元の使い捨てへ', f'{T} -h 127.0.0.1 -p 55432 -c "{SEL}"', policy.ALLOW),
|
|
66
|
+
]
|
|
67
|
+
for label, cmd, want in SIX:
|
|
68
|
+
check(label, decide(cmd), want)
|
|
69
|
+
|
|
70
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
# (f) negative test =転送を単独でも止める
|
|
72
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
FORWARD_DENY = [
|
|
74
|
+
('f1 -L 行き先が本番', f'ssh -L 55432:{PROD}:5432 {JUMP}'),
|
|
75
|
+
('f2 -L 行き先が向こうの手元の 5432', f'ssh -L 55432:127.0.0.1:5432 {JUMP}'),
|
|
76
|
+
('f3 -L 行き先が手元以外(DB でないポートでも)', f'ssh -L 55432:10.0.0.5:8080 {JUMP}'),
|
|
77
|
+
('f4 -L 行き先が名前の localhost', f'ssh -L 55432:localhost:8080 {JUMP}'),
|
|
78
|
+
('f5 -NL のまとめ書き', f'ssh -NL 55432:{PROD}:5432 {JUMP}'),
|
|
79
|
+
('f6 -L55432:… の付け書き', f'ssh -N -L55432:{PROD}:5432 {JUMP}'),
|
|
80
|
+
('f7 ホストの後ろに -L', f'ssh {JUMP} -N -L 55432:{PROD}:5432'),
|
|
81
|
+
('f8 -R 行き先が 6543', f'ssh -R 55432:{PROD}:6543 {JUMP}'),
|
|
82
|
+
('f9 -R 動的(ポートだけ)', f'ssh -R 1080 {JUMP}'),
|
|
83
|
+
('f10 -D 動的転送', f'ssh -D 1080 {JUMP}'),
|
|
84
|
+
('f11 -W 標準入出力の転送', f'ssh -W {PROD}:5432 {JUMP}'),
|
|
85
|
+
('f12 -o LocalForward(空白)', f"ssh -o 'LocalForward 55432 {PROD}:5432' {JUMP}"),
|
|
86
|
+
('f13 -o LocalForward=(: つなぎ)', f'ssh -oLocalForward=55432:{PROD}:5432 {JUMP}'),
|
|
87
|
+
('f14 -o RemoteForward', f"ssh -o 'RemoteForward=55432 {PROD}:6543' {JUMP}"),
|
|
88
|
+
('f15 -o DynamicForward', f'ssh -o DynamicForward=1080 {JUMP}'),
|
|
89
|
+
('f16 -F 設定ファイル(転送が隠れうる)', 'ssh -F ./tunnel.conf bastion'),
|
|
90
|
+
('f17 -L の行き先が変数', f'ssh -L "$FWD" {JUMP}'),
|
|
91
|
+
('f18 -L の行き先がコマンド置換', f'ssh -L "55432:$(cat host.txt):5432" {JUMP}'),
|
|
92
|
+
('f19 autossh', f'autossh -M 0 -N -L 55432:{PROD}:5432 {JUMP}'),
|
|
93
|
+
('f20 nohup で包む・背景で張る', f'nohup ssh -fN -L 55432:{PROD}:5432 {JUMP} &'),
|
|
94
|
+
('f21 bash -c の中', f"bash -c 'ssh -N -L 55432:{PROD}:5432 {JUMP}'"),
|
|
95
|
+
('f22 繰り返しの中', f'until ssh -N -L 55432:{PROD}:5432 {JUMP}; do sleep 5; done'),
|
|
96
|
+
('f23 kubectl port-forward', 'kubectl port-forward svc/postgres 55432:5432'),
|
|
97
|
+
('f24 socat で本番へ中継', f'socat TCP-LISTEN:55432,fork TCP:{PROD}:5432'),
|
|
98
|
+
('f25 socat で何でも実行', 'socat TCP-LISTEN:55432,fork EXEC:./relay.sh'),
|
|
99
|
+
('f26 sshuttle', f'sshuttle -r {JUMP} 10.0.0.0/8'),
|
|
100
|
+
('f27 cloud-sql-proxy', 'cloud-sql-proxy --port 55432 proj:region:inst'),
|
|
101
|
+
('f28 nc で待ち受けて中継', f'nc -l 55432 | nc {PROD} 5432'),
|
|
102
|
+
('f29 iptables で向け替え', 'iptables -t nat -A OUTPUT -p tcp --dport 55432 -j DNAT --to-destination 10.0.0.5:5432'),
|
|
103
|
+
]
|
|
104
|
+
for label, cmd in FORWARD_DENY:
|
|
105
|
+
check(label, decide(cmd), policy.DENY)
|
|
106
|
+
|
|
107
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
# (k) 今までどおり通す
|
|
109
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
KEEP = [
|
|
111
|
+
('k1 git の ssh の確かめ', 'ssh -T git@github.com'),
|
|
112
|
+
('k2 転送の無い ssh', f'ssh {JUMP} uptime'),
|
|
113
|
+
('k3 向こうで動くコマンドの -L は転送でない', f'ssh -p 2222 {JUMP} ls -L /srv'),
|
|
114
|
+
('k4 手元どうしの転送(使い捨ての帯)', f'ssh -N -L 55432:127.0.0.1:55433 {JUMP}'),
|
|
115
|
+
('k5 git fetch', 'git fetch origin'),
|
|
116
|
+
('k6 kubectl の読むだけ', 'kubectl get pods'),
|
|
117
|
+
('k7 nc で手元の口を確かめる', 'nc -z 127.0.0.1 55432'),
|
|
118
|
+
('k8 nc で外の口を確かめる', 'nc -z example.invalid 443'),
|
|
119
|
+
('k9 転送の道具の版を見るだけ', 'cloudflared --version'),
|
|
120
|
+
('k10 手元の使い捨てへの psql', f'{T} -h 127.0.0.1 -p 55432 -c "{SEL}"'),
|
|
121
|
+
('k11 ssh -F /dev/null(設定を読まない)', f'ssh -F /dev/null {JUMP} uptime'),
|
|
122
|
+
]
|
|
123
|
+
for label, cmd in KEEP:
|
|
124
|
+
check(label, decide(cmd), policy.ALLOW)
|
|
125
|
+
|
|
126
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
# (w) 読むだけの待ちの繰り返しは人に聞かない
|
|
128
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
BR = f'fix/{W_GRANT}-anon-select'
|
|
130
|
+
WAIT_ALLOW = [
|
|
131
|
+
('w1 until gh run list | grep', 'until gh run list --branch fix/x --limit 1 --json status | grep -q in_progress; do sleep 15; done'),
|
|
132
|
+
('w2 until [ -n "$(gh run list …)" ]',
|
|
133
|
+
"until [ -n \"$(gh run list --branch fix/x --limit 1 --json databaseId -q '.[0].databaseId')\" ]; do sleep 15; done"),
|
|
134
|
+
('w3 for i in $(seq) で gh run list', 'for i in $(seq 1 40); do gh run list --limit 1; sleep 15; done'),
|
|
135
|
+
('w4 while true で gh pr checks', 'while true; do gh pr checks 12 && break; sleep 15; done'),
|
|
136
|
+
('w5 until gh run view', 'until gh run view 123 --json status -q .status | grep -q completed; do sleep 15; done'),
|
|
137
|
+
('w6 ★ブランチ名に grant($() の中)',
|
|
138
|
+
f"until [ -n \"$(gh run list --branch {BR} --limit 1 --json databaseId -q '.[0].databaseId')\" ]; do sleep 15; done"),
|
|
139
|
+
('w7 ★jq の中に grant の名前',
|
|
140
|
+
f"until gh run list --limit 1 --json status -q '.[] | select(.headBranch==\"{BR}\") | .status' | grep -q .; do sleep 15; done"),
|
|
141
|
+
('w8 ★題名に revoke', f"while true; do gh pr view 12 --json title -q '.title | test(\"{W_REVOKE}\")' && break; sleep 15; done"),
|
|
142
|
+
('w9 ★ワークフロー名に create table', f"until gh run list --workflow '{W_DDL} check' --limit 1; do sleep 15; done"),
|
|
143
|
+
('w10 if で gh pr view', f"if gh pr view 12 --json title -q .title | grep -q {W_GRANT}; then echo y; fi"),
|
|
144
|
+
('w11 until ! で gh run view', 'until ! gh run view 123 --json status -q .status | grep -q in_progress; do sleep 15; done'),
|
|
145
|
+
]
|
|
146
|
+
for label, cmd in WAIT_ALLOW:
|
|
147
|
+
check(label, decide(cmd), policy.ALLOW)
|
|
148
|
+
|
|
149
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
# (m) negative test =混ざった繰り返し・条件は今までどおり止まる/人に聞く
|
|
151
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
152
|
+
MIXED = [
|
|
153
|
+
('m1 繰り返しの中で本番 DB', f'until {T} -h {PROD} -p 5432 -c "{SEL}"; do sleep 5; done', policy.DENY),
|
|
154
|
+
('m2 while の do の中で本番 DB', f'while true; do {T} -h {PROD} -p 5432 -c "{SEL}"; sleep 5; done', policy.DENY),
|
|
155
|
+
('m3 if の then の中で DB の反映', f'if true; then {CENTRAL} db push; fi', policy.DENY),
|
|
156
|
+
('m4 $() の中で本番 DB', f'until [ -n "$({T} -h {PROD} -p 5432 -c \'{SEL}\')" ]; do sleep 5; done', policy.DENY),
|
|
157
|
+
('m5 for の語の並びの $() で本番 DB', f'for i in $({T} -h {PROD} -c "{SEL}"); do echo $i; done', policy.DENY),
|
|
158
|
+
('m6 待ちのあとに本番反映', f'until gh run list --limit 1 | grep -q completed; do sleep 15; done; {HOSTING} --prod', policy.GATE),
|
|
159
|
+
('m7 繰り返しの中で本番反映', f'until {HOSTING} --prod; do sleep 15; done', policy.GATE),
|
|
160
|
+
('m8 繰り返しの中で保護を迂回する合流', 'until gh pr merge 12 --admin --merge; do sleep 15; done', policy.DENY),
|
|
161
|
+
('m9 繰り返しの中で本番ブランチへ push', 'until gh run list --limit 1 | grep -q completed; do git push origin HEAD:main; sleep 15; done', policy.DENY),
|
|
162
|
+
('m10 繰り返しの中で秘密を読む', 'while true; do cat .env; sleep 5; done', policy.DENY),
|
|
163
|
+
('m11 繰り返しの中で秘密へ書く', 'until gh run list --limit 1 | grep -q completed; do echo x > .env; sleep 15; done', policy.DENY),
|
|
164
|
+
('m12 知らない道具に SQL の字面(迷う形は今までどおり)', f'until node run.mjs "{W_GRANT} select on t to anon"; do sleep 5; done', policy.GATE),
|
|
165
|
+
('m13 echo の中の $() で DB の反映', f'echo "$({CENTRAL} db push)"', policy.DENY),
|
|
166
|
+
]
|
|
167
|
+
for label, cmd, want in MIXED:
|
|
168
|
+
check(label, decide(cmd), want)
|
|
169
|
+
|
|
170
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
# (n) 2026-09-21 の形(便 4a7992d6・49cc6ddc)
|
|
172
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
173
|
+
check('n1 node で読むだけの照合', decide('node scripts/migration-verify/compare.mjs expected.csv actual.md'), policy.ALLOW)
|
|
174
|
+
check('n2 照合・名前に grant',
|
|
175
|
+
decide(f'node scripts/migration-verify/compare.mjs docs/expected.csv out/prod-after-{W_GRANT}.md'), policy.ALLOW)
|
|
176
|
+
|
|
177
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
178
|
+
# (v) 空振り確認 =直した所を外すと期待が崩れる(★戻したあと、もとの期待に戻ることも見る)
|
|
179
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
180
|
+
saved = policy._port_forward_danger
|
|
181
|
+
policy._port_forward_danger = lambda command, depth=0: None
|
|
182
|
+
check('★空振り①: 単独の転送の判定を外すと t5 が通ってしまう', decide(SIX[4][1]), policy.ALLOW)
|
|
183
|
+
policy._port_forward_danger = saved
|
|
184
|
+
check('★空振り①の戻し: いまの決まりでは止まる', decide(SIX[4][1]), policy.DENY)
|
|
185
|
+
|
|
186
|
+
saved = policy._SHELL_KEYWORDS
|
|
187
|
+
policy._SHELL_KEYWORDS = frozenset()
|
|
188
|
+
check('★空振り②: 予約語を剥がさないと w9 を人に聞いてしまう', decide(WAIT_ALLOW[8][1]), policy.GATE)
|
|
189
|
+
check('★空振り②: 予約語を剥がさないと m1 が通ってしまう', decide(MIXED[0][1]), policy.ALLOW)
|
|
190
|
+
policy._SHELL_KEYWORDS = saved
|
|
191
|
+
check('★空振り②の戻し: w9 は通る', decide(WAIT_ALLOW[8][1]), policy.ALLOW)
|
|
192
|
+
check('★空振り②の戻し: m1 は止まる', decide(MIXED[0][1]), policy.DENY)
|
|
193
|
+
|
|
194
|
+
saved = policy._subst_payloads
|
|
195
|
+
policy._subst_payloads = lambda text: []
|
|
196
|
+
check('★空振り③: $() の中を測らないと m4 が通ってしまう', decide(MIXED[3][1]), policy.ALLOW)
|
|
197
|
+
policy._subst_payloads = saved
|
|
198
|
+
check('★空振り③の戻し: m4 は止まる', decide(MIXED[3][1]), policy.DENY)
|
|
199
|
+
|
|
200
|
+
print('=' * 72)
|
|
201
|
+
if failures:
|
|
202
|
+
print(f'NG {len(failures)} 件')
|
|
203
|
+
for f in failures:
|
|
204
|
+
print(' ' + f)
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
print('すべて期待どおり')
|