fix(verify-cli): proof / ZIP 由来の文字列を出力境界でサニタイズする (#266) - #279
Conversation
stdout に生値のまま流していたため、改行と ANSI エスケープを仕込むと検証結果に 任意の行を偽造できた。ZIP エントリ名 `evil_proof.json\n \e[32m✓\e[0m main_proof.json` で、2 件とも FAIL・exit 1 なのに Summary へ緑の `✓ main_proof.json` を生やせることを 実 CLI で再現していた。exit code は守られるので壊れるのは stdout を grep する採点運用と 端末表示だが、CLAUDE.md 不変条件 4 が「grep される可能性を考慮」と明記している。 #248 (PR #277) は `language` を allowlist に落として塞いだが、それは値ごとの対処で、 棚卸し (#243) した 11 箇所のうち閉じたのは 1 箇所だった。本コミットは境界側で一括して塞ぐ。 - `output.ts` に `safe()` を追加 (C0/C1 制御文字を除去・200 文字で切り詰め、 変化があったときだけ `(sanitized)` を付ける)。黙って消すと「元からその名前だった」と 読めてしまうため - exam の examId/problemId/variant/reason、time-box、reflection note、errorMessage、 errorAt、poswIterations、署名 cp の reason、analyzer の dimension/summary/note に適用 - `formatProofHeader` / `formatMultiSummary` を新設して `cli.ts` の `console.log` 直書きを 置き換え、ZIP エントリ名もサニタイズしつつテストを当てられるようにした 保証するのは「未信頼値が 1 行に収まり行頭を乗っ取れない」ところまで。行内に `Hash Chain: PASS` という文字列が残ることは正当な本文と区別できず防げないので、 その旨と「採点は行頭を固定して grep する」ことを doc と CLAUDE.md に明記した。
着手時のプラン#266 verify-cli の出力境界サニタイズContext
現行 HEAD ( 実際は 2 件とも FAIL・exit 1 なのに、Summary に緑の #248 (PR #277) は exit code は元々守られているので、直すのは表示の信頼性。判定ロジックには一切触らない。 方針
変更するファイル1.
|
| 箇所 | 値 |
|---|---|
formatExamSection :129 :135 :165 |
exam.binding.reason / examId / problemId / variant |
formatExamSection :151 |
tb.releaseTime / tb.deadline |
formatProcessSummary :207 |
reflection note (現状 \n だけ潰して \r・ESC 素通し → safe() に置換) |
formatResult :247 :259 :262 |
binding reason / errorMessage / errorAt |
:307 |
poswIterations (toLocaleString の前に数値検査、非数値なら safe()) |
:351 |
sc.reason (署名 cp の FAIL 理由) |
:412 :419 |
analyzer の dimension / summary / evidence note — 外部 --analyzer の出力も含むので未信頼 |
formatProofHeader(filename) と formatMultiSummary(entries) を新設して export する。現状 cli.ts が console.log で直接組み立てており (cli.ts:183 の === ${filename} ===、cli.ts:226 の ✓/✗ ${filename})、テストの当てようが無い。整形は output.ts に寄せる (ファイル構成のコメントどおり「output.ts = 結果の整形」)
2. packages/verify-cli/src/cli.ts
:183/:224-227を新設のformatProofHeader/formatMultiSummary呼び出しに置き換える。ロジックの変更はしない (件数計算・exit code は現状のまま)
3. packages/verify-cli/CLAUDE.md
- 不変条件 4 に「proof / ZIP 由来の文字列は
output.tsのsafe()を通してからでないと stdout へ出さない」を追記。文書ルールだけでは強制できないのでテストで縛る (下記) が、次に補間点を足す人が読む場所には置く
4. テスト packages/verify-cli/src/__tests__/output.test.ts
既存の plain() ヘルパ (ANSI 除去) を再利用しつつ追加:
formatMultiSummary: エントリ名に\n \x1b[32m✓\x1b[0m other.jsonを仕込んでも出力行数が entries 数 + 見出し 1 行のままであること (行数で縛るのが本質。文字列一致だと将来の文言変更で壊れる)formatProofHeader: 同じ名前で ESC / 改行が残らないことformatResult:errorMessageに改行 + 偽Hash Chain: PASSを仕込んでも--- Checks ---ブロックが 1 個のままであることformatResult: exam のexamIdに ANSI を仕込んでも ESC が出ないことsafe()単体: 制御文字を含む値に(sanitized)が付き、含まない値には付かないこと / 200 文字超の切り詰め
新テストは「壊して赤くなる」ことを確認してから仕上げる (safe() の呼び出しを 1 箇所外して赤くなるのを見る)。
触らないもの
- shared 側の
extractAllProofsFromZipは変えない。エントリ名の文字種で proof を落とすと「変な名前の proof が未検証で消える」という不変条件 3 (全件検証) の穴になる。表示だけの問題は表示境界で直す - 判定ロジック・exit code・
--analysis-json/--analysis-bundle(JSON 化されるので元々安全) - [repo] レビュー由来の小さな問題まとめ (dead code / stale comment / 表示の細かい不整合) #238 の verify-cli 項目 (
process.exit/ help /--analyzer実行順) は別 Issue のまま残す (1 PR = 1 関心事) - [shared/verify-cli] proof.exam.examId / variant が manifest と突き合わされず、CLI が自己申告値を Exam 行に表示する #273 (
examId/variantが manifest と突き合わされない = 自己申告値の表示ラベル問題) は別 Issue。本 PR は「壊れた文字を出さない」だけを担う
確認方法
-
ユニット:
npm run test:run -w @typedcode/verify-cli -
再現 ZIP で実挙動 (Issue [verify-cli] proof 由来の language が無検証で stdout に出る (偽の Checks 行を注入できる) #266 のコメントと同じ手順。
node dist/cli.jsは shared が raw TS なので不可 →npx tsx):NO_COLOR=1 npx tsx packages/verify-cli/src/cli.ts /path/to/evil.zip
- 修正前: Summary に緑の偽
✓ main_proof.json(grep -c 'main_proof.json'= 4) - 修正後: 偽行が消え
grep -c= 2、エントリ名は 1 行に潰れて(sanitized)付き、cat -vに^[が出ない
- 修正前: Summary に緑の偽
-
正常系の非退行: 制御文字を含まない通常の proof / ZIP で出力が現状と一致すること (
(sanitized)が付かない) -
npm run lint/npm run test:run --workspaces --if-present -
e2e (
npm run test -w @typedcode/e2e) は exit code と--analysis-jsonを見るので影響しないはずだが、Summary の整形を移したので念のため通す
記録
- PR 本文に
Closes #266、着手時のプランを PR コメントへ投稿 - 着地後に [tracking] 2026-08 プロジェクト全体レビュー (コンセプト + 実装) の指摘まとめ #243 (tracking) へ結果コメント → 次は [shared/verify-cli] proof.exam.examId / variant が manifest と突き合わされず、CLI が自己申告値を Exam 行に表示する #273
- マージ後に [verify-cli] proof 由来の language が無検証で stdout に出る (偽の Checks 行を注入できる) #266 の
status: in progressを外す
このプランは着手時点の判断です。実装の過程で変わった場合は、このコメントに返信する形で差分を残してください。
🤖 Assisted by Claude Code
🚀 Preview Deployment
Deployed from commit bb8cd1e |
目的
typedcode-verifyは proof / ZIP 由来の文字列を無加工で stdout に流していた。改行と ANSI エスケープを仕込むと検証結果に任意の行を偽造できる。現行
main(d24b7f9) で再現済み —main_proof.jsonと、名前に改行 + ANSI を仕込んだエントリを持つ ZIP を検証すると:実際は 2 件とも FAIL・exit 1 なのに、Summary に緑の
✓ main_proof.jsonが増える。NO_COLOR=1でも proof 由来の ESC は素通し (c()の抑制は CLI 自身の着色にしか効かない)。exit code は守られるので壊れるのは stdout を grep する採点運用と端末表示だが、verify-cli/CLAUDE.md 不変条件 4 が「パイプして grep される可能性を考慮」と明記している。
#248 (PR #277) は
languageを allowlist に落として塞いだが、それは値ごとの対処。#243 が棚卸しした「proof 由来で無検証のまま stdout に出る文字列」11 箇所のうち閉じたのは 1 箇所だけだった。本 PR は境界側で一括して塞ぐ (#266 推奨の切り方 PR-B)。変更点
output.tsにsafe(value: unknown)を追加 — C0 / C1 制御文字 (ESC・CR・LF 含む) を除去し 200 文字で切り詰める。変化が起きたときだけ(sanitized)を付ける: 黙って消すと「元からその名前だった」と読めてしまい、採点者が異常に気づけないexamId/problemId/variant/reason、time-box、reflection note、errorMessage/errorAt、poswIterations(非数値なら整形せずsafe())、署名 cp のreason、analyzer のdimension/summary/ evidence note (外部--analyzerの出力も未信頼)formatProofHeader/formatMultiSummaryを新設 —cli.tsがconsole.logで直接組み立てていてテストを当てられなかったので、整形をoutput.tsへ寄せた。ZIP エントリ名もここでサニタイズされる。ロジック (件数計算・exit code) は不変/へ畳んでから残る制御文字を落とす (旧実装は\rと ESC を素通ししていた)保証の範囲 (意図的な限界)
safe()が保証するのは「未信頼値が 1 行に収まり、行頭を乗っ取れない」ところまで。Error:行の中にHash Chain: PASSという文字列が残ることは正当な本文と区別できず防げない。採点は行頭を固定して grep することを doc コメントと CLAUDE.md 不変条件 5 に明記した。確認方法
再現 ZIP で実挙動 (
node dist/cli.jsは shared が raw TS なので不可 →npx tsx):grep -c '^ ✓'NO_COLOR=1でも)(sanitized)非退行: 制御文字を含まない通常の ZIP では、修正前後の stdout が
diffで完全一致 ((sanitized)も付かない)。テスト:
output.test.tsに 8 件追加 (safe()単体 4 / Summary の行数固定 2 / 見出し 1 /formatResultへの注入 3)。safe()を素通しに壊すと 6 件が赤くなることを確認済み (残り 2 件は「正常系は変わらない」を固定するテストなので緑のままが正しい)。npm run test:run --workspaces --if-present→ 全件 green (verify-cli 45 件)npm run typecheck -w @typedcode/verify-cli→ greennpm run lint→ error 0 (main と同じ warning 60 / info 9)npm run test -w @typedcode/e2e→ 15 passed / 2 failed。落ちた 2 件 (happy-path/close-tab-recovery) はmainを stash して同じ環境で回しても同じく落ちるローカル環境要因 (rootAnchoredが取れない) で、本 PR とは無関係残件
examId/variantが manifest と突き合わされない = 自己申告値の表示ラベル問題) は別 Issue のまま。本 PR は「壊れた文字を出さない」だけを担うprocess.exit/ help /--analyzerの実行順) も同ファイルだが 1 PR = 1 関心事で分けたCloses #266