Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/verify-cli/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
2. **終了コード**: 0 = 成功、1 = 失敗 / エラー。これが CI で利用されるので変えない
3. **ZIP 内の proof は全件検証する**: exam/class はタブ毎に独立した `<name>_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` へ寄せる)

## ファイル構成

Expand Down
132 changes: 131 additions & 1 deletion packages/verify-cli/src/__tests__/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 を落とす。 */
Expand Down Expand Up @@ -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)');
});
});
10 changes: 3 additions & 7 deletions packages/verify-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -180,7 +180,7 @@ async function main(): Promise<void> {
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,
Expand Down Expand Up @@ -220,11 +220,7 @@ async function main(): Promise<void> {
}

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);
Expand Down
94 changes: 81 additions & 13 deletions packages/verify-cli/src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)')}`);

Expand All @@ -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) {
Expand All @@ -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)}`));
}
}

Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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(
Expand All @@ -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)}`));
}
}
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) : ''}`);
}
}

Expand Down Expand Up @@ -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)})` : ''}`));
}
}
}
Expand All @@ -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}`));
}
Expand Down
Loading