Jupyter Notebookの合成見本で、保存済みの実行結果に残る鍵風の文字列と作業パスを探した

Jupyter Notebookの合成見本で、保存済みの実行結果に残る鍵風の文字列と作業パスを探した

print(key)を一度実行して保存した.ipynbには、その値が文字列のままファイルに残ります。見本でgrep -c 'sk-demo'を打つと、結果は1でした。

見積もりテンプレートのノートブックをデジタル商品として渡す場面を考えます。AIにコードを書かせ、手元で動かして確かめ、そのまま保存する。流れとしては自然です。ただし今回の.ipynbは、検査用に私が作った5セルの合成見本です。AIの出力でも、実際に売った商品でもありません。鍵に見えるsk-demo-...も、動作確認のために私が決めた偽の値です。

ここまでPDFやMarkdown、JSONを順に検査してきましたが、ノートブックは勝手が違います。完成物の中に、実行した跡が保存されるからです。

保存されるものを先に見る

.ipynbは中身がJSONのファイルです。nbformatの説明では、コードセルはsourceのほかにexecution_count(整数かnull)とoutputsの一覧を持ちます。printの文字列はstream、最後の式の値はexecute_result、例外はerrorとして、種類ごとに残ります。

つまり「コードは無害なのに、出力に私物が入っている」状態が作れます。動かして保存した人は、画面上の結果をもう見慣れていて、気づきにくい部分です。

環境はPython 3.13.5です。ノートブックを動かす部品(カーネル)は、作業用の仮想環境の中へ入れました。

python3 -m venv .venv
.venv/bin/pip install -q nbformat nbconvert ipykernel
.venv/bin/pip list | grep -E "^(nbformat|nbconvert|ipykernel) "
.venv/bin/python -m ipykernel install --sys-prefix --name python3
ipykernel                 7.4.0
nbconvert                 7.17.2
nbformat                  5.11.1
Installed kernelspec python3 in .venv/share/jupyter/kernels/python3

最後の行は、出力にあった絶対パスを.venvからの相対表記に直して載せています。

実は最初、最後のコマンドに--sys-prefixではなく--userを付けて実行しました。こちらは仮想環境の外、ホームディレクトリ側にカーネル定義を書きます。検証の後始末が増えるので、そのディレクトリは消して、環境ごと作り直しました。

5セルの見本を作る

見積もりテンプレートは、単価6,000円、作業3.5時間、税率10%で23,100円を出すだけです。セルの番号は0始まりです。0番は見出しのMarkdownセルで、1〜4番がコードセルです。4番はdata/price.csvを読みます。このファイルは、商品フォルダへ入れ忘れた状態にしてあります。

make_notebook.py(見本を作るスクリプト全文)
import nbformat as nbf

nb = nbf.v4.new_notebook()
nb.cells = [
    nbf.v4.new_markdown_cell("# 見積もりテンプレート\n\n単価と作業時間を入れると、税込の見積額を出します。"),
    nbf.v4.new_code_cell("from pathlib import Path\nprint(Path.cwd())"),
    nbf.v4.new_code_cell("import os\nkey = os.environ.get('DEMO_API_KEY', 'unset')\nprint('key =', key)"),
    nbf.v4.new_code_cell("unit_price = 6000\nhours = 3.5\nprint(round(unit_price * hours * 1.1))"),
    nbf.v4.new_code_cell("open('data/price.csv').read()"),
]
nbf.write(nb, "estimate.ipynb")

作者のつもりで、偽のキーを環境変数に入れて全セルを実行し、結果ごと保存します。4番のセルは失敗しますが、--allow-errorsを付けて最後まで進めます。

PY="$PWD/.venv/bin/python"
$PY make_notebook.py
DEMO_API_KEY=sk-demo-0123456789abcdef $PY -m jupyter nbconvert --to notebook --execute --allow-errors estimate.ipynb --output estimate-saved.ipynb 2>&1 | grep -v WARNING
[NbConvertApp] Converting notebook estimate.ipynb to notebook
[NbConvertApp] Writing 3454 bytes to estimate-saved.ipynb

grep -v WARNINGは、カーネルが暗号化なしのTCPで動くという警告行を省くためです。この警告は検査の結果と関係がありません。

保存した.ipynbを検査する

検査スクリプトは標準ライブラリだけで書きました。見るのは4点です。コードセルに実行番号が残っていないか。出力が残っていないか。/home/か/Users/で始まるパスが含まれていないか。sk-やghp_、AKIAで始まる鍵風の文字列がないか。見つけた値は、先頭4文字だけを表示します。報告そのものに秘密を載せないためです。

鍵風の判定は、sk-の後ろに16文字以上が続く形にしました。条件を緩めてsk-だけで拾うと、task-managerのような普通の単語にも当たります。ただし16という数は、実在する鍵の長さを調べて決めた値ではありません。今回の偽の値は後ろに21文字が続くので通る、という程度の仮置きです。

nb_preflight.py(検査スクリプト全文)
import json, re, sys
from pathlib import Path

PATTERNS = {
    "home-path": re.compile(r"(/home/[^/\s'\"\\]+|/Users/[^/\s'\"\\]+)"),
    "key-like": re.compile(r"(sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16})"),
}


def mask(s):
    return s[:4] + "****"


def scan(text, where, problems):
    for name, pat in PATTERNS.items():
        for m in pat.finditer(text):
            problems.append(f"{name} at {where}: {mask(m.group(0))}")


def main(path):
    nb = json.loads(Path(path).read_text(encoding="utf-8"))
    problems = []
    for i, cell in enumerate(nb["cells"]):
        if cell["cell_type"] != "code":
            continue
        scan("".join(cell["source"]), f"cell {i} source", problems)
        if cell.get("execution_count") is not None:
            problems.append(f"execution_count at cell {i}: {cell['execution_count']}")
        for j, out in enumerate(cell.get("outputs", [])):
            problems.append(f"stored output at cell {i}: {out['output_type']}")
            scan(json.dumps(out, ensure_ascii=False), f"cell {i} output {j}", problems)
    for p in problems:
        print(p)
    print(f"problems={len(problems)}")
    return 1 if problems else 0


sys.exit(main(sys.argv[1]))
$PY nb_preflight.py estimate-saved.ipynb; echo exit=$?
execution_count at cell 1: 1
stored output at cell 1: stream
home-path at cell 1 output 0: /hom****
execution_count at cell 2: 2
stored output at cell 2: stream
key-like at cell 2 output 0: sk-d****
execution_count at cell 3: 3
stored output at cell 3: stream
execution_count at cell 4: 4
stored output at cell 4: error
problems=10
exit=1

10件の内訳は次のとおりです。

種類件数場所
実行番号が残っている4セル1〜4
出力が残っている4セル1〜4(セル4はエラー)
作業ディレクトリの絶対パス1セル1の出力
鍵風の文字列1セル2の出力

セル4のerrorは、買った人にそのまま見える失敗です。ノートブックを開くと、4番のセルの下にトレースバックが出ます。件数以上に、商品の印象へ響く部分です。購入直後のお客さんがまず目にするのが、赤いエラー表示というのは避けたい形です。

ソースだけを直した場合も試しました。セル2からprintの行を消し、ノートブックをnbformatのPython APIで書き直して同じ検査にかけます。画面の操作ではなくファイルの書き換えですが、結果は同じ10件でした。ソースを直しても、前回の出力は消えません。消したつもりで一番危ないのは、この形だと感じます。

実行前のestimate.ipynbに同じ検査をかけると、0件で通ります。当たり前ですが、これで一度安心しかけました。検査の対象は、保存し終えた商品ファイルです。

出力を消す。ただし、これで終わりにしない

nbconvertには、出力を消して同じファイルへ上書きする--clear-outputがあります。元の保存済みファイルは比べる相手として残したいので、複製に対して実行します。

cp estimate-saved.ipynb estimate-clean.ipynb
$PY -m jupyter nbconvert --clear-output --inplace estimate-clean.ipynb 2>&1 | grep -v WARNING
$PY nb_preflight.py estimate-clean.ipynb; echo exit=$?
grep -c 'sk-demo' estimate-saved.ipynb; grep -c 'sk-demo' estimate-clean.ipynb
[NbConvertApp] Converting notebook estimate-clean.ipynb to notebook
[NbConvertApp] Writing 2386 bytes to estimate-clean.ipynb
problems=0
exit=0
1
0

偽のキーを含む行は1から0になりました。検査も0件、終了コード0です。

ここで止めたくなりました。表示はすっかりきれいです。ただ、私が確かめたのは、消えたものがあるかどうかだけです。このノートブックが動くかどうかは、まだ何も見ていません。

別のディレクトリで、環境変数なしで動かす

商品フォルダと同じ中身だけを新しいディレクトリに置き、DEMO_API_KEYを外して実行します。買う人の手元に近い状況です。失敗時のトレースバックは長いので、ログはファイルへ逃がし、読む行だけをgrepで拾います。

rm -rf room1 && mkdir room1 && cp estimate-clean.ipynb room1/ && cd room1
env -u DEMO_API_KEY $PY -m jupyter nbconvert --to notebook --execute estimate-clean.ipynb --output ../executed1.ipynb > ../run1.log 2>&1; echo exit=$?
grep -E "FileNotFoundError|^Cell In" ../run1.log
cd ..
exit=1
FileNotFoundError                         Traceback (most recent call last)
FileNotFoundError: [Errno 2] No such file or directory: 'data/price.csv'

出力を消した後の検査は0件でした。それでも、買った人が4番のセルで必ず止まります。検査スクリプトは、同梱されていないファイルを知りません。これは思っていたより大事な発見でした。0件という数字だけでは、動くという保証になりません。検査の合格と、商品として動くことは別の話です。

data/price.csvを足して、もう一度同じ方法で動かします。

mkdir -p room2/data && cp estimate-clean.ipynb room2/
printf 'item,unit_price\n作業1時間,6000\n' > room2/data/price.csv
cd room2
env -u DEMO_API_KEY $PY -m jupyter nbconvert --to notebook --execute estimate-clean.ipynb --output ../executed2.ipynb > ../run2.log 2>&1; echo exit=$?
cd ..
exit=0

--output ../executed2.ipynbで実行結果を別のファイルへ書いたので、room2の中の商品ファイルには出力が戻りません。確認用に保存したexecuted2.ipynbは商品へ入れません。その各セルの出力は次のとおりです。

セル出力
1実行した場所のパス(買った人自身の環境のもの)
2key = unset
323100
4'item,unit_price\n作業1時間,6000\n'

数字は想定どおり、6,000円×3.5時間×1.1で23,100円です。キーは未設定のまま、エラーもありません。

セル1のprint(Path.cwd())は、そのまま商品へ残しました。買った人の手元では、その人のディレクトリを表示するだけで害はありません。残したのは、出力に絶対パスが出る例を作りたかったからです。売る商品では、この行を消します。

最後に商品側のファイルを再検査し、cmpで出力を消した直後のファイルと同一か確かめました。

$PY nb_preflight.py room2/estimate-clean.ipynb; echo exit=$?
cmp estimate-clean.ipynb room2/estimate-clean.ipynb && echo same
problems=0
exit=0
same

まだ見ていないもの

出力を消す専用ツールは他にもあり、nbstripoutという名前は知っています。今回は試していないので、nbconvertとの比較はしていません。

検査が0件だったのは、見たものが無かったという意味です。危険がない証明ではありません。見ていない場所は、はっきりしています。

  • Markdownセルの本文は走査していません。/home/が説明文に混ざっても通ります。
  • 鍵の形は3種類だけです。未知の形式の文字列は素通りします。
  • 画像出力(グラフのPNGなど)の中身、ウィジェットのメタデータ、Gitの履歴は見ていません。
  • env -uで外せるのは環境変数だけです。ホームディレクトリの設定ファイルや、作者の環境にだけ入っている別のパッケージが結果を変える場合は、この方法で差を消せません。
  • 実行後の出力そのものを商品の見本にしたい場合は、消す方針が合いません。その時は、偽の値と偽のパスだけで動かした結果を保存し直すのが、今のところ私の案です。この案はまだ試していません。

私なら、ノートブックを渡す直前の手順を3つに固定します。出力を消す。別のディレクトリへ置いて、環境変数なしで最後まで実行する。商品側のファイルに検査をかける。出力を消す工程を抜くと、偽のキーとパスが残ります。別ディレクトリでの実行を抜くと、同梱漏れが残ります。検査を抜くと、消し忘れに気づけません。

出典

この記事をシェア