diff --git a/packages/shared/CLAUDE.md b/packages/shared/CLAUDE.md index 4f0b517..9b87ac0 100644 --- a/packages/shared/CLAUDE.md +++ b/packages/shared/CLAUDE.md @@ -19,6 +19,7 @@ 8. **`isPureTyping` の判定点は 1 つ** (#235): `structuralEdit.ts` の `evaluatePureTyping` だけが決める。export 時の自己申告 (`TypingProof`) と採点側の再計算 (`verifyProofMetadata`) が同じ関数を呼ぶので、proof に焼かれる値と検証器の結論が食い違わない。**別の場所に式を書き足さないこと**。なお `metadata.bulkInsertEvents` は `isSuspiciousBulkInsert` の素のカウントのままで、こちらは申告値との完全一致を要求するので定義を変えると既存 proof が invalid になる 9. **ZIP エントリの展開は予算経由** (#234): `fileProcessing/zipBudget.ts` の `ZipExtractionBudget` + `readZipEntryBytes` / `readZipEntryText` だけが JSZip からバイトを取り出す。ヘッダの申告 `uncompressedSize` は攻撃者が書ける値なので `assertZipWithinBudget` は**早期 reject 専用** (それだけでは 300 MiB を 1 バイトと名乗る ZIP を素通しする)。上限は展開中の**実バイト**で見る。`entry.async(...)` の直接呼び出しは `__tests__/zipReadPolicy.test.ts` が禁止する 10. **registry の日時判定は 1 箇所** (#233): `checkpointKeys/keyValidity.ts` の `checkRegistryKeyValidityAt` だけが `validFrom` / `validUntil` / `revokedAt` を解釈する。署名 cp (anchor=`serverTimestamp`) / sessionStartToken (`issuedAt`) / 出題者鍵 (`releaseTime`) の 3 消費者はここを呼び、**文言づけだけ**を各自で持つ。日時は system-spec §9.4 の手書き運用なので **parse 不能なら entry 単位で信頼しない** (fail-closed) — かつては 3 箇所とも `Number.isFinite` ガードで検査ごと素通りし、失効済み鍵が警告すら出さずに通っていた。出荷 registry の形式は `__tests__/registryFormat.test.ts` が縛る (`Date.parse` は `2026-02-30` をロールオーバーして受け入れるので、厳密 ISO 正規表現 + 往復比較の両方が要る) +11. **proof 由来の自己申告文字列は表示前に allowlist へ落とす** (#210 / #248): `proof.json` は攻撃者が組み立てられる入力で、型注釈は `JSON.parse` の結果を cast しただけ。表示層は値を補間する (verify は `className`、verify-cli は stdout) ので、`language` は `fileProcessing/proofLanguage.ts` の `normalizeProofLanguage` を通す (集合外は `'unknown'`)。allowlist の元は `languageDetection.ts` の `KNOWN_LANGUAGES` **1 箇所**で、別に書き起こすとドリフトする。`mode` は verify 側の `normalizeProofMode` が同じ契約を持つ。**表示に使う自己申告フィールドを増やすときは、まず正規化の置き場を決めること** ## モジュール一覧 @@ -40,7 +41,7 @@ | `verification.ts` | チェーン外検証ユーティリティ (content replay 等) | | `poswWorker.ts` | PoSW Web Worker 本体 | | `attestation.ts` | 人間認証クライアント | -| `fileProcessing/` | ZIP / JSON 解析。`zipBudget.ts` が ZIP 展開の上限 (エントリ数・実バイト予算) と予算付きリーダーを持つ | +| `fileProcessing/` | ZIP / JSON 解析。`zipBudget.ts` が ZIP 展開の上限 (エントリ数・実バイト予算) と予算付きリーダーを持つ。`proofLanguage.ts` は proof 由来の自己申告 `language` を allowlist (`languageDetection.ts` の `KNOWN_LANGUAGES`) に落とす (#248) | | `types.ts` (実体は `types/`) | 全公開型 | | `assurance.ts` | 三層保証語彙 (ADR-0020)。`deriveAssurance` が実証拠のみから整合性/時刻アンカー/著述性(advisory) を導出。verify(web)/verify-cli が同一実装を使う (表示の食い違い防止)。**自己申告 `proof.mode` を入力に使わない・provenance を判定に昇格させない** | | `analysis/` | 分析層フレームワーク (ADR-0009)。`runAnalysis` + 差し替え可能な `Analyzer` 群 (automation / transcription-topology / focus-burst の第一次ヒューリスティック + pureTyping)。検証と**直交**する advisory のみ・判定しない。`automationAnalyzer` は webdriver/headless GPU に加え **合成打鍵 (`KeystrokeDynamicsData.isTrusted===false`, ADR-0018)** も数える。`typingPatternAnalyzer` は旧 `TypingPatternAnalyzer` (打鍵動態) を `keystroke-content-consistency` 次元の advisory signal に折り込む (旧 verify TypingPatternCard を廃止・判定ゲージは持ち込まない・critical でも notice 止まり・dwell<30 で黙る ★6b)。`analysis/eval.ts` は **実証評価** (W5): ラベル付きコーパス → `evaluateAnalysis` が混同行列/閾値スイープ/genuineSignalRate を純粋関数で算出。**ゲート: 実測まで heuristic を `review` に昇格しない** (収集手順は docs/analysis-eval-protocol.md、ランナーは `__tests__/analysisEvalCorpus.test.ts`)。`analysis/bundle.ts` は **Tier A バンドル** (ADR-0024): `buildAnalysisBundle` が content-free な `{processSummary, analysis, assurance}` を束ねる (events/source/fingerprint なし)。`analysis/cohort.ts` は **コホート基準** (ADR-0025): `computeCohortBaseline`/`positionInCohort` が `AnalysisBundle[]` から頑健分布 (中央値/IQR・個票非保持) と提出物の位置 (percentile/IQR距離) を算出。**advisory・外れ値≠違反・小N ガード** | diff --git a/packages/shared/src/__tests__/proofLanguage.test.ts b/packages/shared/src/__tests__/proofLanguage.test.ts new file mode 100644 index 0000000..9c49c7f --- /dev/null +++ b/packages/shared/src/__tests__/proofLanguage.test.ts @@ -0,0 +1,69 @@ +/** + * proof 由来の自己申告 `language` の入力検証 (#248)。 + * + * `language` は表示層で `className` (web) と stdout (CLI) に補間される。proof.json は + * 攻撃者が組み立てられる入力なので、表示に渡す前に allowlist へ落とす契約をここで固定する。 + */ + +import { describe, expect, it } from 'vitest'; +import { KNOWN_LANGUAGES } from '../fileProcessing/languageDetection.js'; +import { normalizeProofLanguage, UNKNOWN_LANGUAGE } from '../fileProcessing/proofLanguage.js'; + +/** + * エディタが実際に吐きうる言語 ID (`packages/editor/src/config/SupportedLanguages.ts` の + * `LanguageId`)。allowlist がこれを取りこぼすと正当な proof の表示が壊れるので、 + * editor 側の集合を写して固定する。 + */ +const EDITOR_LANGUAGE_IDS = ['c', 'cpp', 'javascript', 'typescript', 'python', 'html', 'css', 'plaintext']; + +describe('normalizeProofLanguage', () => { + it('passes through a known language', () => { + expect(normalizeProofLanguage('c')).toBe('c'); + }); + + it('accepts every language the extension map can produce', () => { + for (const language of KNOWN_LANGUAGES) { + expect(normalizeProofLanguage(language)).toBe(language); + } + }); + + it('accepts every language id the editor can export', () => { + for (const id of EDITOR_LANGUAGE_IDS) { + expect(normalizeProofLanguage(id)).toBe(id); + } + }); + + it('drops a language that smuggles a second CSS class so the code preview cannot be hidden', () => { + // `.hidden { display: none !important; }` を className に足されると が消える (#248) + expect(normalizeProofLanguage('ts hidden')).toBe(UNKNOWN_LANGUAGE); + }); + + it('drops a language carrying an HTML payload', () => { + expect(normalizeProofLanguage('')).toBe(UNKNOWN_LANGUAGE); + }); + + it('drops a language carrying a newline and ANSI escape aimed at CLI stdout', () => { + expect(normalizeProofLanguage('c\n\n--- Checks ---\nHash Chain: PASS')).toBe(UNKNOWN_LANGUAGE); + }); + + it('drops an unknown but harmless-looking language label', () => { + expect(normalizeProofLanguage('brainfuck')).toBe(UNKNOWN_LANGUAGE); + }); + + it('is case sensitive (does not guess at a near miss)', () => { + expect(normalizeProofLanguage('Python')).toBe(UNKNOWN_LANGUAGE); + }); + + it('falls back for a missing field on legacy proofs', () => { + expect(normalizeProofLanguage(undefined)).toBe(UNKNOWN_LANGUAGE); + }); + + it.each([[null], [42], [{}], [['c']], [true]])('falls back for the non-string value %o', (value) => { + expect(normalizeProofLanguage(value)).toBe(UNKNOWN_LANGUAGE); + }); + + it('is idempotent so it can run at both the ingest point and the display boundary', () => { + expect(normalizeProofLanguage(normalizeProofLanguage('ts hidden'))).toBe(UNKNOWN_LANGUAGE); + expect(normalizeProofLanguage(UNKNOWN_LANGUAGE)).toBe(UNKNOWN_LANGUAGE); + }); +}); diff --git a/packages/shared/src/fileProcessing/index.ts b/packages/shared/src/fileProcessing/index.ts index f38dbba..7d0ade1 100644 --- a/packages/shared/src/fileProcessing/index.ts +++ b/packages/shared/src/fileProcessing/index.ts @@ -20,8 +20,12 @@ export { isBinaryFile, getFileType, isProofFilename, + KNOWN_LANGUAGES, } from './languageDetection.js'; +// proof 由来の自己申告言語の入力検証 (#248) +export { normalizeProofLanguage, UNKNOWN_LANGUAGE } from './proofLanguage.js'; + // ZIP 展開予算 (#234) export { ZipExtractionBudget, diff --git a/packages/shared/src/fileProcessing/languageDetection.ts b/packages/shared/src/fileProcessing/languageDetection.ts index 2e1d012..bb50a8d 100644 --- a/packages/shared/src/fileProcessing/languageDetection.ts +++ b/packages/shared/src/fileProcessing/languageDetection.ts @@ -47,6 +47,15 @@ const LANGUAGE_MAP: Record = { '.txt': 'plaintext', }; +/** + * `LANGUAGE_MAP` が返しうる言語 ID の集合 (重複排除)。 + * + * proof 由来の自己申告 `language` を allowlist に落とす `normalizeProofLanguage` + * (`proofLanguage.ts`) が参照する正本。集合を別に書き起こすと `LANGUAGE_MAP` とドリフトして + * 正当な言語が `'unknown'` に落ちるので、必ずここから導出すること。 + */ +export const KNOWN_LANGUAGES: readonly string[] = Object.freeze([...new Set(Object.values(LANGUAGE_MAP))]); + /** Binary file extensions */ const BINARY_EXTENSIONS = [ '.exe', diff --git a/packages/shared/src/fileProcessing/proofLanguage.ts b/packages/shared/src/fileProcessing/proofLanguage.ts new file mode 100644 index 0000000..eee91a5 --- /dev/null +++ b/packages/shared/src/fileProcessing/proofLanguage.ts @@ -0,0 +1,35 @@ +/** + * proof の自己申告言語 (`ExportedProof.language`) の入力検証。 + * + * `language` は型の上では `string` だが、実体は `JSON.parse` の結果を cast しただけなので + * 実行時は攻撃者が組み立てた任意の文字列が入る。これを検証せずに表示層へ流すと: + * + * - **verify (web)**: `ResultPanel` が `codeEl.className = \`language-${language} hljs …\`` を + * 組み立てるため、空白区切りで既存の CSS ユーティリティクラスを足せる。 + * `.hidden { display: none !important; }` を混ぜると `` が消え、採点者からは + * コードプレビューが空に見える (#248) + * - **verify-cli**: `Language:` 行に生値が出るため、改行や ANSI を仕込んで stdout を + * grep する運用を騙せる (#266 — 出力境界のサニタイズは別途 CLI 側で行う) + * + * `language` は参考表示のみで保証導出には使わない (ADR-0020) ので、既知の集合に無い値は + * 推測して寄せず `'unknown'` に落とすのが正しい振る舞い。#210 で `mode` に敷いた + * `normalizeProofMode` と同じ方針を `language` にも敷く。 + */ + +import { KNOWN_LANGUAGES } from './languageDetection.js'; + +/** 言語が判別できないときの表示値。表示層が従来から使ってきた既定値と同じ。 */ +export const UNKNOWN_LANGUAGE = 'unknown'; + +/** 受理する言語 ID。`LANGUAGE_MAP` の値集合 (= エディタと出題ツールが吐きうる全て) + `'unknown'`。 */ +const ACCEPTED_LANGUAGES: ReadonlySet = new Set([...KNOWN_LANGUAGES, UNKNOWN_LANGUAGE]); + +/** + * allowlist に一致する値だけを返す。非文字列・集合外の値は `'unknown'`。 + * + * 冪等なので、取り込み口と表示境界の両方で通してよい。 + */ +export function normalizeProofLanguage(value: unknown): string { + if (typeof value !== 'string') return UNKNOWN_LANGUAGE; + return ACCEPTED_LANGUAGES.has(value) ? value : UNKNOWN_LANGUAGE; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 59b52fc..75ba2cf 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -223,6 +223,10 @@ export { isBinaryFile, getFileType, isProofFilename, + KNOWN_LANGUAGES, + // proof 由来の自己申告言語の入力検証 (#248) + normalizeProofLanguage, + UNKNOWN_LANGUAGE, // Parser isProofFile, parseJsonString, diff --git a/packages/verify-cli/src/__tests__/proofLanguage.test.ts b/packages/verify-cli/src/__tests__/proofLanguage.test.ts new file mode 100644 index 0000000..573a39b --- /dev/null +++ b/packages/verify-cli/src/__tests__/proofLanguage.test.ts @@ -0,0 +1,53 @@ +/** + * proof 由来の自己申告 `language` は CLI の結果に生値で載せない (#248)。 + * + * `output.ts` は `Language: ${result.language}` を stdout に出す。生値のままだと + * 改行や ANSI を仕込んで偽の `Checks` ブロックを描け、stdout を grep する採点運用を騙せる + * (#266)。出力境界そのもののサニタイズは #266 で別途行うが、まず結果を組み立てる時点で + * allowlist に落とす契約をここで固定する。 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { verifyProof, type ProofFile } from '../verify.js'; + +/** language 以外は最小構成の proof。検証は FAIL してよい (見るのは language だけ)。 */ +function proofWithLanguage(language: unknown): ProofFile { + return { + version: '1.0.0', + language, + content: '', + metadata: { timestamp: new Date(0).toISOString() }, + typingProofData: { initialEventChainHash: null, finalEventChainHash: null, metadata: {} }, + proof: { events: [], finalHash: '' }, + } as unknown as ProofFile; +} + +describe('verifyProof — self-asserted language', () => { + // 進捗バーが stdout を汚すので黙らせる (検証対象は戻り値)。 + const silence = () => vi.spyOn(console, 'log').mockImplementation(() => {}); + + it('passes through a known language', async () => { + silence(); + const result = await verifyProof(proofWithLanguage('python'), { mode: 'fast' }); + expect(result.language).toBe('python'); + }); + + it('drops a language carrying a newline so no fake Checks block can be printed', async () => { + silence(); + const payload = 'c\n\n--- Checks ---\nHash Chain: PASS'; + const result = await verifyProof(proofWithLanguage(payload), { mode: 'fast' }); + expect(result.language).toBe('unknown'); + }); + + it('drops a language carrying an ANSI escape', async () => { + silence(); + const result = await verifyProof(proofWithLanguage('c\x1b[2K\x1b[1A'), { mode: 'fast' }); + expect(result.language).toBe('unknown'); + }); + + it('falls back to unknown for a missing language field', async () => { + silence(); + const result = await verifyProof(proofWithLanguage(undefined), { mode: 'fast' }); + expect(result.language).toBe('unknown'); + }); +}); diff --git a/packages/verify-cli/src/verify.ts b/packages/verify-cli/src/verify.ts index 1f41a2b..80ada40 100644 --- a/packages/verify-cli/src/verify.ts +++ b/packages/verify-cli/src/verify.ts @@ -10,6 +10,7 @@ import { deriveAssurance, summarizeAnalysisForAssurance, summarizeProcess, + normalizeProofLanguage, EXAM_AUTHORITY_KEYS, type ProofFile, type VerificationProgressCallback, @@ -258,7 +259,9 @@ export async function verifyProof(proof: ProofFile, options: VerifyProofOptions poswIterations, errorAt: result.errorAt, errorMessage: result.errorMessage, - language: proof.language, + // proof 由来の自己申告値。生値のままだと Language: 行から改行や ANSI を stdout へ + // 流し込める (#248 / #266)。allowlist に落として渡す。 + language: normalizeProofLanguage(proof.language), mode, poswSkipped: result.poswSkipped ?? false, signedCheckpoints: result.signedCheckpoints, diff --git a/packages/verify/src/services/JsonFileProcessor.ts b/packages/verify/src/services/JsonFileProcessor.ts index eb122fa..2023f13 100644 --- a/packages/verify/src/services/JsonFileProcessor.ts +++ b/packages/verify/src/services/JsonFileProcessor.ts @@ -4,6 +4,7 @@ * JSON ファイルの読み込みと解析を担当 */ +import { normalizeProofLanguage } from '@typedcode/shared'; import type { ProofFile } from '../types.js'; import type { ParsedFileData, FileProcessResult, FileProcessCallbacks } from './FileProcessor.js'; import { getLanguageFromExtension } from './fileUtils.js'; @@ -44,7 +45,7 @@ export class JsonFileProcessor { if (parsed.proof) { isValidProofFile = true; proofData = parsed; - language = parsed.language ?? 'unknown'; + language = normalizeProofLanguage(parsed.language); const eventCount = parsed.proof?.events?.length ?? 0; this.callbacks.onParseComplete?.(file.name, eventCount); } @@ -116,7 +117,7 @@ export class JsonFileProcessor { return { filename, type: 'proof', - language: parsed.language ?? 'unknown', + language: normalizeProofLanguage(parsed.language), rawData: content, proofData: parsed, }; diff --git a/packages/verify/src/services/ResultDataService.ts b/packages/verify/src/services/ResultDataService.ts index 0e64752..81b70bc 100644 --- a/packages/verify/src/services/ResultDataService.ts +++ b/packages/verify/src/services/ResultDataService.ts @@ -31,6 +31,7 @@ import { formatTypingTime, calculateTypingSpeed as calculateTypingSpeedShared, countPasteEvents as countPasteEventsShared, + normalizeProofLanguage, summarizeProcess, } from '@typedcode/shared'; @@ -148,7 +149,10 @@ export function buildResultData(tabState: VerifyTabState): ResultData | null { return { filename: tabState.filename, content: proofData.content || '', - language: tabState.language, + // 自己申告言語。ResultPanel が `language-${...}` を className に組み立てるので、 + // 表示境界でも入力を信用しない (#248)。取り込み口 (FileController) でも同じ正規化を + // 通しているが、normalizeProofLanguage は冪等なので二重適用に害はない。 + language: normalizeProofLanguage(tabState.language), // 自己申告モードラベル (ADR-0011)。参考表示のみ — 保証導出には使わない (ADR-0020)。 // proof.json は攻撃者が組み立てられる入力なので、型を信用せず allowlist に落とす (#210)。 mode: normalizeProofMode(proofData.mode), diff --git a/packages/verify/src/services/ZipFileProcessor.ts b/packages/verify/src/services/ZipFileProcessor.ts index 62dffee..aa77758 100644 --- a/packages/verify/src/services/ZipFileProcessor.ts +++ b/packages/verify/src/services/ZipFileProcessor.ts @@ -9,6 +9,7 @@ import { ZipExtractionBudget, assertZipWithinBudget, collectChainImageHashes, + normalizeProofLanguage, readZipEntryBytes, readZipEntryText, } from '@typedcode/shared'; @@ -214,7 +215,7 @@ export class ZipFileProcessor { if (parsed.proof) { isValidProofFile = true; proofData = parsed; - language = parsed.language ?? 'unknown'; + language = normalizeProofLanguage(parsed.language); } } catch { // パース失敗は無視(通常のJSONとして扱う) diff --git a/packages/verify/src/services/__tests__/ResultDataService.test.ts b/packages/verify/src/services/__tests__/ResultDataService.test.ts index 15d7065..ef2254f 100644 --- a/packages/verify/src/services/__tests__/ResultDataService.test.ts +++ b/packages/verify/src/services/__tests__/ResultDataService.test.ts @@ -3,6 +3,7 @@ * * proof.json は攻撃者が自由に組み立てられる入力。UI へ渡す ResultData を組み立てる * この境界で、自己申告 mode を allowlist に落としておく (#210 の入力層の防御)。 + * 同じ契約を自己申告 language にも敷く (#248 — `ResultPanel` が className に補間するため)。 */ import { describe, expect, it } from 'vitest'; @@ -29,6 +30,14 @@ function tabState(mode: unknown): VerifyTabState { } as unknown as VerifyTabState; } +/** language 以外は最小構成の検証済みタブ状態。 */ +function languageTabState(language: unknown): VerifyTabState { + return { + ...tabState('casual'), + language, + } as unknown as VerifyTabState; +} + describe('buildResultData — self-asserted mode', () => { it('passes through a known mode', () => { expect(buildResultData(tabState('exam'))?.mode).toBe('exam'); @@ -46,3 +55,23 @@ describe('buildResultData — self-asserted mode', () => { expect(buildResultData(tabState(undefined))?.mode).toBeUndefined(); }); }); + +describe('buildResultData — self-asserted language', () => { + it('passes through a known language', () => { + expect(buildResultData(languageTabState('python'))?.language).toBe('python'); + }); + + it('drops a language that smuggles a CSS utility class into the code preview className', () => { + // `language-ts hidden hljs …` になると `.hidden { display: none !important }` が勝ち、 + // ごとコードプレビューが消える (#248) + expect(buildResultData(languageTabState('ts hidden'))?.language).toBe('unknown'); + }); + + it('drops a language carrying an HTML payload', () => { + expect(buildResultData(languageTabState(''))?.language).toBe('unknown'); + }); + + it('falls back to unknown for a missing language field', () => { + expect(buildResultData(languageTabState(undefined))?.language).toBe('unknown'); + }); +}); diff --git a/packages/verify/src/ui/controllers/FileController.ts b/packages/verify/src/ui/controllers/FileController.ts index 5990090..2add828 100644 --- a/packages/verify/src/ui/controllers/FileController.ts +++ b/packages/verify/src/ui/controllers/FileController.ts @@ -11,7 +11,7 @@ import type { StatusBarUI } from '../StatusBarUI'; import type { ProofFile, VerifyScreenshot, DiffResult, ContentMismatchInfo } from '../../types'; import { t } from '../../i18n/index'; import { DiffService } from '../../services/DiffService'; -import { parseExamPackageManifest, type ExamPackageManifest } from '@typedcode/shared'; +import { normalizeProofLanguage, parseExamPackageManifest, type ExamPackageManifest } from '@typedcode/shared'; /** 隠し file input を開いて 1 ファイルを返す (キャンセルは null)。 */ function pickFile(accept: string): Promise { @@ -398,7 +398,9 @@ export class FileController { this.deps.tabManager.addTab({ id, filename: displayName, - language: proofData.language || 'unknown', + // proof.json は攻撃者が組み立てられる入力。自己申告の language をそのまま状態に入れると + // 表示層の className に届く (#248)。取り込み口で allowlist に落とす。 + language: normalizeProofLanguage(proofData.language), status: 'pending', progress: 0, proofData,