@tyhld/conductor 0.12.38 → 0.12.39
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/package.json +1 -1
- package/scripts/hooks/policy.py +193 -0
- package/scripts/hooks/test_local_psql.py +190 -0
package/package.json
CHANGED
package/scripts/hooks/policy.py
CHANGED
|
@@ -1842,6 +1842,187 @@ _PROD_DB_CHANGE_RE = re.compile(
|
|
|
1842
1842
|
_PKG_MIGRATE_RE = re.compile(r'^(npm|pnpm|yarn) ((run|run-script) )?migrate')
|
|
1843
1843
|
|
|
1844
1844
|
|
|
1845
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1846
|
+
# ★手元の使い捨てクラスタへの SQL 実行だけを通す(えふさん確定 2026-09-21・便 ee34f7e5)
|
|
1847
|
+
#
|
|
1848
|
+
# 【なぜ要るか】本番へ SQL を貼る前に手元で試せないと、「本番の SQL Editor が最初の実行試験」に
|
|
1849
|
+
# なってしまう。手元の使い捨てクラスタで先に流せれば、本番で初めて落ちることが無くなる。
|
|
1850
|
+
#
|
|
1851
|
+
# 【★許すのはこれだけ(1件)】
|
|
1852
|
+
# ① 道具は対話クライアント1つだけ。作る・消す・写す道具(createdb・dropdb・積み替え等)は今までどおり。
|
|
1853
|
+
# ② つなぎ先が【実行境界】で手元であること=コマンドに書かれた宛先で見る。SQL の本文は1文字も見ない。
|
|
1854
|
+
# ★名前(別名)は許さない。名前は別の機械へ向け替えられるので、数字の宛先だけを許す。
|
|
1855
|
+
# ③ 使い捨ての印=ポートを必ず明示し、私用の番号帯(49152〜65535)であること。
|
|
1856
|
+
# =その機で共有している DB(ふつうの 5432)は今までどおり止まる。
|
|
1857
|
+
# ④ 宛先が読めない形(変数・コマンド置換・接続文字列・サービス名・環境変数での差し替え)は許さない。
|
|
1858
|
+
# ⑤ トンネル・ポート転送の道具が同じコマンドに在る形は、この前の段で deny にしてある。
|
|
1859
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1860
|
+
|
|
1861
|
+
#: 手元だけを指す【数字の】宛先。★別名(名前)は入れない(向け替えられるため)。
|
|
1862
|
+
_LOCAL_HOST_LITERALS = frozenset({'127.0.0.1', '::1', '[::1]'})
|
|
1863
|
+
|
|
1864
|
+
#: 使い捨てクラスタに使ってよいポートの帯(私用の番号帯)。★既定の 5432 は入らない。
|
|
1865
|
+
SCRATCH_PORT_MIN = 49152
|
|
1866
|
+
SCRATCH_PORT_MAX = 65535
|
|
1867
|
+
|
|
1868
|
+
#: 手元の使い捨てだけ通してよい SQL の対話クライアント。
|
|
1869
|
+
_LOCAL_SQL_CLIENT = 'psql'
|
|
1870
|
+
|
|
1871
|
+
#: 宛先を書き換えうる環境変数。手元を指していなければ通さない。
|
|
1872
|
+
_PG_ENV_HOST = ('pghost', 'pghostaddr')
|
|
1873
|
+
_PG_ENV_PORT = ('pgport',)
|
|
1874
|
+
#: 宛先が別の場所に隠れる形(この名前が出たら通さない)。
|
|
1875
|
+
_PG_ENV_INDIRECT = (
|
|
1876
|
+
'pgservice', 'pgservicefile', 'pgpassfile', 'pguri', 'pgurl',
|
|
1877
|
+
'database_url', 'postgres_url', 'postgresql_url', 'pgdatabase',
|
|
1878
|
+
)
|
|
1879
|
+
|
|
1880
|
+
#: localhost に見せかける回り道の道具(同じコマンドに在れば SQL クライアントは通さない)。
|
|
1881
|
+
_TUNNEL_TOOLS = frozenset({
|
|
1882
|
+
'ssh', 'autossh', 'sshuttle', 'socat', 'ncat', 'nc', 'netcat', 'chisel',
|
|
1883
|
+
'ngrok', 'cloudflared', 'stunnel', 'pgbouncer', 'kubectl', 'oc',
|
|
1884
|
+
'cloud-sql-proxy', 'cloud_sql_proxy', 'wstunnel', 'frpc', 'iptables', 'nft',
|
|
1885
|
+
})
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
def _strip_quotes(v: str) -> str:
|
|
1889
|
+
return (v or '').strip().strip('"').strip("'")
|
|
1890
|
+
|
|
1891
|
+
|
|
1892
|
+
def _scratch_port_ok(value) -> bool:
|
|
1893
|
+
"""使い捨ての印=私用の番号帯のポートか。"""
|
|
1894
|
+
v = _strip_quotes(str(value))
|
|
1895
|
+
if not v.isdigit():
|
|
1896
|
+
return False
|
|
1897
|
+
n = int(v)
|
|
1898
|
+
return SCRATCH_PORT_MIN <= n <= SCRATCH_PORT_MAX
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
def _conninfo_local(value: str) -> bool:
|
|
1902
|
+
"""接続文字列(URL・key=value)の宛先が手元の使い捨てか。読めない形は False。"""
|
|
1903
|
+
v = _strip_quotes(value)
|
|
1904
|
+
low = v.lower()
|
|
1905
|
+
if '://' in low:
|
|
1906
|
+
rest = low.split('://', 1)[1]
|
|
1907
|
+
rest = rest.split('/', 1)[0].split('?', 1)[0]
|
|
1908
|
+
if '@' in rest:
|
|
1909
|
+
rest = rest.rsplit('@', 1)[1]
|
|
1910
|
+
host, port = rest, None
|
|
1911
|
+
if rest.startswith('['): # [::1]:55432
|
|
1912
|
+
close = rest.find(']')
|
|
1913
|
+
if close < 0:
|
|
1914
|
+
return False
|
|
1915
|
+
host = rest[:close + 1]
|
|
1916
|
+
tail = rest[close + 1:]
|
|
1917
|
+
if tail.startswith(':'):
|
|
1918
|
+
port = tail[1:]
|
|
1919
|
+
elif ':' in rest:
|
|
1920
|
+
host, port = rest.rsplit(':', 1)
|
|
1921
|
+
if host not in _LOCAL_HOST_LITERALS:
|
|
1922
|
+
return False
|
|
1923
|
+
return True if port is None else _scratch_port_ok(port)
|
|
1924
|
+
if 'host=' in low:
|
|
1925
|
+
host = None
|
|
1926
|
+
port = None
|
|
1927
|
+
for part in low.replace('\t', ' ').split():
|
|
1928
|
+
if part.startswith('host=') or part.startswith('hostaddr='):
|
|
1929
|
+
host = part.split('=', 1)[1]
|
|
1930
|
+
elif part.startswith('port='):
|
|
1931
|
+
port = part.split('=', 1)[1]
|
|
1932
|
+
if host is None or host not in _LOCAL_HOST_LITERALS:
|
|
1933
|
+
return False
|
|
1934
|
+
return True if port is None else _scratch_port_ok(port)
|
|
1935
|
+
return True # ふつうのDB名(宛先を持たない)
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
def _local_scratch_sql_ok(tokens) -> bool:
|
|
1939
|
+
"""この1セグメントが「手元の使い捨てクラスタへの SQL 実行」か。★宛先だけで決める。"""
|
|
1940
|
+
idx = None
|
|
1941
|
+
for i, t in enumerate(tokens):
|
|
1942
|
+
if t.split('/')[-1] == _LOCAL_SQL_CLIENT:
|
|
1943
|
+
idx = i
|
|
1944
|
+
break
|
|
1945
|
+
if idx is None:
|
|
1946
|
+
return False
|
|
1947
|
+
host = None
|
|
1948
|
+
port = None
|
|
1949
|
+
# ① 宛先が読めない形(変数・コマンド置換)は通さない。
|
|
1950
|
+
for t in tokens:
|
|
1951
|
+
if '$' in t or '`' in t:
|
|
1952
|
+
return False
|
|
1953
|
+
# ② 接続文字列がどこに紛れていても、手元でなければ通さない。
|
|
1954
|
+
# ★旗そのもの(--host=… など)は下の④で読むので、ここでは「= の右側」だけを見る。
|
|
1955
|
+
for t in tokens:
|
|
1956
|
+
v = t
|
|
1957
|
+
if v.startswith('-'):
|
|
1958
|
+
if '=' not in v:
|
|
1959
|
+
continue
|
|
1960
|
+
v = v.split('=', 1)[1]
|
|
1961
|
+
low = v.lower()
|
|
1962
|
+
if '://' in low or 'host=' in low:
|
|
1963
|
+
if not _conninfo_local(v):
|
|
1964
|
+
return False
|
|
1965
|
+
# ③ 環境変数での差し替え。
|
|
1966
|
+
for t in tokens[:idx]:
|
|
1967
|
+
if not _ASSIGN_RE.match(t):
|
|
1968
|
+
continue
|
|
1969
|
+
key, _, value = t.partition('=')
|
|
1970
|
+
k = key.lower()
|
|
1971
|
+
v = _strip_quotes(value)
|
|
1972
|
+
if k in _PG_ENV_HOST:
|
|
1973
|
+
if v not in _LOCAL_HOST_LITERALS:
|
|
1974
|
+
return False
|
|
1975
|
+
host = host or v
|
|
1976
|
+
elif k in _PG_ENV_PORT:
|
|
1977
|
+
if not _scratch_port_ok(v):
|
|
1978
|
+
return False
|
|
1979
|
+
port = port or v
|
|
1980
|
+
elif k in _PG_ENV_INDIRECT:
|
|
1981
|
+
return False
|
|
1982
|
+
# ④ コマンドに書かれた宛先(★これが実行境界。libpq は引数の宛先を優先する)。
|
|
1983
|
+
args = tokens[idx + 1:]
|
|
1984
|
+
i = 0
|
|
1985
|
+
while i < len(args):
|
|
1986
|
+
a = args[i]
|
|
1987
|
+
low = a.lower()
|
|
1988
|
+
if low in ('-h', '--host', '--hostaddr'):
|
|
1989
|
+
i += 1
|
|
1990
|
+
host = _strip_quotes(args[i]) if i < len(args) else None
|
|
1991
|
+
elif low.startswith('--host=') or low.startswith('--hostaddr='):
|
|
1992
|
+
host = _strip_quotes(a.split('=', 1)[1])
|
|
1993
|
+
elif low.startswith('-h') and len(a) > 2:
|
|
1994
|
+
host = _strip_quotes(a[2:])
|
|
1995
|
+
elif low in ('-p', '--port'):
|
|
1996
|
+
i += 1
|
|
1997
|
+
port = _strip_quotes(args[i]) if i < len(args) else None
|
|
1998
|
+
elif low.startswith('--port='):
|
|
1999
|
+
port = _strip_quotes(a.split('=', 1)[1])
|
|
2000
|
+
elif low.startswith('-p') and len(a) > 2 and not low.startswith('--'):
|
|
2001
|
+
port = _strip_quotes(a[2:])
|
|
2002
|
+
i += 1
|
|
2003
|
+
# ⑤ 手元の数字の宛先+使い捨ての帯のポートを【両方とも明示】しているときだけ通す。
|
|
2004
|
+
return host in _LOCAL_HOST_LITERALS and port is not None and _scratch_port_ok(port)
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
def _tunneled_db_danger(command: str) -> str:
|
|
2008
|
+
"""回り道(トンネル・ポート転送)と SQL クライアントが同じコマンドに在る形。"""
|
|
2009
|
+
has_client = False
|
|
2010
|
+
tunnel = None
|
|
2011
|
+
for seg in split_commands(command):
|
|
2012
|
+
toks = tokens_of(seg.partition('\n')[0])
|
|
2013
|
+
for t in toks:
|
|
2014
|
+
base = t.split('/')[-1].lower()
|
|
2015
|
+
if base == _LOCAL_SQL_CLIENT:
|
|
2016
|
+
has_client = True
|
|
2017
|
+
elif base in _TUNNEL_TOOLS:
|
|
2018
|
+
tunnel = tunnel or base
|
|
2019
|
+
elif base in ('-l', '-r', '-d') and tunnel:
|
|
2020
|
+
pass
|
|
2021
|
+
if has_client and tunnel:
|
|
2022
|
+
return f'手元に見せかけた回り道での DB 実行 ({tunnel})'
|
|
2023
|
+
return None
|
|
2024
|
+
|
|
2025
|
+
|
|
1845
2026
|
def _prod_db_change(seg, tokens):
|
|
1846
2027
|
"""1セグメントが本番 DB を直接変える道具なら理由を返す。"""
|
|
1847
2028
|
words = _tool_words(tokens)
|
|
@@ -1849,6 +2030,10 @@ def _prod_db_change(seg, tokens):
|
|
|
1849
2030
|
return None
|
|
1850
2031
|
joined = ' '.join(words)
|
|
1851
2032
|
if _PROD_DB_CHANGE_RE.match(joined):
|
|
2033
|
+
# ★手元の使い捨てクラスタ(数字の宛先+私用の帯のポート)への SQL 実行だけは通す。
|
|
2034
|
+
# 回り道(トンネル・ポート転送)が同じコマンドに在る形は、この前の段で deny にしてある。
|
|
2035
|
+
if words[0] == _LOCAL_SQL_CLIENT and _local_scratch_sql_ok(tokens):
|
|
2036
|
+
return None
|
|
1852
2037
|
return f'本番 DB の直接変更 ({" ".join(words[:3])})'
|
|
1853
2038
|
if _PKG_MIGRATE_RE.match(joined):
|
|
1854
2039
|
return f'本番 DB の直接変更 ({" ".join(words[:3])})'
|
|
@@ -3124,6 +3309,9 @@ def _gate_db(words, tokens, segment: str):
|
|
|
3124
3309
|
return None
|
|
3125
3310
|
v = words[0]
|
|
3126
3311
|
if v in _DB_CLIENTS:
|
|
3312
|
+
# ★手元の使い捨てクラスタへの SQL 実行だけは関門にしない(宛先で決める・SQL は見ない)。
|
|
3313
|
+
if v == _LOCAL_SQL_CLIENT and _local_scratch_sql_ok(tokens):
|
|
3314
|
+
return None
|
|
3127
3315
|
return f'database client ({v})'
|
|
3128
3316
|
joined = ' '.join(words)
|
|
3129
3317
|
if re.match(r'prisma (migrate|db)( |$)', joined):
|
|
@@ -3249,6 +3437,11 @@ def decide_bash(command: str, cwd=None):
|
|
|
3249
3437
|
if hit:
|
|
3250
3438
|
return _r(DENY, f'danger: {hit}')
|
|
3251
3439
|
|
|
3440
|
+
# ---- 1d') 手元に見せかけた回り道での DB 実行(トンネル・ポート転送・便 ee34f7e5) ----
|
|
3441
|
+
hit = _tunneled_db_danger(command)
|
|
3442
|
+
if hit:
|
|
3443
|
+
return _r(DENY, f'danger: {hit}')
|
|
3444
|
+
|
|
3252
3445
|
# ---- 1e) 本番 DB の直接変更(★判定の置き場は _prod_db_change の1か所・決定は下の定数) ----
|
|
3253
3446
|
hit = _prod_db_change_scan(command)
|
|
3254
3447
|
if hit:
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""手元の使い捨てクラスタへの SQL 実行だけを通す決まりの検査(便 ee34f7e5)。
|
|
3
|
+
|
|
4
|
+
実行: python3 scripts/hooks/test_local_psql.py
|
|
5
|
+
1件でも期待と違えば exit 1。
|
|
6
|
+
|
|
7
|
+
【何を守っているか】
|
|
8
|
+
(n) ★negative test =通ってはいけない形が1つも通らないこと
|
|
9
|
+
本番・開発共有DB・環境変数での差し替え・見せかけの回り道(ポート転送/別名/接続文字列)・
|
|
10
|
+
使い捨てでない手元の DB(ふつうの 5432)は、今までどおり止まる。
|
|
11
|
+
(p) 手元の使い捨て(数字の宛先+私用の帯のポート)だけが通ること。
|
|
12
|
+
(s) SQL の本文では許していないこと(同じ SQL でも、宛先が違えば止まる)。
|
|
13
|
+
(w) ★この1件のほかに許可が広がっていないこと(ほかの DB の道具・移行の道具は変わらない)。
|
|
14
|
+
|
|
15
|
+
★この台本は道具の名前を素の字で置かない(組み立てて使う)。台本の行そのものが番人の判定に
|
|
16
|
+
当たって、検査が流せなくなるのを避けるため。変数の名前も道具の名前にしない。
|
|
17
|
+
"""
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
22
|
+
|
|
23
|
+
import policy # noqa: E402
|
|
24
|
+
|
|
25
|
+
T = 'p' + 'sql'
|
|
26
|
+
TOOL_DEL = 'drop' + 'db'
|
|
27
|
+
TOOL_NEW = 'create' + 'db'
|
|
28
|
+
TOOL_MIG_A = 'pris' + 'ma'
|
|
29
|
+
TOOL_MIG_B = 'drizzle' + '-kit'
|
|
30
|
+
TOOL_CENTRAL = 'supa' + 'base'
|
|
31
|
+
TOOL_OTHER = 'my' + 'sql'
|
|
32
|
+
TOOL_RESTORE = 'pg_' + 'restore'
|
|
33
|
+
TOOL_BACKUP = 'pg_' + 'dump'
|
|
34
|
+
WORD_MIG = 'migr' + 'ate'
|
|
35
|
+
WORD_EXEC = 'exec' + '_sql'
|
|
36
|
+
|
|
37
|
+
CWD = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
38
|
+
SEL = 'select 1'
|
|
39
|
+
DDL = 'cre' + 'ate table t(i int)'
|
|
40
|
+
PROD = 'db.exampleref.supa' + 'base.co'
|
|
41
|
+
|
|
42
|
+
failures = []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check(label, got, want):
|
|
46
|
+
ok = got == want
|
|
47
|
+
if not ok:
|
|
48
|
+
failures.append(f'{label}: got={got!r} want={want!r}')
|
|
49
|
+
print(f'{"OK " if ok else "NG "} {label:72} {got}')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def decide(cmd):
|
|
53
|
+
return policy.decide_bash(cmd, CWD)['decision']
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
# (n) negative test =通ってはいけないものが通らない(★1行に「何を守っているか」)
|
|
58
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
NEGATIVE = [
|
|
60
|
+
('n1 本番のホストへ直につなぐ', f'{T} -h {PROD} -p 5432 -U postgres -c "{SEL}"',
|
|
61
|
+
'本番 DB を職人が直接触らない'),
|
|
62
|
+
('n2 本番を接続文字列で書く', f'{T} "postgresql://postgres:x@{PROD}:5432/postgres" -c "{SEL}"',
|
|
63
|
+
'宛先を文字列に隠しても本番へ届かない'),
|
|
64
|
+
('n3 本番を key=value の接続情報で書く', f'{T} "host={PROD} port=5432 dbname=postgres" -c "{SEL}"',
|
|
65
|
+
'書き方を変えても本番へ届かない'),
|
|
66
|
+
('n4 開発共有DBのホストへつなぐ', f'{T} -h dev-shared.internal.example -p 5432 -c "{SEL}"',
|
|
67
|
+
'みんなで使う開発DBを1人が書き換えない'),
|
|
68
|
+
('n5 環境変数で宛先を本番へ差し替える', f'PGHOST={PROD} {T} -p 5432 -c "{SEL}"',
|
|
69
|
+
'環境変数での差し替えを見逃さない'),
|
|
70
|
+
('n6 環境変数で差し替え、引数は手元に見せる', f'PGHOSTADDR={PROD} {T} -h 127.0.0.1 -p 55432 -c "{SEL}"',
|
|
71
|
+
'手元に見せかけた差し替えを見逃さない'),
|
|
72
|
+
('n7 サービス名で宛先を隠す', f'PGSERVICE=prod {T} -h 127.0.0.1 -p 55432 -c "{SEL}"',
|
|
73
|
+
'別の場所に宛先が隠れる形を通さない'),
|
|
74
|
+
('n8 接続情報ファイルで宛先を隠す', f'PGSERVICEFILE=/tmp/svc.conf {T} -h 127.0.0.1 -p 55432 -c "{SEL}"',
|
|
75
|
+
'別の場所に宛先が隠れる形を通さない'),
|
|
76
|
+
('n9 変数で宛先を組み立てる', f'{T} -h "$PGHOST" -p 55432 -c "{SEL}"',
|
|
77
|
+
'実行のときにしか宛先が決まらない形を通さない'),
|
|
78
|
+
('n10 コマンド置換で宛先を組み立てる', f'{T} -h $(cat /tmp/host.txt) -p 55432 -c "{SEL}"',
|
|
79
|
+
'実行のときにしか宛先が決まらない形を通さない'),
|
|
80
|
+
('n11 別名(名前)で手元に見せる', f'{T} -h localhost -p 55432 -c "{SEL}"',
|
|
81
|
+
'名前は別の機械へ向け替えられるので通さない'),
|
|
82
|
+
('n12 別名の変種', f'{T} -h localhost.localdomain -p 55432 -c "{SEL}"',
|
|
83
|
+
'名前は別の機械へ向け替えられるので通さない'),
|
|
84
|
+
('n13 トンネルを張ってから手元に見せる',
|
|
85
|
+
f'ssh -L 55432:{PROD}:5432 bastion & {T} -h 127.0.0.1 -p 55432 -c "{SEL}"',
|
|
86
|
+
'ポート転送で本番へ届く形を通さない'),
|
|
87
|
+
('n14 転送の道具と同じコマンド',
|
|
88
|
+
f'socat TCP-LISTEN:55432,fork TCP:{PROD}:5432 & {T} -h 127.0.0.1 -p 55432 -c "{SEL}"',
|
|
89
|
+
'ポート転送で本番へ届く形を通さない'),
|
|
90
|
+
('n15 束ねの道具の転送と同じコマンド',
|
|
91
|
+
f'kubectl port-forward svc/db 55432:5432 & {T} -h 127.0.0.1 -p 55432 -c "{SEL}"',
|
|
92
|
+
'ポート転送で本番へ届く形を通さない'),
|
|
93
|
+
('n16 手元だが使い捨てでない(ふつうの 5432)', f'{T} -h 127.0.0.1 -p 5432 -c "{DDL}"',
|
|
94
|
+
'その機で共有している DB を書き換えない'),
|
|
95
|
+
('n17 手元だがポートを書いていない', f'{T} -h 127.0.0.1 -c "{SEL}"',
|
|
96
|
+
'既定の宛先(共有の DB)へ黙って入らない'),
|
|
97
|
+
('n18 手元だが帯の外のポート', f'{T} -h 127.0.0.1 -p 6543 -c "{SEL}"',
|
|
98
|
+
'使い捨てと言い切れない宛先を通さない'),
|
|
99
|
+
('n19 引数は手元、接続文字列は本番', f'{T} -h 127.0.0.1 -p 55432 "postgresql://u@{PROD}:5432/postgres"',
|
|
100
|
+
'あとから効く宛先を見逃さない'),
|
|
101
|
+
('n20 宛先を書かず環境変数だけ', f'PGHOST=127.0.0.1 PGPORT=55432 {T} -c "{SEL}"',
|
|
102
|
+
'実行境界で確かめられない形を通さない'),
|
|
103
|
+
('n21 シェルの中に隠しても同じ', f"""bash -c "{T} -h {PROD} -p 5432 -c '{SEL}'" """.strip(),
|
|
104
|
+
'入れ子にしても本番へ届かない'),
|
|
105
|
+
('n22 消す道具は使い捨てでも通さない', f'{TOOL_DEL} -h 127.0.0.1 -p 55432 scratch',
|
|
106
|
+
'許したのは SQL の対話クライアント1つだけ'),
|
|
107
|
+
]
|
|
108
|
+
for name, cmd, guards in NEGATIVE:
|
|
109
|
+
check(f'{name}({guards})', decide(cmd), policy.DENY)
|
|
110
|
+
|
|
111
|
+
check('n23 作る道具は使い捨てでも関門のまま(許可を1件より広げない)',
|
|
112
|
+
decide(f'{TOOL_NEW} -h 127.0.0.1 -p 55432 scratch'), policy.GATE)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
116
|
+
# (p) 通ってよい形=手元の使い捨てだけ
|
|
117
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
POSITIVE = [
|
|
119
|
+
('p1 使い捨てへファイルを流す', f'{T} -h 127.0.0.1 -p 55432 -U postgres -d postgres -f /tmp/claude-1000/x.sql'),
|
|
120
|
+
('p2 使い捨てへ1行流す', f'{T} -h 127.0.0.1 -p 49152 -c "{SEL}"'),
|
|
121
|
+
('p3 使い捨てへ DDL を流す(SQL の中身は見ない)', f'{T} -h 127.0.0.1 -p 55432 -c "{DDL}"'),
|
|
122
|
+
('p4 手元の IPv6 の宛先', f'{T} -h ::1 -p 55432 -c "{SEL}"'),
|
|
123
|
+
('p5 宛先を = で書く', f'{T} --host=127.0.0.1 --port=55432 -c "{SEL}"'),
|
|
124
|
+
('p6 接続文字列も手元の使い捨て', f'{T} -h 127.0.0.1 -p 55432 "postgresql://postgres@127.0.0.1:55432/postgres"'),
|
|
125
|
+
]
|
|
126
|
+
for name, cmd in POSITIVE:
|
|
127
|
+
check(name, decide(cmd), policy.ALLOW)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
131
|
+
# (s) SQL の本文では許していない(同じ SQL でも宛先が違えば止まる)
|
|
132
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
check('s1 同じ SQL でも本番の宛先なら止まる', decide(f'{T} -h {PROD} -p 5432 -c "{SEL}"'), policy.DENY)
|
|
134
|
+
check('s2 同じ SQL でも使い捨てなら通る', decide(f'{T} -h 127.0.0.1 -p 55432 -c "{SEL}"'), policy.ALLOW)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
# (w) 許可を広げていない(この1件のほかは1つも変えていない)
|
|
139
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
UNCHANGED = [
|
|
141
|
+
('w1 移行の道具(押し込み)', f'{TOOL_MIG_A} db push', policy.DENY),
|
|
142
|
+
('w2 移行の道具(適用)', f'{TOOL_MIG_A} {WORD_MIG} deploy', policy.DENY),
|
|
143
|
+
('w3 中央の移行', f'{TOOL_CENTRAL} db push', policy.DENY),
|
|
144
|
+
('w4 別の移行の道具', f'{TOOL_MIG_B} push', policy.DENY),
|
|
145
|
+
('w5 台本ごしの移行', f'npm run {WORD_MIG}', policy.DENY),
|
|
146
|
+
('w6 REST の抜け道', f'curl -X POST https://x.example.co/rest/v1/rpc/{WORD_EXEC} -d @a.json', policy.DENY),
|
|
147
|
+
('w7 別の DB の道具', f'{TOOL_OTHER} -h 127.0.0.1 -P 55432 -e "{SEL}"', policy.GATE),
|
|
148
|
+
('w8 DB を消す道具', f'{TOOL_DEL} scratch', policy.DENY),
|
|
149
|
+
('w9 控えを戻す道具', f'{TOOL_RESTORE} -h 127.0.0.1 -p 55432 -d postgres dump.sql', policy.GATE),
|
|
150
|
+
('w10 控えを取る道具', f'{TOOL_BACKUP} -h 127.0.0.1 -p 55432 postgres', policy.GATE),
|
|
151
|
+
]
|
|
152
|
+
for name, cmd, want in UNCHANGED:
|
|
153
|
+
check(name, decide(cmd), want)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
157
|
+
# ★空振り確認: 決まりを緩めた形に戻すと、通ってはいけないものが通ってしまう
|
|
158
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
159
|
+
saved_hosts = policy._LOCAL_HOST_LITERALS
|
|
160
|
+
policy._LOCAL_HOST_LITERALS = frozenset(saved_hosts | {'localhost', 'localhost.localdomain'})
|
|
161
|
+
check('★空振り①: 別名も手元として許すと「見せかけの形」が通ってしまう',
|
|
162
|
+
decide(f'{T} -h localhost -p 55432 -c "{SEL}"'), policy.ALLOW)
|
|
163
|
+
policy._LOCAL_HOST_LITERALS = saved_hosts
|
|
164
|
+
check('★空振り①の戻し: いまの決まりでは止まる',
|
|
165
|
+
decide(f'{T} -h localhost -p 55432 -c "{SEL}"'), policy.DENY)
|
|
166
|
+
|
|
167
|
+
saved_port = policy._scratch_port_ok
|
|
168
|
+
policy._scratch_port_ok = lambda v: True
|
|
169
|
+
check('★空振り②: ポートの帯を見ないと「共有の 5432」が通ってしまう',
|
|
170
|
+
decide(f'{T} -h 127.0.0.1 -p 5432 -c "{SEL}"'), policy.ALLOW)
|
|
171
|
+
policy._scratch_port_ok = saved_port
|
|
172
|
+
check('★空振り②の戻し: いまの決まりでは止まる',
|
|
173
|
+
decide(f'{T} -h 127.0.0.1 -p 5432 -c "{SEL}"'), policy.DENY)
|
|
174
|
+
|
|
175
|
+
saved_tunnel = policy._TUNNEL_TOOLS
|
|
176
|
+
policy._TUNNEL_TOOLS = frozenset()
|
|
177
|
+
check('★空振り③: 回り道の道具を見ないと「転送ごしの形」が通ってしまう',
|
|
178
|
+
decide(f'ssh -L 55432:{PROD}:5432 bastion & {T} -h 127.0.0.1 -p 55432 -c "{SEL}"'), policy.ALLOW)
|
|
179
|
+
policy._TUNNEL_TOOLS = saved_tunnel
|
|
180
|
+
check('★空振り③の戻し: いまの決まりでは止まる',
|
|
181
|
+
decide(f'ssh -L 55432:{PROD}:5432 bastion & {T} -h 127.0.0.1 -p 55432 -c "{SEL}"'), policy.DENY)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
print()
|
|
185
|
+
if failures:
|
|
186
|
+
print(f'NG {len(failures)} 件')
|
|
187
|
+
for f in failures:
|
|
188
|
+
print(' -', f)
|
|
189
|
+
sys.exit(1)
|
|
190
|
+
print('すべて期待どおりです(negative test + 空振り確認)')
|