diff --git a/packages/verify-cli/CLAUDE.md b/packages/verify-cli/CLAUDE.md index 7667913..c8d4377 100644 --- a/packages/verify-cli/CLAUDE.md +++ b/packages/verify-cli/CLAUDE.md @@ -18,6 +18,7 @@ 2. **終了コード**: 0 = 成功、1 = 失敗 / エラー。これが CI で利用されるので変えない 3. **ZIP 内の proof は全件検証する**: exam/class はタブ毎に独立した `_proof.json` を N 個出力するので、`shared` の `extractAllProofsFromZip` で全件を取り出し、**1 件でも fail なら exit 1**。最初の 1 件だけ見ると未検証タブが exit 0 で通る (proof 判定は構造 `isProofFile` で、ファイル名順や `screenshots/manifest.json` に依存しない) 4. **stdout は人間向け、stderr はエラーログ**: パイプして grep される可能性を考慮 +5. **proof / ZIP 由来の文字列は `output.ts` の `safe()` を通してから stdout へ出す** (#266): 生値のままだと改行と ANSI エスケープで**任意の行を偽造できる** (ZIP エントリ名から Summary に緑の `✓ <正規ファイル名>` を生やせることを再現済み)。exit code は守られるので壊れるのは grep する採点運用と端末表示。`safe()` が保証するのは「未信頼値が 1 行に収まり行頭を乗っ取れない」ところまで — 行内に `Hash Chain: PASS` という**文字列**が残るのは防げないので、**採点は行頭を固定して** grep する。整形を `cli.ts` の `console.log` に直接書かない (テストを当てられなくなる。`formatProofHeader` / `formatMultiSummary` のように `output.ts` へ寄せる) ## ファイル構成 diff --git a/packages/verify-cli/src/__tests__/output.test.ts b/packages/verify-cli/src/__tests__/output.test.ts index 42079e5..f1d7b20 100644 --- a/packages/verify-cli/src/__tests__/output.test.ts +++ b/packages/verify-cli/src/__tests__/output.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest'; import type { AssuranceResult, ScreenshotVerificationSummary } from '@typedcode/shared'; -import { formatResult, type VerificationOutput } from '../output.js'; +import { formatMultiSummary, formatProofHeader, formatResult, safe, type VerificationOutput } from '../output.js'; import type { CLIExamResult } from '../verify.js'; /** 色付けは TTY 依存 (module load 時に決まる) なので、比較前に ANSI を落とす。 */ @@ -298,3 +298,133 @@ describe('formatResult — PoSW が再計算されなかったとき (fast モ expect(text).toContain('Verification FAILED'); }); }); + +/** + * 出力境界のサニタイズ (#266)。 + * + * proof / ZIP 由来の文字列を生値のまま stdout に流すと、改行と ANSI エスケープで**任意の行を + * 偽造できる**。実 CLI で「ZIP エントリ名から Summary に緑の `✓ main_proof.json` を生やす」 + * ところまで再現済み。exit code は守られるので、守るのは **stdout を grep する採点運用**。 + */ +const ESC = String.fromCharCode(27); + +/** Summary の合格行を偽造しにくる ZIP エントリ名。 */ +const FORGED_ENTRY_NAME = `evil_proof.json\n ${ESC}[32m✓${ESC}[0m main_proof.json`; + +describe('safe — 未信頼文字列の無害化 (#266)', () => { + it('leaves an ordinary value untouched (no noisy suffix on the happy path)', () => { + expect(safe('main_proof.json')).toBe('main_proof.json'); + }); + + it('strips control characters and says so', () => { + const result = safe(FORGED_ENTRY_NAME); + expect(result).not.toContain('\n'); + expect(result).not.toContain(ESC); + expect(result).toContain('(sanitized)'); + }); + + it('truncates an over-long value and says so', () => { + const result = safe('a'.repeat(500)); + expect(result).toBe(`${'a'.repeat(200)} (sanitized)`); + }); + + it('accepts non-strings (proof fields are self-asserted, not type-checked)', () => { + expect(safe(10000)).toBe('10000'); + expect(safe(undefined)).toBe('undefined'); + }); +}); + +describe('formatMultiSummary — ZIP エントリ名による合格行の偽造 (#266)', () => { + it('keeps one line per proof even when an entry name carries a newline', () => { + const text = plain( + formatMultiSummary([ + { filename: FORGED_ENTRY_NAME, valid: false }, + { filename: 'main_proof.json', valid: false }, + ]) + ); + + // 見出し 1 行 + proof 2 行。偽造行が混ざれば 4 行になる。 + expect(text.trim().split('\n')).toHaveLength(3); + expect(text).not.toContain(ESC); + // 合格印は行頭にしか立たない。名前の中に残る `✓` の文字そのものは無害。 + expect(text.split('\n').filter((l) => /^ {2}✓/.test(l))).toHaveLength(0); + expect(text).toContain('0/2 proofs passed'); + }); + + it('still marks genuinely passing proofs', () => { + const text = plain(formatMultiSummary([{ filename: 'ok_proof.json', valid: true }])); + expect(text).toContain('1/1 proofs passed'); + expect(text).toContain(`✓ ok_proof.json`); + }); +}); + +describe('formatProofHeader — proof ごとの見出し (#266)', () => { + it('collapses a forged entry name into a single header line', () => { + const text = plain(formatProofHeader(FORGED_ENTRY_NAME)); + expect(text.trim().split('\n')).toHaveLength(1); + expect(text).not.toContain(ESC); + expect(text).toContain('(sanitized)'); + }); +}); + +describe('formatResult — proof 由来の文字列による偽セクションの注入 (#266)', () => { + it('does not let errorMessage open a second Checks block', () => { + const text = plain( + formatResult( + output({ + valid: false, + chainValid: false, + errorMessage: `Hash mismatch\n\n--- Checks ---\nHash Chain: PASS`, + }) + ) + ); + + // 見出しとして立つ `--- Checks ---` は 1 つだけ。注入分は `Error:` 行の中に留まる。 + const lines = text.split('\n'); + expect(lines.filter((l) => l.trim() === '--- Checks ---')).toHaveLength(1); + expect(lines.filter((l) => /^Hash Chain: +PASS/.test(l))).toHaveLength(0); + }); + + it('does not emit ANSI escapes carried by a self-asserted examId', () => { + const text = formatResult( + output({ + valid: false, + exam: { ...examBindingFailed(), examId: `exam-1${ESC}[32m` }, + }) + ); + + expect(text).not.toContain(`exam-1${ESC}[32m`); + expect(plain(text)).toContain('(sanitized)'); + }); + + it('does not let a reflection note inject a line, but keeps its newlines readable', () => { + const text = plain( + formatResult( + output({ + processSummary: { + durationMs: 0, + insertedChars: 0, + deletedChars: 0, + deletionRatio: null, + executionCount: 0, + runSuccessCount: 0, + runFailureCount: 0, + hasRunResults: false, + pauseCount: 0, + focusLossCount: 0, + externalInputCount: 0, + reflectionNotes: [`first${ESC}[31m\nsecond`], + moments: [], + }, + }) + ) + ); + + const reflection = text.split('\n').filter((l) => l.startsWith('Reflection:')); + expect(reflection).toHaveLength(1); + // 改行は ` / ` へ畳んで読めるまま、ESC だけが落ちる (`[31m` の文字は無害なので残る)。 + expect(reflection[0]).toContain(' / second'); + expect(reflection[0]).not.toContain(ESC); + expect(reflection[0]).toContain('(sanitized)'); + }); +}); diff --git a/packages/verify-cli/src/cli.ts b/packages/verify-cli/src/cli.ts index 0cea4f7..d333e6a 100644 --- a/packages/verify-cli/src/cli.ts +++ b/packages/verify-cli/src/cli.ts @@ -10,7 +10,7 @@ import { resolve, extname } from 'node:path'; import { verifyProof, toBundleIntegrityValid, type ProofFile } from './verify.js'; import { extractAllProofs, extractScreenshotArtifacts } from './zip.js'; import { loadExternalAnalyzers } from './analyzers.js'; -import { formatResult, printError, printUsage } from './output.js'; +import { formatMultiSummary, formatProofHeader, formatResult, printError, printUsage } from './output.js'; import { Spinner } from './progress.js'; import { parseExamPackageManifest, @@ -180,7 +180,7 @@ async function main(): Promise { const analysisDump: Array<{ filename: string; valid: boolean; analysis: unknown }> = []; const bundleDump: Array<{ filename: string } & AnalysisBundle> = []; for (const { filename, proof } of proofs) { - if (multi) console.log(`\n=== ${filename} ===`); + if (multi) console.log(formatProofHeader(filename)); const result = await verifyProof(proof, { mode, examPackageManifest, @@ -220,11 +220,7 @@ async function main(): Promise { } if (multi) { - const passed = summary.filter((s) => s.valid).length; - console.log(`\n=== Summary: ${passed}/${summary.length} proofs passed ===`); - for (const s of summary) { - console.log(` ${s.valid ? '✓' : '✗'} ${s.filename}`); - } + console.log(formatMultiSummary(summary)); } process.exit(summary.every((s) => s.valid) ? 0 : 1); diff --git a/packages/verify-cli/src/output.ts b/packages/verify-cli/src/output.ts index 37398ad..fd1a5c7 100644 --- a/packages/verify-cli/src/output.ts +++ b/packages/verify-cli/src/output.ts @@ -18,6 +18,36 @@ function c(color: keyof typeof COLORS, text: string): string { return useColors ? `${COLORS[color]}${text}${COLORS.reset}` : text; } +/** 未信頼値を 1 行に収めるための表示上限。超えた分は切り詰めて `(sanitized)` を付ける。 */ +const MAX_UNTRUSTED_LENGTH = 200; + +/** + * proof / ZIP 由来の**未信頼**文字列を stdout へ出す前に無害化する (#266)。 + * + * CLI の stdout はパイプして grep される (不変条件 4)。生値のままだと改行で任意の行を、 + * ANSI エスケープで色と既存行の上書きを注入でき、`✓ main_proof.json` のような + * **偽の合格行を採点スクリプトに読ませられる** (ZIP エントリ名で実際に再現済み)。 + * + * - C0 / C1 制御文字 (ESC・CR・LF を含む) を除去する + * - `MAX_UNTRUSTED_LENGTH` で切り詰める + * - **除去か切り詰めが起きたときだけ `(sanitized)` を付ける**。黙って消すと「元からその名前 + * だった」と読めてしまい、採点者が異常に気づけない + * + * 保証するのは「**未信頼値が 1 行に収まり、行頭を乗っ取れない**」ところまで。`Error:` 行の中に + * `Hash Chain: PASS` という**文字列**が残ることは防げない (正当な本文と区別できない) ので、 + * 採点スクリプトは `^Hash Chain:` のように**行頭を固定して** grep すること。 + * + * 注意: `c()` による着色は `safe()` の**外側**で行うこと。内側に入れると CLI 自身の ANSI まで + * 落ちる。また非文字列も受ける — 未検証の proof には数値であるべき欄に文字列が入りうる。 + */ +export function safe(value: unknown): string { + const raw = typeof value === 'string' ? value : String(value); + // biome-ignore lint/suspicious/noControlCharactersInRegex: 制御文字の除去そのものが目的 + const stripped = raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, ''); + const truncated = stripped.slice(0, MAX_UNTRUSTED_LENGTH); + return truncated === raw ? truncated : `${truncated} (sanitized)`; +} + import type { VerificationMode, SignedCheckpointsVerificationResult, @@ -126,13 +156,15 @@ function formatExamSection(exam: CLIExamResult, lines: string[]): void { lines.push(c('red', ' ! --exam-package was provided but this proof has no exam block')); lines.push(c('dim', ' This submission is not an exam proof — the exam binding gate does not apply to it.')); if (exam.binding?.reason) { - lines.push(c('red', ` Reason: ${exam.binding.reason}`)); + lines.push(c('red', ` Reason: ${safe(exam.binding.reason)}`)); } return; } - const variant = exam.variant ? ` / ${exam.variant}` : ''; - lines.push(`Exam: ${exam.examId} / ${exam.problemId}${variant}`); + // examId / problemId / variant は proof の自己申告 (#273 で manifest 突合を検討中)。 + // 突合前でも「壊れた文字を出さない」ことだけは境界で保証する (#266)。 + const variant = exam.variant ? ` / ${safe(exam.variant)}` : ''; + lines.push(`Exam: ${safe(exam.examId)} / ${safe(exam.problemId)}${variant}`); // root 束縛は proof 自己完結 (package 不要)。 lines.push(`Root binding: ${passFail(exam.rootBindingValid)} ${c('dim', '(answer bound to package + T0)')}`); @@ -148,7 +180,7 @@ function formatExamSection(exam: CLIExamResult, lines: string[]): void { lines.push(`Content hash: ${passFail(b.problemContentHashMatches)}`); if (b.timeBox) { const tb = b.timeBox; - lines.push(`Time-box: ${tb.releaseTime} … ${tb.deadline}`); + lines.push(`Time-box: ${safe(tb.releaseTime)} … ${safe(tb.deadline)}`); if (tb.withinWindow === null) { lines.push(c('dim', ' (submission time not provided — pass --submitted-at to check the window)')); } else if (tb.withinWindow) { @@ -162,7 +194,7 @@ function formatExamSection(exam: CLIExamResult, lines: string[]): void { } } if (!b.valid && b.reason) { - lines.push(c('red', ` Reason: ${b.reason}`)); + lines.push(c('red', ` Reason: ${safe(b.reason)}`)); } } @@ -204,7 +236,10 @@ function formatProcessSummary(p: ProcessSummary): string[] { `Activity: ${runs}, ${p.pauseCount} long pause(s), ${p.focusLossCount} focus loss(es), ${p.externalInputCount} external input(s)` ); for (const note of p.reflectionNotes) { - lines.push(`Reflection: ${note.replace(/\n/g, ' / ')}`); + // 学習者が自由入力した文字列 = 未信頼 (#266)。改行は正当な入力なので ` / ` へ畳んでから + // (捨てると文が繋がって読めなくなる)、残る制御文字を safe() で落とす。旧実装は \r と ESC を + // 素通ししていた。 + lines.push(`Reflection: ${safe(note.replace(/\n/g, ' / '))}`); } for (const m of p.moments) { const range = @@ -244,7 +279,7 @@ export function formatResult(result: VerificationOutput): string { if (examBindingFailedOnly || screenshotsFailedOnly) { // 両方落ちることもある (exam proof の ZIP でスクショも改ざん) ので、片方に潰さず両方出す。 if (examBindingFailedOnly) { - lines.push(c('red', ` Exam binding failed: ${result.exam!.binding!.reason ?? 'see section below'}`)); + lines.push(c('red', ` Exam binding failed: ${safe(result.exam!.binding!.reason ?? 'see section below')}`)); } if (screenshotsFailedOnly) { lines.push( @@ -256,10 +291,11 @@ export function formatResult(result: VerificationOutput): string { } } else { if (result.errorMessage) { - lines.push(c('red', ` Error: ${result.errorMessage}`)); + // 検証失敗の理由には proof 由来の値が埋め込まれる (`got ${claimed[key]}` など)。 + lines.push(c('red', ` Error: ${safe(result.errorMessage)}`)); } if (result.errorAt !== undefined) { - lines.push(c('red', ` Failed at event: ${result.errorAt}`)); + lines.push(c('red', ` Failed at event: ${safe(result.errorAt)}`)); } } } @@ -304,7 +340,12 @@ export function formatResult(result: VerificationOutput): string { if (result.poswIterations) { const poswStatus = result.poswSkipped ? c('yellow', 'SKIPPED (fast mode)') : c('green', 'VERIFIED'); - lines.push(`PoSW: ${result.poswIterations.toLocaleString()} iterations/event — ${poswStatus}`); + // 型は number だが値は proof の自己申告なので、非数値が来たら整形せず safe() で出す (#266)。 + const iterations = + typeof result.poswIterations === 'number' && Number.isFinite(result.poswIterations) + ? result.poswIterations.toLocaleString() + : safe(result.poswIterations); + lines.push(`PoSW: ${iterations} iterations/event — ${poswStatus}`); } if (result.mode) { @@ -348,7 +389,8 @@ export function formatResult(result: VerificationOutput): string { lines.push(c('yellow', ' ! Some envelopes signed with a key that was later revoked')); } } else { - lines.push(`Anchoring: ${c('red', 'FAILED')} ${sc.reason ?? ''}`); + // reason には proof 由来の keyId / algorithm が埋め込まれる。 + lines.push(`Anchoring: ${c('red', 'FAILED')} ${sc.reason ? safe(sc.reason) : ''}`); } } @@ -409,14 +451,15 @@ export function formatResult(result: VerificationOutput): string { : s.severity === 'notice' ? c('yellow', 'NOTICE') : c('dim', 'INFO'); - lines.push(` [${tag}] ${s.dimension}: ${s.summary}`); + // signal の文言は proof 由来に加え、外部 `--analyzer` (ADR-0023) の出力も入る = 未信頼。 + lines.push(` [${tag}] ${safe(s.dimension)}: ${safe(s.summary)}`); // 証拠リンク (ADR-0009 で必須): 人間が当該イベントを検分できるよう event index を出す。 for (const ev of s.evidence) { const range = ev.toEventIndex !== undefined && ev.toEventIndex !== ev.fromEventIndex ? `events ${ev.fromEventIndex}–${ev.toEventIndex}` : `event ${ev.fromEventIndex}`; - lines.push(c('dim', ` evidence: ${range}${ev.note ? ` (${ev.note})` : ''}`)); + lines.push(c('dim', ` evidence: ${range}${ev.note ? ` (${safe(ev.note)})` : ''}`)); } } } @@ -427,6 +470,31 @@ export function formatResult(result: VerificationOutput): string { return lines.join('\n'); } +/** + * ZIP 内の proof ごとの見出し (複数 proof のときだけ出る)。 + * + * ファイル名は **ZIP エントリ名** = 攻撃者が自由に決められる文字列 (#266)。整形を `cli.ts` の + * `console.log` に置いたままだとテストを当てられないので、出力の組み立てはここに寄せる。 + */ +export function formatProofHeader(filename: string): string { + return `\n=== ${safe(filename)} ===`; +} + +/** + * 複数 proof の合否一覧。 + * + * 採点運用はこの `✓` 行を grep する。エントリ名に改行を仕込まれても**行数が entries.length + 1 + * のまま**であることが不変条件で、`output.test.ts` がそれを固定する (#266)。 + */ +export function formatMultiSummary(entries: ReadonlyArray<{ filename: string; valid: boolean }>): string { + const passed = entries.filter((e) => e.valid).length; + const lines = [`\n=== Summary: ${passed}/${entries.length} proofs passed ===`]; + for (const e of entries) { + lines.push(` ${e.valid ? '✓' : '✗'} ${safe(e.filename)}`); + } + return lines.join('\n'); +} + export function printError(message: string): void { console.error(c('red', `Error: ${message}`)); }