diff --git a/docs/design/security.md b/docs/design/security.md index 5829e52..184fbf9 100644 --- a/docs/design/security.md +++ b/docs/design/security.md @@ -74,7 +74,7 @@ | クラス | 対象 | 担い手 | |---|---|---| -| 機械的(構造的担保) | 正規テキスト(S9)・SVG(S12)・`meta.id`=dirname(S11)・生成物/ソース乖離・サイズ上限・必須フィールド・sha512 整合・ホスト関数リテラル限定 best-effort | CI(`scripts/check-registry-integrity.mjs`) | +| 機械的(構造的担保) | 正規テキスト(S9)・SVG(S12)・ID=dirname 全種別(S11)・storeId 形式 + 全 kind 横断一意 + テーマ内部 ID 一意(notedeck#913)・生成物/ソース乖離・サイズ上限・必須フィールド・sha512 整合・ホスト関数リテラル限定 best-effort | CI(`scripts/check-registry-integrity.mjs`) | | 論理(人手必須) | ロジックボム・権限と説明の釣り合い・第2引数の宛先・config 駆動の引数 | 人間(CI が候補フラグを提示) | - Tier は種別でなく**導出されるホスト関数**で決める。`write:*` / `Nd:http` / diff --git a/docs/registry-format.md b/docs/registry-format.md index 34f33d5..2b16cf0 100644 --- a/docs/registry-format.md +++ b/docs/registry-format.md @@ -22,10 +22,21 @@ public/registry/ frontmatter から自動生成されます。`api.json` などの生成物はコミットせず、ビルドで 生成します(レビュー対象とソースを一致させるため。[security.md](design/security.md) S1)。 -> **ID はディレクトリ名と一致必須。** `meta.id` を書く場合はディレクトリ名と完全に -> 一致させてください(不一致は既存アイテム乗っ取りの温床として CI が reject します)。 +> **ID はディレクトリ名と一致必須。** `meta.id`(スキルは frontmatter の `id`)は +> ディレクトリ名と完全に一致させてください(不一致は既存アイテム乗っ取りの温床として +> CI が reject します)。 > `createdAt`/`updatedAt` は `meta.json` に書かず、git 履歴から自動採取されます。 +ディレクトリ名(storeId)は NoteDeck 側でローカル同一性の正準リンクになるため +(notedeck#913)、CI(`scripts/check-registry-integrity.mjs`)が次を機械検査します。 + +- 形式は `^[a-z0-9-]{1,48}$`(小文字英数とハイフンのみ、48 文字以内)。 + Windows 予約デバイス名(`con` / `prn` / `aux` / `nul` / `com1`-`com9` / + `lpt1`-`lpt9`)は不可 +- ID は種別をまたいでレジストリ全体で一意(同じ ID を plugins と skills で + 使い回すことはできない) +- テーマの `theme.json5` 内部 `id` はテーマ間で一意(欠損は許容、重複は reject) + ### エントリの URL フィールド - `sourceUrl` — 生ソース(`plugin.is` / `theme.json5` / `widget.is` / `skill.md` / diff --git a/scripts/check-registry-integrity.mjs b/scripts/check-registry-integrity.mjs index 1c904ee..ae4dbdf 100644 --- a/scripts/check-registry-integrity.mjs +++ b/scripts/check-registry-integrity.mjs @@ -11,6 +11,7 @@ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs' import { resolve, join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' +import JSON5 from 'json5' const REGISTRY_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'public', 'registry') @@ -25,9 +26,20 @@ const KINDS = { const MAX_SOURCE_BYTES = 500 * 1024 // S2: サイズ上限 500KB +// notedeck#913: storeId(ディレクトリ名)はローカル同一性の正準リンクになるため +// 形式を機械保証する。小文字英数とハイフンのみ、48 文字以内。 +const STORE_ID_RE = /^[a-z0-9-]{1,48}$/ +// Windows 予約デバイス名。ローカル展開時にファイル/ディレクトリ名として使えない。 +const WINDOWS_RESERVED_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/ + const errors = [] const warnings = [] +// notedeck#913: レジストリ全体(全 kind 横断)の ID 重複検査用 +const idOwners = new Map() // storeId → ['kind/id', ...] +// notedeck#913: テーマ内部 ID(theme.json5 の id)の一意性検査用 +const themeInternalIds = new Map() // 内部 id → [storeId, ...] + // S9: 見た目とパーサ解釈を乖離させる不可視/制御文字。 // bidi 制御は無条件 reject(Trojan Source, CVE-2021-42574)。 const BIDI_CONTROLS = /[‪-‮⁦-⁩]/ @@ -73,12 +85,37 @@ function checkSvg(label, svg) { } } +// skill.md の YAML frontmatter から id を取り出す。 +// build-registry.js の parseFrontmatter と同じく浅い frontmatter のみ想定。 +function frontmatterId(text) { + const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!m) return null + for (const line of m[1].split(/\r?\n/)) { + const kv = line.match(/^id\s*:\s*(.*)$/) + if (!kv) continue + const v = kv[1].trim() + return /^".*"$/.test(v) || /^'.*'$/.test(v) ? v.slice(1, -1) : v + } + return null +} + for (const [kind, sourceName] of Object.entries(KINDS)) { const kindDir = join(REGISTRY_DIR, kind) for (const id of scanDirs(kindDir)) { const itemDir = join(kindDir, id) const label = `${kind}/${id}` + // notedeck#913: storeId(ディレクトリ名)の形式検査 + if (!STORE_ID_RE.test(id)) { + errors.push(`[${label}] storeId(ディレクトリ名)が不正 — ^[a-z0-9-]{1,48}$ に一致しない`) + } else if (WINDOWS_RESERVED_RE.test(id)) { + errors.push(`[${label}] storeId が Windows 予約デバイス名(${id})`) + } + + // notedeck#913: 全 kind 横断の ID 重複検査(収集。判定はループ後) + if (!idOwners.has(id)) idOwners.set(id, []) + idOwners.get(id).push(label) + // 主ソース const sourcePath = join(itemDir, sourceName) if (!existsSync(sourcePath)) { @@ -92,9 +129,20 @@ for (const [kind, sourceName] of Object.entries(KINDS)) { } } - // meta.json(skills は持たない) + // meta.json(skills は持たず skill.md の frontmatter が同じ役割) const metaPath = join(itemDir, 'meta.json') - if (kind !== 'skills') { + if (kind === 'skills') { + // S11 / notedeck#913: skills も frontmatter の id = ディレクトリ名を強制。 + // ここを素通しすると skills だけアイテム乗っ取り経路が残る。 + if (existsSync(sourcePath)) { + const fmId = frontmatterId(readFileSync(sourcePath, 'utf-8')) + if (fmId == null) { + errors.push(`[${label}] skill.md の frontmatter に id が無い`) + } else if (fmId !== id) { + errors.push(`[${label}] frontmatter の id (${fmId}) がディレクトリ名 (${id}) と不一致 — アイテム乗っ取りの温床`) + } + } + } else { if (!existsSync(metaPath)) { errors.push(`[${label}] meta.json が無い`) } else { @@ -118,6 +166,22 @@ for (const [kind, sourceName] of Object.entries(KINDS)) { } } + // notedeck#913: テーマ内部 ID(theme.json5 の id)の収集(判定はループ後)。 + // id 欠損は現状データで許容されているため fail にせず、重複のみ弾く。 + if (kind === 'themes' && existsSync(sourcePath)) { + let theme + try { + theme = JSON5.parse(readFileSync(sourcePath, 'utf-8')) + } catch { + errors.push(`[${label}] theme.json5 が JSON5 として解釈できない`) + theme = {} + } + if (theme.id != null) { + if (!themeInternalIds.has(theme.id)) themeInternalIds.set(theme.id, []) + themeInternalIds.get(theme.id).push(id) + } + } + // icon.svg(任意) const iconPath = join(itemDir, 'icon.svg') if (existsSync(iconPath)) { @@ -128,6 +192,19 @@ for (const [kind, sourceName] of Object.entries(KINDS)) { } } +// notedeck#913: レジストリ全体(全 kind 横断)で storeId は一意 +for (const [id, owners] of idOwners) { + if (owners.length > 1) { + errors.push(`[registry] ID "${id}" が重複: ${owners.join(', ')} — storeId はレジストリ全体で一意`) + } +} +// notedeck#913: テーマ内部 ID の一意性 +for (const [tid, dirs] of themeInternalIds) { + if (dirs.length > 1) { + errors.push(`[themes] theme.json5 の内部 ID "${tid}" が重複: ${dirs.join(', ')}`) + } +} + for (const w of warnings) console.warn(`WARN ${w}`) for (const e of errors) console.error(`ERROR ${e}`)