Skip to content

fix(verify-cli): proof / ZIP 由来の文字列を出力境界でサニタイズする (#266) - #279

Merged
shinyaoguri merged 1 commit into
mainfrom
fix/verify-cli-output-sanitize
Aug 24, 2026
Merged

fix(verify-cli): proof / ZIP 由来の文字列を出力境界でサニタイズする (#266)#279
shinyaoguri merged 1 commit into
mainfrom
fix/verify-cli-output-sanitize

Conversation

@shinyaoguri

Copy link
Copy Markdown
Owner

目的

typedcode-verify は proof / ZIP 由来の文字列を無加工で stdout に流していた。改行と ANSI エスケープを仕込むと検証結果に任意の行を偽造できる

現行 main (d24b7f9) で再現済み — main_proof.json と、名前に改行 + ANSI を仕込んだエントリを持つ ZIP を検証すると:

=== Summary: 0/2 proofs passed ===
  ✗ evil_proof.json
  ^[[32m✓^[[0m main_proof.json      <-- ZIP エントリ名から生えた偽造行
  ✗ main_proof.json

実際は 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.tssafe(value: unknown) を追加 — C0 / C1 制御文字 (ESC・CR・LF 含む) を除去し 200 文字で切り詰める。変化が起きたときだけ (sanitized) を付ける: 黙って消すと「元からその名前だった」と読めてしまい、採点者が異常に気づけない
  • 未信頼値の補間点すべてに適用 — exam の examId / problemId / variant / reason、time-box、reflection note、errorMessage / errorAtposwIterations (非数値なら整形せず safe())、署名 cp の reason、analyzer の dimension / summary / evidence note (外部 --analyzer の出力も未信頼)
  • formatProofHeader / formatMultiSummary を新設cli.tsconsole.log で直接組み立てていてテストを当てられなかったので、整形を output.ts へ寄せた。ZIP エントリ名もここでサニタイズされる。ロジック (件数計算・exit code) は不変
  • reflection note は改行が正当な入力なので、従来どおり / へ畳んでから残る制御文字を落とす (旧実装は \r と ESC を素通ししていた)

保証の範囲 (意図的な限界)

safe() が保証するのは「未信頼値が 1 行に収まり、行頭を乗っ取れない」ところまで。Error: 行の中に Hash Chain: PASS という文字列が残ることは正当な本文と区別できず防げない。採点は行頭を固定して grep することを doc コメントと CLAUDE.md 不変条件 5 に明記した。

確認方法

再現 ZIP で実挙動 (node dist/cli.js は shared が raw TS なので不可 → npx tsx):

修正前 修正後
Summary の行数 見出し + 3 行 (偽造 1 行混入) 見出し + 2 行
grep -c '^ ✓' 1 (偽の合格) 0
出力中の ESC 有り (NO_COLOR=1 でも) 0
エントリ名の表示 2 行に割れる 1 行 + (sanitized)
exit code 1 1 (不変)

非退行: 制御文字を含まない通常の 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 → green
  • npm run lint → error 0 (main と同じ warning 60 / info 9)
  • npm run test -w @typedcode/e2e15 passed / 2 failed。落ちた 2 件 (happy-path / close-tab-recovery) は main を stash して同じ環境で回しても同じく落ちるローカル環境要因 (rootAnchored が取れない) で、本 PR とは無関係

残件

Closes #266

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 に明記した。
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Aug 24, 2026
@shinyaoguri

Copy link
Copy Markdown
Owner Author

着手時のプラン

#266 verify-cli の出力境界サニタイズ

Context

typedcode-verify は proof.json / ZIP 由来の文字列を無加工で stdout に流している。改行と ANSI エスケープを仕込むと、検証結果に任意の行を偽造できる。verify-cli/CLAUDE.md 不変条件 4 が「stdout はパイプして grep される可能性を考慮」と明記している以上、放置できない。

現行 HEAD (d24b7f9) で再現済み (Issue #266 の 2026-08-24 コメントに記録):

=== Summary: 0/2 proofs passed ===
  ✗ evil_proof.json
  ^[[32m✓^[[0m main_proof.json      <-- ZIP エントリ名から生えた偽造行
  ✗ main_proof.json

実際は 2 件とも FAIL・exit 1 なのに、Summary に緑の ✓ main_proof.json が増える。NO_COLOR=1 でも proof 由来の ESC は素通しする (c() の抑制は CLI 自身の着色にしか効かない)。

#248 (PR #277) は proof.language を allowlist に落として塞いだが、それは値ごとの対処であって出力境界そのものではない。#243 のトリアージが棚卸しした「proof 由来で無検証のまま stdout に出る文字列」11 箇所のうち、閉じたのは 1 箇所だけ。本 PR は境界側で一括して塞ぐ (Issue #266 推奨の切り方 PR-B)。

exit code は元々守られているので、直すのは表示の信頼性。判定ロジックには一切触らない。

方針

output.tssafe() を 1 つ置き、信頼できない値の補間点すべてに通す

変更するファイル

1. packages/verify-cli/src/output.ts (本体)

  • safe(value: unknown): string を追加 (module 冒頭、c() の近く)

  • 未信頼値の補間点に適用する。棚卸し ([tracking] 2026-08 プロジェクト全体レビュー (コンセプト + 実装) の指摘まとめ #243) の Bump the npm-dependencies group with 2 updates #1Update Open Graph and Twitter Card URLs in editor and verification pages #11 に対応:

    箇所
    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.tsconsole.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.tssafe() を通してからでないと 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 箇所外して赤くなるのを見る)。

触らないもの

確認方法

  1. ユニット: npm run test:run -w @typedcode/verify-cli

  2. 再現 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^[ が出ない
  3. 正常系の非退行: 制御文字を含まない通常の proof / ZIP で出力が現状と一致すること ((sanitized) が付かない)

  4. npm run lint / npm run test:run --workspaces --if-present

  5. e2e (npm run test -w @typedcode/e2e) は exit code と --analysis-json を見るので影響しないはずだが、Summary の整形を移したので念のため通す

記録


このプランは着手時点の判断です。実装の過程で変わった場合は、このコメントに返信する形で差分を残してください。

🤖 Assisted by Claude Code

@github-actions github-actions Bot added the pkg:verify packages/verify + verify-cli label Aug 24, 2026
@github-actions

Copy link
Copy Markdown

🚀 Preview Deployment

Name URL
Preview https://fb912cee.typedcode.pages.dev

Deployed from commit bb8cd1e
API: staging Workers

@shinyaoguri
shinyaoguri merged commit d61f126 into main Aug 24, 2026
8 checks passed
@shinyaoguri
shinyaoguri deleted the fix/verify-cli-output-sanitize branch August 24, 2026 02:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation pkg:verify packages/verify + verify-cli

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[verify-cli] proof 由来の language が無検証で stdout に出る (偽の Checks 行を注入できる)

1 participant