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
3 changes: 2 additions & 1 deletion packages/shared/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` が同じ契約を持つ。**表示に使う自己申告フィールドを増やすときは、まず正規化の置き場を決めること**

## モジュール一覧

Expand All @@ -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 ガード** |
Expand Down
69 changes: 69 additions & 0 deletions packages/shared/src/__tests__/proofLanguage.test.ts
Original file line number Diff line number Diff line change
@@ -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 に足されると <code> が消える (#248)
expect(normalizeProofLanguage('ts hidden')).toBe(UNKNOWN_LANGUAGE);
});

it('drops a language carrying an HTML payload', () => {
expect(normalizeProofLanguage('<img src=x onerror=alert(1)>')).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);
});
});
4 changes: 4 additions & 0 deletions packages/shared/src/fileProcessing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/fileProcessing/languageDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ const LANGUAGE_MAP: Record<string, string> = {
'.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',
Expand Down
35 changes: 35 additions & 0 deletions packages/shared/src/fileProcessing/proofLanguage.ts
Original file line number Diff line number Diff line change
@@ -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; }` を混ぜると `<code>` が消え、採点者からは
* コードプレビューが空に見える (#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<string> = 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;
}
4 changes: 4 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ export {
isBinaryFile,
getFileType,
isProofFilename,
KNOWN_LANGUAGES,
// proof 由来の自己申告言語の入力検証 (#248)
normalizeProofLanguage,
UNKNOWN_LANGUAGE,
// Parser
isProofFile,
parseJsonString,
Expand Down
53 changes: 53 additions & 0 deletions packages/verify-cli/src/__tests__/proofLanguage.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
5 changes: 4 additions & 1 deletion packages/verify-cli/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
deriveAssurance,
summarizeAnalysisForAssurance,
summarizeProcess,
normalizeProofLanguage,
EXAM_AUTHORITY_KEYS,
type ProofFile,
type VerificationProgressCallback,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions packages/verify/src/services/JsonFileProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -116,7 +117,7 @@ export class JsonFileProcessor {
return {
filename,
type: 'proof',
language: parsed.language ?? 'unknown',
language: normalizeProofLanguage(parsed.language),
rawData: content,
proofData: parsed,
};
Expand Down
6 changes: 5 additions & 1 deletion packages/verify/src/services/ResultDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
formatTypingTime,
calculateTypingSpeed as calculateTypingSpeedShared,
countPasteEvents as countPasteEventsShared,
normalizeProofLanguage,
summarizeProcess,
} from '@typedcode/shared';

Expand Down Expand Up @@ -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),
Expand Down
3 changes: 2 additions & 1 deletion packages/verify/src/services/ZipFileProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ZipExtractionBudget,
assertZipWithinBudget,
collectChainImageHashes,
normalizeProofLanguage,
readZipEntryBytes,
readZipEntryText,
} from '@typedcode/shared';
Expand Down Expand Up @@ -214,7 +215,7 @@ export class ZipFileProcessor {
if (parsed.proof) {
isValidProofFile = true;
proofData = parsed;
language = parsed.language ?? 'unknown';
language = normalizeProofLanguage(parsed.language);
}
} catch {
// パース失敗は無視(通常のJSONとして扱う)
Expand Down
29 changes: 29 additions & 0 deletions packages/verify/src/services/__tests__/ResultDataService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*
* proof.json は攻撃者が自由に組み立てられる入力。UI へ渡す ResultData を組み立てる
* この境界で、自己申告 mode を allowlist に落としておく (#210 の入力層の防御)。
* 同じ契約を自己申告 language にも敷く (#248 — `ResultPanel` が className に補間するため)。
*/

import { describe, expect, it } from 'vitest';
Expand All @@ -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');
Expand All @@ -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 }` が勝ち、
// <code> ごとコードプレビューが消える (#248)
expect(buildResultData(languageTabState('ts hidden'))?.language).toBe('unknown');
});

it('drops a language carrying an HTML payload', () => {
expect(buildResultData(languageTabState('<img src=x onerror=alert(1)>'))?.language).toBe('unknown');
});

it('falls back to unknown for a missing language field', () => {
expect(buildResultData(languageTabState(undefined))?.language).toBe('unknown');
});
});
Loading
Loading