From 973f7d4145482318aea6fc095664b08dadcd73c8 Mon Sep 17 00:00:00 2001 From: hironow Date: Fri, 30 Oct 2026 09:47:40 +0900 Subject: [PATCH 1/6] chore(deps): drop expired exclude-newer-package overrides The 7-day release quarantine lets a security fix through per package with an absolute `exclude-newer-package` cutoff. That cutoff never expires by itself, so once the admitted release is older than the window the entry only freezes the package there and later Dependabot bumps become silent no-ops. That is what scripts/check_uv_exclude_newer.py exists to catch, and it now fails: emulator/pyproject.toml: `certifi` cutoff 2026-10-20 is 9 days old emulator/pyproject.toml: `mlflow` cutoff 2026-10-21 is 8 days old ... mlflow-skinny, mlflow-tracing, and telemetry/examples `certifi` certifi 2026.10.19 (the eMudhra CA removal) and mlflow / mlflow-skinny / mlflow-tracing 3.17.2 (GHSA-wqmh-qccw-pqwf) were admitted on 2026-10-26 with a comment saying to remove them on or after 2026-10-28. Dropping them leaves the repo-authoritative `exclude-newer = "7 days"` span in charge, which now admits those releases on its own. `uv lock` in both projects removes only the lock's `[options.exclude-newer-package]` metadata: no resolved version changes (the diff is 7 deletions, 0 version lines). The `ruff = false, ty = false` entries stay; those are a different mechanism (skip the span so an exact pin can move without waiting out the window). --- emulator/pyproject.toml | 13 +++++++------ emulator/uv.lock | 4 ---- telemetry/examples/pyproject.toml | 7 +++---- telemetry/examples/uv.lock | 3 --- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/emulator/pyproject.toml b/emulator/pyproject.toml index f73b5179..7f13790e 100644 --- a/emulator/pyproject.toml +++ b/emulator/pyproject.toml @@ -97,9 +97,10 @@ timeout_method = "thread" exclude-newer = "7 days" # ruff/ty = false so an exact pin can move without waiting out the window # (docs/agents/python-tooling.md). -# Security fixes inside the window (2026-10-26), cutoff just after each upload; -# remove on or after 2026-10-28 (scripts/check_uv_exclude_newer.py fails then): -# certifi 2026.10.19 (eMudhra CA removal, uploaded 2026-10-20T02:24Z) and -# mlflow / mlflow-skinny / mlflow-tracing 3.17.2 (GHSA-wqmh-qccw-pqwf, -# uploaded 2026-10-21T02:08Z). -exclude-newer-package = { ruff = false, ty = false, certifi = "2026-10-20T02:25:00Z", mlflow = "2026-10-21T02:10:00Z", mlflow-skinny = "2026-10-21T02:10:00Z", mlflow-tracing = "2026-10-21T02:10:00Z" } +# The 2026-10-26 security-fix cutoffs (certifi 2026.10.19, the eMudhra CA +# removal, uploaded 2026-10-20T02:24Z; mlflow / mlflow-skinny / +# mlflow-tracing 3.17.2, GHSA-wqmh-qccw-pqwf, uploaded 2026-10-21T02:08Z) were +# dropped on 2026-10-30: those releases are now older than the 7-day window, +# so the relative span admits them on its own and the absolute cutoff would +# only freeze the packages there. +exclude-newer-package = { ruff = false, ty = false } diff --git a/emulator/uv.lock b/emulator/uv.lock index 859c5815..a78e4891 100644 --- a/emulator/uv.lock +++ b/emulator/uv.lock @@ -15,10 +15,6 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] -certifi = "2026-10-20T02:25:00Z" -mlflow = "2026-10-21T02:10:00Z" -mlflow-skinny = "2026-10-21T02:10:00Z" -mlflow-tracing = "2026-10-21T02:10:00Z" ruff = false ty = false diff --git a/telemetry/examples/pyproject.toml b/telemetry/examples/pyproject.toml index 5ac8165a..18082cf5 100644 --- a/telemetry/examples/pyproject.toml +++ b/telemetry/examples/pyproject.toml @@ -19,10 +19,9 @@ dependencies = [ [tool.uv] # 7-day release quarantine, the same as every uv project in this repo. exclude-newer = "7 days" -# Security fix inside the window (2026-10-26), cutoff just after its upload; -# remove on or after 2026-10-28 (scripts/check_uv_exclude_newer.py fails then): -# certifi 2026.10.19 (eMudhra CA removal, uploaded 2026-10-20T02:24Z). -exclude-newer-package = { certifi = "2026-10-20T02:25:00Z" } +# The 2026-10-26 certifi cutoff (2026.10.19, the eMudhra CA removal, uploaded +# 2026-10-20T02:24Z) was dropped on 2026-10-30: that release is now older than +# the 7-day window, so the relative span admits it on its own. dev-dependencies = [ "pytest>=7.4.0", "pytest-asyncio>=0.23.0", diff --git a/telemetry/examples/uv.lock b/telemetry/examples/uv.lock index 61bd6fce..7c8a500b 100644 --- a/telemetry/examples/uv.lock +++ b/telemetry/examples/uv.lock @@ -11,9 +11,6 @@ resolution-markers = [ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P7D" -[options.exclude-newer-package] -certifi = "2026-10-20T02:25:00Z" - [[package]] name = "annotated-doc" version = "0.0.4" From 41551b2e818944a33e81380d259062f9e0250d2b Mon Sep 17 00:00:00 2001 From: hironow Date: Fri, 30 Oct 2026 09:58:06 +0900 Subject: [PATCH 2/6] feat(kernel-doctor): report Linux kernel CVE evidence, never a verdict CVE-2026-98432 (ipv4: free inet_opt and ireq_opt after an RCU grace period) is a real Linux kernel use-after-free, but this repository ships, builds and pins no Linux kernel: the kernel belongs to Microsoft (WSL and its self-hosted runner), to the distribution, or to the container host. There is nothing here to patch, so the honest response is detection and advice. tools/kernel-doctor compares the kernel version a host reports against reviewed, source-cited fix floors in one embedded record. It deliberately refuses to compare anything that is not a clean upstream x.y.z on a reviewed branch: Ubuntu 6.8.0-45-generic and Debian 6.1.0-28-amd64 carry the vendor's own ABI revision, not the upstream patch level, so numeric comparison is wrong in both directions. Unverified is never counted as fixed, and patch_status/backport_state stay unverified on every line. scripts/doctor.sh wires it as advisory only. This repository cannot fix a kernel, so no kernel finding may reach doctor's err count: `just doctor` exiting non-zero would break `just self-check` and test_doctor_sandbox over a condition only the operator can fix, and only outside this repository. The block therefore adds findings to warn only, and downgrades any ERR the checker emits to a single WARN. docs/runbook/kernel-doctor.md records the dispute over severity (NVD/Tenable 9.8 with AC:L versus Oracle's preliminary 8.1 with AC:H; Red Hat priority high, not critical; EPSS 0.00388; not in KEV, which is not proof that no public exploit exists) and the remedy per surface. The WSL kernel and .wslconfig stay unmanaged (ADR-0058, ADR-0064); the runbook carries `wsl --update` as an operator step, to run when no runner job is in flight. ADR-0065 records the decision and is Proposed, tracked in the decision queue. --- .gitattributes | 6 +- .gitignore | 1 + docs/adr/0065-kernel-cve-is-evidence-only.md | 98 ++++++ docs/adr/README.md | 2 + docs/decision-queue.md | 3 +- docs/plan/kernel-cve-evidence.md | 198 +++++++++++ docs/runbook/kernel-doctor.md | 150 +++++++++ justfile | 16 + scripts/doctor.sh | 40 +++ tests/unit/test_doctor_ca.py | 8 +- tests/unit/test_doctor_kernel.py | 126 +++++++ tests/unit/test_gitattributes_eol.py | 50 +++ tools/README.md | 1 + tools/kernel-doctor/findings.go | 52 +++ tools/kernel-doctor/freshness.go | 22 ++ tools/kernel-doctor/go.mod | 3 + tools/kernel-doctor/host.go | 74 +++++ tools/kernel-doctor/incidents.go | 206 ++++++++++++ .../incidents/cve-2026-98432.json | 65 ++++ tools/kernel-doctor/incidents_test.go | 126 +++++++ tools/kernel-doctor/integrity.go | 61 ++++ tools/kernel-doctor/kernel.go | 181 ++++++++++ tools/kernel-doctor/kernel_test.go | 123 +++++++ tools/kernel-doctor/main.go | 242 ++++++++++++++ tools/kernel-doctor/main_test.go | 314 ++++++++++++++++++ 25 files changed, 2165 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0065-kernel-cve-is-evidence-only.md create mode 100644 docs/plan/kernel-cve-evidence.md create mode 100644 docs/runbook/kernel-doctor.md create mode 100644 tests/unit/test_doctor_kernel.py create mode 100644 tools/kernel-doctor/findings.go create mode 100644 tools/kernel-doctor/freshness.go create mode 100644 tools/kernel-doctor/go.mod create mode 100644 tools/kernel-doctor/host.go create mode 100644 tools/kernel-doctor/incidents.go create mode 100644 tools/kernel-doctor/incidents/cve-2026-98432.json create mode 100644 tools/kernel-doctor/incidents_test.go create mode 100644 tools/kernel-doctor/integrity.go create mode 100644 tools/kernel-doctor/kernel.go create mode 100644 tools/kernel-doctor/kernel_test.go create mode 100644 tools/kernel-doctor/main.go create mode 100644 tools/kernel-doctor/main_test.go diff --git a/.gitattributes b/.gitattributes index 296588b2..e323b2de 100644 --- a/.gitattributes +++ b/.gitattributes @@ -20,6 +20,10 @@ dump/harness/skill-lock.json text eol=lf # checkout needs a forced re-checkout, because git skips stat-clean paths (see # ADR 0050). Same shape as the *.sh pin above. *.go text eol=lf -# CA doctor embeds these bytes for stale-binary detection across host OSes. +# The doctor tools embed these bytes and compare them against the working +# tree, so they must be LF on every OS or a CRLF checkout reports a stale +# binary for an unchanged commit. tools/ca-doctor/go.mod text eol=lf tools/ca-doctor/incidents/*.json text eol=lf +tools/kernel-doctor/go.mod text eol=lf +tools/kernel-doctor/incidents/*.json text eol=lf diff --git a/.gitignore b/.gitignore index 76a99252..66a51fe0 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,7 @@ tools/simple-server/simple-server tools/simple-server/simple-server.exe /output/ca-doctor/ /output/brotli-doctor/ +/output/kernel-doctor/ # Stray `bunx skills` output. The CLI's real lock is ~/.agents/.skill-lock.json # in the agent home (dot-prefixed, singular); running the CLI from the repo root diff --git a/docs/adr/0065-kernel-cve-is-evidence-only.md b/docs/adr/0065-kernel-cve-is-evidence-only.md new file mode 100644 index 00000000..01399b8c --- /dev/null +++ b/docs/adr/0065-kernel-cve-is-evidence-only.md @@ -0,0 +1,98 @@ +# 0065. Linux カーネルの CVE は dotfiles が直さず、報告と助言だけを行う + +**Date:** 2026-10-29 +**Status:** Proposed(決裁待ち。[decision-queue](../decision-queue.md) で追跡する) +**ID:** ADR-0065 +**Title:** Linux カーネルの CVE は dotfiles が直さず、報告と助言だけを行う +**Decided-date:** (未決) +**Decision-maker:** Hiroto N. +**Decision-maker-contact:** @hironow +**Author:** Pi(owner session) +**Author-contact:** @hironow +**Supersedes:** none +**Superseded-by:** none +**Related:** [ADR-0058](0058-pi-web-search-ollama.md)、[ADR-0064](0064-mirrored-wsl-uses-the-shared-loopback.md)、[ADR-0035](0035-runner-disk-gc.md) +**Consultation-trace:** 2026-10-29 の本セッションで所有者が選んだ — (1) 対応の深さは runbook + `just doctor` の advisory 検査 + Go の `kernel-doctor`、(2) 記録は ADR + decision-queue 行、(3) WSL カーネルを管理しない方針は維持し、runbook に operator 手順として書く + +## やさしい説明 + +Linux カーネルの脆弱性(CVE-2026-98432)への対応を、このリポジトリで行った。 +この dotfiles はカーネルそのものを持っていないので、**直すことはできない**。 +カーネルを入れているのは Microsoft(WSL)やディストロ、OrbStack や Docker Desktop である。 + +だからやったのは 3 つ。どのカーネルが露出しているかを調べる `kernel-doctor` を足した。 +`just doctor` に助言として組み込んだ。そして直し方を runbook に書いた。 +**「脆弱」とも「安全」とも言わない。** 版と、レビュー済みの修正版を比べた結果だけを言う。 + +## Context + +2026-10-15 に CVE-2026-98432(`ipv4: free inet_opt and ireq_opt after an RCU grace period`)が公開された。 +CNA は kernel.org。導入は 2015 年の commit `079096f103fa`(v4.4 期)で、修正は +`inet_sock_destruct()` と `tcp_v4_reqsk_destructor()` の `kfree()` を `kfree_rcu()` にするもの。 +レビュー済みの上流修正版は 5.10.272 / 5.15.223 / 6.1.190 / 6.6.159 / 6.12.113 / 6.18.56 / 7.2.10、 +mainline は 7.3(2026-10-29 時点)。 + +深刻度には争いがある。NVD / Tenable / Rapid7 は `CVSS:3.1/AV:N/AC:L/PR:N/UI:N` = 9.8 だが、 +Oracle の自社記録は暫定と明記したうえで `AC:H` = 8.1、Red Hat の優先度は critical ではなく high +(Bugzilla は `NEW`、修正版は未確定)。EPSS は 0.00388、CISA KEV 未収載。 + +**このリポジトリは Linux カーネルを同梱・ビルド・固定していない**(ツリーを検索して確認)。 +露出は 4 面あるが、いずれもカーネルは他者の管理下にある。 + +| 面 | カーネルの供給元 | dotfiles の管轄 | +| --- | --- | --- | +| macOS ホスト | Apple | 対象外 | +| WSL2 と self-hosted runner | Microsoft(`wsl --update`) | なし | +| devcontainer / emulator / telemetry のコンテナ | ホスト VM / WSL2 を共有 | なし | + +WSL のカーネルを管理しないのは既存の方針である。ADR-0058 の Context と ADR-0064 は +「dotfiles は `.wslconfig` を変えない」と明記し、`config/wsl/wslconfig` は推奨テンプレート、 +`just wslconfig` は差分表示のみで自己適用しない。`just wsl-conf` が扱うのは +`/etc/wsl.conf` の interop / systemd キーであって、カーネルではない。 + +前例は `tools/ca-doctor` と `tools/brotli-doctor` である。どちらも「証拠であって判定ではない」 +規律(明示的な `unverified`、レビュー日と期限、冪等な再実行)を持ち、`just doctor` に advisory として入る。 + +## Decision + +1. **dotfiles は Linux カーネルを同梱・ビルド・固定・更新しない。** 現状を明示的な決定として固定する。 + カーネルの是正は operator が各供給元(Microsoft / ディストロ / OrbStack・Docker Desktop)で行う。 +2. **カーネルの CVE は「報告と助言」に留める。** `tools/kernel-doctor`(Go、stdlib のみ)が + 実行中または報告されたカーネル版をレビュー済みの出典つき修正床と突き合わせ、 + `just doctor` が advisory として表示し、`docs/runbook/kernel-doctor.md` が面ごとの手順を示す。 +3. **ベンダー/ディストロのカーネルは判定しない。** 比較は文字列**全体**がクリーンな上流リリース形 + `x.y.z` で、かつ `major.minor` がレビュー済み branch のときだけ行う。それ以外 + (ベンダーの ABI サフィックス、未収載 branch、`+` や `.N` 付き)は `unverified` とし、 + レビュー済み修正床の一覧を出す。**未判定を「修正済み」として数えない。** + 理由: Ubuntu `6.8.0-45-generic` や Debian `6.1.0-28-amd64` の第 3 成分は上流の patch level ではなく、 + 数値比較は両方向に間違える。 +4. **kernel-doctor の所見は doctor の `err` に入れない(WARN 止まり)。** `just doctor` の exit 1 は + `just self-check` と `test_doctor_sandbox` を赤くする。カーネルはこのリポジトリが直せないので、 + operator がリポジトリ外でしか直せない条件で repo の gate を赤くしない。ツールの自己申告に頼らず、 + `scripts/doctor.sh` の側で ERR を単一の WARN に落とす。前例は `win-machine-path`。 +5. **WSL カーネルと `.wslconfig` を管理しない方針を維持する。** 是正手順 + (Windows 側で `wsl --update` → `wsl --shutdown` → `uname -r` で確認、runner の + アイドル時に行う)は runbook に operator 手順として書く。ADR-0058 と ADR-0064 は変更しない。 + +## Consequences + +### Positive + +- カーネルの CVE が来ても、repo が担う範囲(検出・助言・記録)と担わない範囲(是正)が明示される。 +- 誤判定しない。ベンダーカーネルを数値で比べて「脆弱」「修正済み」と言う事故が構造的に起きない。 +- `just doctor` の exit 契約が守られる。直せないものを赤にしないので、gate が意味を保つ。 +- 記録にレビュー期限があるので、一次情報が古くなったことが表面化する。 + +### Negative + +- ベンダーカーネルの実際の修正状態は分からないままである。operator が各勧告を読む必要がある。 +- レビュー済み branch のあいだの branch(例: 6.13〜6.18)は `unverified-branch` になり、 + 判定が出ない。保守側に倒れる分、情報量は少ない。 +- 記録を人手で更新し続ける必要がある(期限切れは WARN で出るが、更新は自動ではない)。 + +### Neutral + +- `kernel-doctor` は CVE が 1 件のうちは薄い道具である。汎用の脆弱性スキャナにはしない。 +- クロスビルドできることは、Windows / WSL の実機で観測できたことの証明ではない。 +- `tools/kernel-doctor/` は `tools/ca-doctor/` と同じ形(`incidents/*.json`、`//go:embed` と + バイト比較によるビルド鮮度検査)を取るので、保守の作法は既存の 2 つと揃う。 diff --git a/docs/adr/README.md b/docs/adr/README.md index f31e35a0..3272b684 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -89,6 +89,7 @@ and what outlived it. | 0062 | [共存機の Ollama は Windows の Scoop ollama-full に 1 台だけ置き、WSL はクライアントにする](./0062-one-windows-ollama-for-wsl.md) | Accepted; partly supersedes [0058](./0058-pi-web-search-ollama.md) | 2026-10-25 | `scripts/ollama_host.py`、`scripts/install_pi_extensions.py`、`docs/runbook/ollama-windows-wsl.md` | | 0063 | [宣言した機体だけ、tailnet の中だけの Tailscale 転送を Ollama の公開と数えない](./0063-declared-tailnet-forward-of-ollama.md) | Accepted | 2026-10-25 | `scripts/ollama_host.py`、`tests/unit/test_ollama_host.py`、`.github/workflows/unit-test.yaml`、`docs/runbook/ollama-windows-wsl.md` | | 0064 | [mirrored の WSL にはブリッジを置かず、共有の loopback で Windows の Ollama を使う](./0064-mirrored-wsl-uses-the-shared-loopback.md) | Accepted | 2026-10-25 | `scripts/ollama_host.py`、`tests/unit/test_ollama_host.py`、`docs/runbook/ollama-windows-wsl.md` | +| 0065 | [Linux カーネルの CVE は dotfiles が直さず、報告と助言だけを行う](./0065-kernel-cve-is-evidence-only.md) | Proposed(決裁待ち) | 2026-10-29 | `tools/kernel-doctor/`、`justfile`、`scripts/doctor.sh`、`tests/unit/test_doctor_kernel.py`、`docs/runbook/kernel-doctor.md` | ## Reading order for newcomers @@ -143,3 +144,4 @@ and what outlived it. 共存機の Ollama は Windows の Scoop `extras/ollama-full` に 1 台だけ置き、WSL はクライアントにする(PR #502 の実装の記録)。DR 間整合検査で検出した矛盾を所有者が裁定した。 - 2026-10-25: [ADR-0063](./0063-declared-tailnet-forward-of-ollama.md)を採択。宣言した機体だけ、tailnet の中だけの Tailscale 転送を、厳しい条件で Ollama の公開と数えない。Funnel は認めない。 - 2026-10-25: [ADR-0064](./0064-mirrored-wsl-uses-the-shared-loopback.md)を採択。mirrored の WSL にはブリッジを置かず、共有の loopback で Windows の Ollama を使い、doctor が接続の前に持ち主を確かめる。 +- 2026-10-29: [ADR-0065](./0065-kernel-cve-is-evidence-only.md)を起票(Proposed、決裁待ち)。CVE-2026-98432(Linux カーネル IPv4 TCP の use-after-free)を契機に、カーネルは dotfiles が直さないこと、版の比較はクリーンな上流形かつレビュー済み branch のときだけ行い未判定を修正済みと数えないこと、kernel-doctor の所見は doctor の `err` に入れないこと、WSL カーネルを管理しない方針(0058 / 0064)を維持することを定める。 diff --git a/docs/decision-queue.md b/docs/decision-queue.md index 73107a59..b7ce4582 100644 --- a/docs/decision-queue.md +++ b/docs/decision-queue.md @@ -7,7 +7,8 @@ ## 早見表(決裁待ち) | ID | 何を決める(ひとことで) | 種別 | 作者 | 作者連絡先 | 決める人 | 決裁者連絡先 | 期限 | 状態 | 相談記録 | 最終催促日 | 備考 | 元ファイル | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| ADR-0065 | Linux カーネルの CVE は dotfiles が直さず、報告と助言だけを行う | ADR | Pi(owner session) | @hironow | Hiroto N. | @hironow | — | 決裁待ち | 2026-10-29 の本セッションで所有者が選択(深さ = runbook + `just doctor` の advisory + Go の `kernel-doctor`、記録 = ADR + 本行、WSL 方針 = 維持) | — | 独立レビュー(Codex 0.164.0)は REVISE、8 指摘を全件実測確認して反映済み。`just doctor` の exit 契約を守るため kernel の所見は `err` に入れない | [docs/adr/0065-kernel-cve-is-evidence-only.md](adr/0065-kernel-cve-is-evidence-only.md) | --- | ## 決定済みログ diff --git a/docs/plan/kernel-cve-evidence.md b/docs/plan/kernel-cve-evidence.md new file mode 100644 index 00000000..0eb53ab3 --- /dev/null +++ b/docs/plan/kernel-cve-evidence.md @@ -0,0 +1,198 @@ +# Linux カーネル CVE を「報告と助言」に留める kernel-doctor(CVE-2026-98432) + +**Date:** 2026-10-29 +**Status:** Reviewed(独立レビュー REVISE の 8 指摘を全件採用・反映済み。実装前) +**Branch:** `feat/kernel-doctor` +**Related:** [ADR-0058](../adr/0058-pi-web-search-ollama.md)(`.wslconfig` を管理しない Context)、[ADR-0064](../adr/0064-mirrored-wsl-uses-the-shared-loopback.md)、[ADR-0035](../adr/0035-runner-disk-gc.md)(self-hosted runner の WSL)、`ROOT_AGENTS_docs_agents_go-tooling.md`、`ROOT_AGENTS_docs_agents_dependency-policy.md`、前例: `tools/ca-doctor/`(`incidents/*.json`)、`tools/brotli-doctor/` +**Blocking decisions:** 利用者が 2026-10-29 に裁定済み — (1) 深さ = **runbook + `just doctor` の advisory 検査 + Go の `kernel-doctor` ツール**、(2) 記録 = **ADR + decision-queue 行**、(3) WSL 方針 = **維持**(カーネルは dotfiles が管理しない。runbook に operator 手順として書く) +**Graduates to:** ADR-0065(Proposed → 所有者の裁定で Accepted) + +## 目的 + +CVE-2026-98432(Linux カーネル IPv4 TCP の use-after-free)への対応を dotfiles リポジトリで行う。 + +**このリポジトリは Linux カーネルを同梱もビルドも固定もしない。** したがって「修正を適用する」対応は存在しない。できるのは「どのカーネルが露出しているかを調べ、レビュー済みの修正床と突き合わせ、直せないことを明示して是正手順を示す」ことだけである。本件はその検出と助言を、既存の `ca-doctor` / `brotli-doctor` と同じ規律(証拠であって判定ではない)で追加する。 + +## 確定させた事実(一次情報で確認済み) + +### 脆弱性そのもの + +- **F1**: CVE-2026-98432 は実在する。CNA は kernel.org、公開 2026-10-15。`ipv4: free inet_opt and ireq_opt after an RCU grace period`。導入は commit `079096f103fa`(2015年、v4.4 期)。修正版は **5.10.272 / 5.15.223 / 6.1.190 / 6.6.159 / 6.12.113 / 6.18.56 / 7.2.10**、mainline は 7.3。一次情報: `https://git.kernel.org/pub/scm/linux/security/vulns.git/plain/cve/published/2026/CVE-2026-98432.mbox`、`https://www.cve.org/CVERecord?id=CVE-2026-98432`、OSV `https://osv.dev/vulnerability/CVE-2026-98432`、Red Hat Bugzilla 2553390。 +- **F2**: **提示された深刻度の前提は争いがある。** NVD / Tenable / Rapid7 は `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H` = 9.8。一方 Oracle の自社記録は `AV:N/AC:H` で **8.1**、Red Hat の優先度は critical ではなく **high**(Bugzilla は `NEW` のまま)。EPSS 0.00388(約0.4%)、CISA KEV 未収載(KEV 未収載は「CISA が実悪用を把握していない」であり、**公開 exploit の不在の証明ではない**。EPSS も確率の推定値)。到達には「SYNACK 組み立て中に子ソケットが解放される競合」または「request socket migration(`net.ipv4.tcp_migrate_req=1`、または BPF reuseport プログラム)」が必要で、AC:L の 9.8 は悲観側の見積もりである。**runbook には両方を併記する。** +- **F3**: 修正は `inet_sock_destruct()` と `tcp_v4_reqsk_destructor()` の `kfree()` を `kfree_rcu()` に変えるもの。IPv6 側の同型問題(`tcp_v6_reqsk_destructor()` の `ipv6_opt`、CALIPSO 時のみ)は別 patch で扱われ、**本 CVE の修正範囲外**である。 + +### dotfiles 側の露出 + +- **F4**: dotfiles は Linux カーネルを一切持たない。カーネル成果物・カーネル版の pin・カーネルをビルドする recipe はどれも存在しない(全ツリーを検索して確認)。 +- **F5**: dotfiles は **WSL カーネルを管理しない**。`config/wsl/wslconfig` は `%USERPROFILE%\.wslconfig` の「推奨テンプレート」で、`just wslconfig`(`scripts/wslconfig_conf.sh`)は差分を表示するだけで**自己適用しない**。ADR-0058 Context と ADR-0064 は「dotfiles は `.wslconfig` を変えない」と明記する。`just wsl-conf` が扱うのは `/etc/wsl.conf` の interop/systemd キーで、カーネルではない。WSL カーネルの供給元は Microsoft(`wsl --update`)。 +- **F6**: 露出面は 4 つ。(a) **WSL2 とその中の self-hosted runner** — カーネルは Microsoft。(b) **devcontainer / emulator / telemetry のコンテナ** — ホストカーネルを共有する(macOS では OrbStack / Docker Desktop の VM、Windows では WSL2)。(c) **macOS ホスト** — 対象外。(d) `tofu/` `gcloud/` 等 — カーネル成果物なし。 +- **F7**: **この作業機は macOS**(`Darwin 27.0.0 arm64`、`uname -s` = Darwin)。したがって本機は CVE の対象外であり、かつ**この機から Windows ホストへ到達できない**(`wsl.exe` は存在しない)。実機の WSL カーネル版は本作業では検証できない。だからこそ `--osrelease` の口を設計に入れる(D5)。 + +### リポジトリの規約(前例) + +- **F8**: `ca-doctor` の形: `Finding{Level, Name, Detail}` と `Report()`(exit 0 / WARN で 1 / ERR で 2)、`strict` な JSON 検証(`encoding/json/v2` の `json.RejectUnknownMembers(true)`)、`//go:embed *.go go.mod incidents/*.json` を `SourceMatches()` で check out とバイト比較する**ビルド鮮度検査**、`incidents/*.json` に `reviewedAt` / `reviewAfter` を持たせ `IncidentFreshness()` で期限切れを WARN にする、`VersionFloor{branch, minimum}` による版比較。 +- **F9**: `brotli-doctor` の形: 判定を出さない。`RuntimeReview` に `Comparison`(`below-reviewed-release-threshold` / `at-or-above-reviewed-release-threshold` / `unverified-version`)と **`binary_identity: unverified` / `patch_status: unverified` を常に併記**する。冒頭コメントは "It reports Brotli evidence and uncertainty, never a clean bill of health."。`--json` を持つ。 +- **F10**: `scripts/doctor_lines.py` が `LEVEL name - detail`(LEVEL は 4 文字 pad)の line protocol を定義する。`scripts/doctor.sh` の CA ブロックは、子プロセスの rc と OK/WARN/ERR の**件数を照合してから**受理し、不一致・空・クラッシュは `WARN ...-check` に落とす(部分成功を洗浄しない)。`tests/unit/test_doctor_ca.py` がそのブロックを正規表現で切り出し、偽バイナリで検証する。 +- **F11**: justfile の doctor 系レシピの形: `-doctor-build`(**明示ビルドのみ**。doctor 自身はコンパイラを入れない)、`-doctor *args`、`-doctor-build-all`(linux/darwin/windows × amd64/arm64 の 6 ターゲット、`CGO_ENABLED=0`)。出力は gitignore 済みの `output//`。 +- **F12**: Go の規約は `tools//` の自前 module、`go` ディレクティブ ≥ 1.27、stdlib 優先、ルート `.golangci.yaml` を共有(gofumpt のみ)。`just go-test` は `git ls-files -z 'tools/**/go.mod'`、`just go-lint` は `git ls-files -z '*go.mod'` を走査する(`justfile:834` / `justfile:845`)。**両方とも `git ls-files` なので、未追跡の module は走査されない** — 新しい module を gate に入れるには `git add` が要る。 +- **F13**: `just check` = ruff format/check + ty + rttm-check + go-lint + shellcheck + markdownlint(emulator/telemetry 以外の全 `*.md`)+ vp check + … + **go-test**。`just ci` = check + lint-claude + test-unit + semgrep-test + test-iac + instruction-budget + skills-lock-check + emu-lint + forbidden-token 2 種。 +- **F15**: `.gitattributes` は `tools/ca-doctor/go.mod` と `tools/ca-doctor/incidents/*.json` を `text eol=lf` に固定する(`.gitattributes:24-25`)。`tools/kernel-doctor/` の同種パスは `git check-attr text eol` が `unspecified` を返す。`SourceMatches()` は埋め込みバイトと作業ツリーのバイトを比べるので、この固定が無いと CRLF checkout で誤って「stale」になる。 +- **F16**: `scripts/doctor.sh` は `test_doctor_sandbox`(`tests/test_just_sandbox.py:657`)が **sandbox 内で `just doctor` を実行し exit 0 を assert** する。つまり `just doctor` の exit 1 は `just test` を赤くする。`justfile:655` の `just self-check` も同じ。doctor の集計は WARN では exit 0、ERR では exit 1(`scripts/doctor.sh` 末尾)。 +- **F14**: ADR は `docs/adr/NNNN-short-title.md`。次の番号は **0065**。`docs/adr/README.md` の索引表に行を足す。新規の決定案は ADR-0051 により `docs/decision-queue.md` の早見表に行を足す。ADR テンプレートは `ROOT_AGENTS_docs_agents_docs-discipline.md`。 + +## 設計上の判断(レビューで争ってほしい点) + +- **D1: `kernel-doctor` は ERR を出さない(WARN 止まり)。** ca-doctor は「repo が管理する道具」が修正床を下回れば ERR を出す。カーネルは repo が直せない。ERR は `doctor.sh` の集計で global な `err` に入り(`scripts/doctor.sh:379`)、`just doctor` が exit 1 になる。exit 1 は **`just self-check`(`justfile:655`)** と **`test_doctor_sandbox`(`tests/test_just_sandbox.py:657`、`just test` の leg)** を赤くする。どちらも **operator がリポジトリ外でしか直せない条件**で落ちることになる。前例は `win-machine-path`(管理者の手動修復を前提に WARN、`scripts/doctor.sh:162`)。 + - **さらに、ツールの自己申告に頼らない。** kernel ブロックは CA ブロックと違い、受理した ERR を global な `err` に**加算しない**。想定外の ERR や整合しない終了コードは単一の `WARN kernel-check` に落とす。これで「ツールが ERR を出さない約束」ではなく「ERR が doctor の exit を変えられない」構造になる。 +- **D2: 版の数値比較だけで「影響あり」と言わない。** これは本設計の中心。 + - Ubuntu `6.8.0-45-generic` や Debian `6.1.0-28-amd64` の第 3 成分は**上流の patch level ではない**(ベンダーの ABI/リビジョン)。上流 6.1.190 と単純比較すると**誤判定**する。ベンダーは修正を backport するので、数値が古くても修正済みがありうる。 + - **比較は「文字列全体がクリーンな上流リリース形のとき」だけ行う。** 先頭の 3 成分を抜き出して残りを捨てると、`6.6.159+`(Linux が実際に付ける)・`6.6.159.2`・`6.6.159garbage` がすべて「6.6.159 以上」に化けてしまう。表示用の数値 prefix と、比較可能かどうかを分ける。 + - したがって比較するのは `^[0-9]+\.[0-9]+\.[0-9]+$` を**全体が**満たし、かつ `major.minor` が**レビュー済み branch(5.10 / 5.15 / 6.1 / 6.6 / 6.12 / 6.18 / 7.2)に一致する**ときだけ。7.3 以上は「レビュー済み修正版以上」。 + - それ以外(suffix 付き = vendor、および 4.19 / 5.4 / 6.8 / 6.17 などの未収載 branch、`+` や `.N` 付き、3 成分でない)は判定せず `unverified` として、レビュー済み修正床の一覧と「branch 自身の stable tree かベンダーの勧告を確認せよ」を出す。**未判定を「修正済み」として数えない。** + - 固定する期待値(受入テスト): + + | 入力 | 比較結果 | + | --- | --- | + | `6.6.150` | `below-reviewed-fix-floor` | + | `6.6.159` | `at-or-above-reviewed-fix-floor` | + | `7.3.0` | `at-or-above-reviewed-fix-floor`(mainline で修正済み) | + | `6.6.87.2-microsoft-standard-WSL2` | unverified(vendor。prefix `6.6.87` は表示のみ) | + | `6.8.0-45-generic` | unverified(vendor。branch 6.8 も未収載) | + | `6.1.0-28-amd64` | unverified(vendor) | + | `6.6.159+` / `6.6.159.2` / `6.6.159garbage` | unverified(クリーンな上流形でない) | + | `7.3.0-rc1` | unverified | + | `4.19.999` | unverified(branch 未収載) | + | `7.3` | unverified(3 成分でない) | + +- **D3: 「脆弱」と言わない。** 常に「レビュー済み上流修正床との比較」として述べ、brotli-doctor と同じ `binary_identity` / `patch_status` 相当の `unverified` を常時併記する。加えて coverage 行として常に出す: (a) カーネル更新が効くのは**そのホスト/VM が新しいカーネルで起動したとき**であり、プロセスやコンテナの再起動では効かない、(b) コンテナは**ホストカーネルを共有**するので報告される版はホストのもの、(c) vendor の backport はこの比較から見えない、(d) CIP などの長期維持 branch にはレビュー時点で修正が無い。 +- **D4: 到達性の証拠を 1 つ出す。** 第 2 の引き金である migration 経路について `/proc/sys/net/ipv4/tcp_migrate_req` を読む。**0 でも免責にならない**(一次情報は「`=1` **または** BPF_SK_REUSEPORT_SELECT_OR_MIGRATE プログラム」)。またこの sysctl は **network namespace ごと**に保持される。したがって 0 / 1 / 読めないのいずれでも「現在の namespace で観測した値」「BPF migration は未評価」「他 namespace は未評価」「第 1 経路(IP options 付きソケット)は外部から内省できないので未評価」を出す。BPF スキャナは作らない。 +- **D5: `--osrelease=` を用意する。** 別ホストのカーネル版を、その OS のコマンドを実行せずに評価できるようにする(クロス OS 実行をしない)。本作業では macOS から Windows ホストへ届かないので、operator が `wsl.exe -e uname -r` の結果を渡せる口が要る。 + - **明示された `--osrelease` は OS 判定より優先する。** そうしないと D5 の主要な用途(macOS から別ホストを評価)が動かない。override 時はローカルの `/proc` と `/etc/os-release` を**対象ホストの証拠として使わない**(「入力された版だけの評価。対象ホストの sysctl/ディストロは未観測」と明示する)。空文字の override も明示的な入力として扱い、欠損と区別する。 + - **非 Linux の通常実行(override なし)は範囲を限定する。** 「このローカル OS に Linux カーネルは無い」であって「安全」ではない。`not-applicable` の行に「この機体から使う WSL/Linux VM はこの実行では未評価」を必ず併記する。 +- **D6: WSL 方針は変えない。** runbook に `wsl --update` → `wsl --shutdown` → `uname -r` を **operator 手順**として書く。`.wslconfig` は引き続き管理しない(F5)。ADR-0065 がこの方針を記録し、0058 / 0064 は編集しない。 + +## 変更内容 + +### 1. `tools/kernel-doctor/`(新規 Go module、stdlib のみ) + +- `go.mod`: `module github.com/hironow/dotfiles/tools/kernel-doctor`、`go 1.27.x`(F12)。 +- `main.go`: CLI と出力。フラグは `--json`、`--osrelease=`、`--source `、`-h`。`Report()` は ca-doctor と同じく 0 / 1 / 2 を返す。usage は brotli-doctor と同じく `help` 定数。 +- `kernel.go`: カーネル版文字列の解析。**表示用の数値 prefix** と **比較可能性** を分離する。比較は D2 の表のとおり「文字列全体が `^[0-9]+\.[0-9]+\.[0-9]+$` かつ `major.minor` がレビュー済み branch(7.3 以上を含む)」のときだけ。判定語は `below-reviewed-fix-floor` / `at-or-above-reviewed-fix-floor` / `unverified-version` / `unverified-vendor` / `unverified-branch`。 +- `host.go`: `/proc/sys/kernel/osrelease`(Linux。**コンテナ内ではホストのカーネルを返す = 露出の正しい答え**)、`/proc/sys/net/ipv4/tcp_migrate_req`、`/etc/os-release` の `ID`。`--osrelease` が明示されていれば OS 判定より優先し、ローカルの `/proc`・`/etc/os-release` は読まない(D5)。`runtime.GOOS` が linux 以外で override も無ければ `not-applicable` の OK 行を出し、「WSL/Linux VM は未評価」を併記する。 +- `freshness.go`: 現在時刻を**引数で受け取る**鮮度判定(`IncidentFreshness` と同じ形)。`reviewAfter` の**翌日**から `policy_freshness=overdue` として、レビュー日・一次情報の更新案内・`EPSS`/`KEV` の**観測日**を出す。テストは `2026-11-26` 当日と `2026-11-27 00:00 UTC` の境界を固定する。 +- `incidents.go`: `Incident` の型と**厳密な検証**(F8 と同じ `encoding/json/v2` + `RejectUnknownMembers`、id の正規表現、`type` は `kernel-version-range` 固定、source / record は public https、`reviewedAt` / `reviewAfter` は `time.DateOnly` かつ after、`branches` は 1 件以上・branch が `^[0-9]+\.[0-9]+$`・floor が branch 接頭辞を持つ 3 成分版・branch の重複と親子の重なりを拒否、`advice` は 1 件以上)。`VersionFloor` は ca-doctor と同じ形を使う。 +- `integrity.go`: ca-doctor と同じ `//go:embed *.go go.mod incidents/*.json` + `SourceMatches()`。 +- `incidents/cve-2026-98432.json`: F1〜F3 の内容(branches 7 本 + mainline の注記、`reviewedAt: 2026-10-29`、`reviewAfter: 2026-11-26`、sysctl、advice、F2 の両論の CVSS/EPSS 値)。**数値は F1/F2 の一次情報の写しで、推測を書かない。** +- `*_test.go`: D2 の表の**全行**(`6.6.87.2-microsoft-standard-WSL2` / `6.8.0-45-generic` / `6.1.0-28-amd64` / `6.6.159+` / `6.6.159.2` / `6.6.159garbage` / `7.3.0-rc1` / `4.19.999` / `7.3` / 空)、branch 一致/不一致、floor の前後、incident JSON の不正(未知 member / 重複 branch / 不正な日付 / 非 https)、鮮度の境界(当日 / 翌日)、`--osrelease` の優先(darwin + override、Linux + 別ホスト override、明示的な空文字)、override 時にローカルの `/proc` を読まないこと、`not-applicable` の darwin 経路、sysctl の 3 状態(0 / 1 / 読めない)で **coverage 行が消えない**こと。 +- `README.md`: `tools/simple-server/README.md` に倣う(何をする道具か、`just kernel-doctor` / `--osrelease`、**判定ではない**こと、限界)。 + +### 2. `justfile` — 3 レシピ(F11 の形を踏襲) + +- `kernel-doctor-build`(明示ビルドのみ。doctor は自動ビルドしない) +- `kernel-doctor *args`(`[positional-arguments]`、brotli-doctor と同形) +- `kernel-doctor-build-all`(6 ターゲット) + +### 3. `scripts/doctor.sh` — advisory ブロックを 1 つ追加(境界コメント付き) + +CA ブロックの後に置く。ただし **`tests/unit/test_doctor_ca.py:20` が `^# CA health and incidents:` から `(?=^# AI tooling:)` までを切り出している**ので、そのまま間に挟むと CA テストが kernel ブロックまで取り込み、CA バイナリ不在時の件数が `0 1 0` → `0 2 0` になって**既存テストが落ちる**(レビューで実測)。 + +- CA ブロックの末尾に明示的な終端コメント、kernel ブロックに開始コメントを置き、`test_doctor_ca.py` の切り出しも終端コメントで止めるよう**更新する**(`test_doctor_ca.py` を変更一覧に含める)。 +- kernel ブロックは CA と同形の rc・件数照合を行うが、**ERR を global な `err` に加算しない**(D1)。ERR または整合しない終了コードは単一の `WARN kernel-check` に落とす。 +- `output/kernel-doctor/` が無ければ `WARN kernel-build` + `just kernel-doctor-build` の案内(ca-doctor と同じ)。 +- この「ERR が exit を変えられない」契約を `test_doctor_kernel.py` で固定する(ERR を出す偽バイナリで doctor が exit 0 のままであること)。 + +### 4. `tests/unit/test_doctor_kernel.py`(新規) + +`test_doctor_ca.py` と同形: doctor.sh から新ブロックを切り出し、偽バイナリで (a) バイナリ不在は clean ではない、(b) OK/WARN の件数が正しく数えられる、(c) 空・プロトコル違反・クラッシュ・rc 不一致は **unverified に落ちる**、(d) **偽バイナリが ERR を出しても doctor の `err` が 0 のまま(exit 0)** を検証する。 + +### 5. 文書 + +- `docs/runbook/kernel-doctor.md`(新規): F1〜F3 の事実、F2 の**両論併記**、D2 の誤判定の罠(ベンダー backport)、面ごとの是正手順。 + - **再起動の単位はカーネルの実行単位で書く。** 効くのは「新しいカーネルでホスト/VM を起動し直すこと」であり、プロセスやコンテナの再起動では効かない。 + - WSL は **Windows 側から** `wsl --update` → `wsl --shutdown` → `wsl.exe -d <対象 distro> -- uname -r` で確認する。`wsl --shutdown` は**全 distro と WSL2 VM を停止する**ので、runner(ADR-0035)の**実行中ジョブが無い時**に行う、と手順に書く。 + - ベンダーカーネルは各勧告と `apt` / `dnf` + ホスト再起動。コンテナはホスト VM/アプリ(OrbStack・Docker Desktop)の更新。macOS は対象外。 + - custom `kernel=` を使う WSL は別管理であること、CIP 等はレビュー日時点で未修正であること、**「更新コマンドが成功した=修正済み」ではない**こと。 + - `--osrelease` の使い方(macOS から Windows ホストを評価する口)。 +- `docs/adr/0065-*.md`(新規、Proposed): 「Linux カーネルの CVE は dotfiles が直さない。報告と助言だけを行う」。F4〜F5(管理しない)と D1〜D3(判定しない・ERR を出さない)を決定として書く。0058 / 0064 は編集しない。 +- `docs/adr/README.md`: 索引表に 0065 の行。 +- `docs/decision-queue.md`: 早見表に 1 行(状態は決裁待ち)。 +- `tools/README.md`: `kernel-doctor` の行。 +- `.gitignore`: `/output/kernel-doctor/`。 +- **`.gitattributes`**: `tools/kernel-doctor/go.mod` と `tools/kernel-doctor/incidents/*.json` に `text eol=lf` を追加。既存は CA の 2 行だけ(`.gitattributes:24-25`)で、新パスは `git check-attr` が `unspecified` を返す。`SourceMatches()` は**バイト比較**なので、CRLF で check out した Windows と別 OS でビルドしたバイナリが、同一コミットでも「stale」と誤判定する。 +- `tests/unit/test_gitattributes_eol.py`: 埋め込み対象の**非 Go ファイル**(`incidents/*.json`)まで検査対象を広げる(同種の再発防止)。 + +## 検証 + +- `just kernel-doctor-build` → `just kernel-doctor`。この機体(Darwin)は `not-applicable` の OK 行のみ、exit 0 を確認する。 +- `--osrelease` に WSL 風(`6.6.87.2-microsoft-standard-WSL2`)・Ubuntu 風(`6.8.0-45-generic`)・修正前 branch(`6.6.150`)・修正後(`6.6.159`)・未収載 branch(`6.1.190` の前後、`7.3.0`)を渡し、**D2 の降格が意図どおり**であることを確認する。 +- `git add tools/kernel-doctor`(**`go-test` は `git ls-files 'tools/**/go.mod'`、`go-lint` は `git ls-files '*go.mod'` で走査するので、未追跡の module は gate に入らない**)→ `just go-test` / `just go-lint`。ログに新 module の行が出ることを確認する(gofumpt / gocyclo / gosec を含む)。 +- `just kernel-doctor-build-all`(6 ターゲットがクロスビルドできること)。**クロスビルドの成功は Windows/WSL 実機での観測成功ではない**と報告で分ける。 +- `{{UV_RUN}} python -m pytest tests/unit/test_doctor_kernel.py -q` → その後 `just test-unit`。 +- `just doctor` が新ブロックを受理し、この機体で ERR を出さない(D1)ことを確認する。 +- `just check`。 +- **`just test`(full sandbox)。** `CLAUDE.md:79` は recipe 追加後に `just ci` ではなく full `just test` を要求する。`test_doctor_sandbox`(`tests/test_just_sandbox.py:657`)が sandbox 内で `just doctor` の exit 0 を assert するので、D1 がここで効くことを確認する。 +- 最後に `just ci`。 + +## やらないこと(非目標) + +- カーネルのビルド、patch、更新、`wsl --update` の実行、`.wslconfig` の書き込み。 +- ADR-0058 / 0064 の編集。WSL 方針の変更。 +- `emulator/` / `telemetry/` / `tofu/` の変更。 +- CVE フィードの取り込みや汎用の脆弱性スキャナ。本件は 1 件のレビュー済みレコードに限る。 +- **実機の Windows ホストのカーネル版の確認**(本機から到達できない。runbook が operator 手順として示す)。 +- 「脆弱である」という断定、および exploit 可能性の主張。 + +## 独立レビュー記録 + +**レビュー:** Codex CLI 0.164.0(`codex exec --skip-git-repo-check`、`ROOT_AGENTS_docs_agents_plan-review.md` の primary path)。2026-10-29。結論 **REVISE**。一次情報の抜粋(`/tmp/cve-2026-98432-primary-sources.md`)を渡した(この CVE はモデルの学習データに無いため)。 + +D1(WARN 止まり)・D2(ベンダー版を断定しない)・D3(`unverified` 常時併記)の方針自体は**支持**された。8 件の指摘を**すべて実測で確認して採用**した。反論した指摘は無い。 + +| # | 重要度 | 指摘 | 確認方法(自分で実測) | 反映 | +| --- | --- | --- | --- | --- | +| 1 | P1 | `--osrelease`(D5)と「非 Linux は即終了」(host.go)が矛盾し、主要な検証経路が動かない | 自分の計画 lines 51 vs 61 を突き合わせ | D5 に「override は OS 判定より優先、ローカルの `/proc` は読まない、非 Linux は範囲限定」を追記 | +| 2 | P1 | CA ブロックの直後への挿入は既存 CA テストの切り出しに取り込まれ、CA バイナリ不在時の件数が `0 1 0` → `0 2 0` になる。CA の受理は ERR を global な `err` に加算するので D1 も守れない | `tests/unit/test_doctor_ca.py:20`(`(?=^# AI tooling:)`)、`scripts/doctor.sh:379`、**`tests/test_just_sandbox.py:657`(`just doctor` の exit 0 を assert)** を実際に読んだ | 終端/開始コメントで境界を明示、`test_doctor_ca.py` を変更一覧へ、kernel ブロックは ERR を加算しない契約にし、テストで固定 | +| 3 | P2 | 先頭 3 成分 + `-` だけの解析では `6.6.159+` / `6.6.159.2` / `6.6.159garbage` が「6.6.159 以上」に化ける | 自分の計画の解析仕様(line 60)が残りを捨てる記述であることを確認。レビュアーは 13 入力の実測を提示 | D2 に受入テストの表(10 行)を追加。比較は文字列全体がクリーンな上流形のときだけ | +| 4 | P2 | `.gitattributes` の追加漏れ。`SourceMatches()` のバイト比較が CRLF checkout で誤判定する | `.gitattributes:24-25` が CA の 2 パスだけ。`git check-attr text eol` が新パスの両方に `unspecified` を返すことを実行して確認 | `.gitattributes` と `test_gitattributes_eol.py` を変更一覧へ | +| 5 | P2 | `reviewAfter` の**実行時**鮮度判定が未定義。また F2 の「公開 exploit なし」は根拠が無い | `tools/ca-doctor/incidents.go:131` の `IncidentFreshness` を読んだ。自分の一次情報ファイルには `KEV: false` と `EPSS 0.00388` しか無く、「公開 exploit の不在」は確立していない | `freshness.go` を追加、境界テスト(当日/翌日)を明記。F2 は「KEV 未収載・EPSS 0.00388(確率の推定であって不在の証明ではない)」に修正 | +| 6 | P2 | `tcp_migrate_req=0` では BPF migration 経路を除外できない。sysctl は namespace ごと | 一次情報が「`=1` **または** BPF_SK_REUSEPORT_SELECT_OR_MIGRATE プログラム」と明記しているのを確認 | D4 を「0 でも免責にならない。BPF・他 namespace・第 1 経路は未評価」に書き直し、coverage 行が消えないことをテスト | +| 7 | P2 | 「実行中プロセスは再起動まで」は、プロセス/コンテナの再起動で足りると読める。カーネルはホスト/VM の起動が必要 | 自分の D3 の文言が曖昧であることを確認 | D3 と runbook をカーネルの実行単位に修正。`wsl --shutdown` は runner を止めるため**アイドル時に実行**する手順を追加 | +| 8 | P2 | 検証手順が「新 module が gate に入る」「recipe 追加後は full `just test`」を取りこぼす | `justfile:834` と `justfile:845` が `git ls-files`(=未追跡は走査しない)であること、`CLAUDE.md:79` が full `just test` を要求することを実測 | 検証に `git add` の明示、`kernel-doctor-build-all`、full `just test` を追加。クロスビルドと実機観測を分けて報告 | + +**レビュアーの限界(自分で確かめた):** レビュアーの環境では `just check` が uv のキャッシュロック作成で中断しており、**ruff / markdownlint / golangci-lint の合格はレビュアーには未証明**。これは自分の検証で確かめる(§検証)。 + +**自分の誤りの訂正:** レビュー前、私は「ERR が赤くするのは `just self-check` だけ」と述べた。誤り。`tests/test_just_sandbox.py:657` の `test_doctor_sandbox` も `just doctor` の exit 0 を要求し、これは `just test` の leg である。`.github/` と 2 つのテストファイルしか grep していなかった。 + +## 検証記録(実測) + +環境: macOS `Darwin 27.0.0 arm64`、Go 1.27.3、golangci-lint 2.15.0。**Docker はこのホストで実行できない**(`just test` の recipe 自身が `⚠️ docker or @devcontainers/cli not found` と出す)。 + +### 通った検証 + +| 検証 | 結果 | +| --- | --- | +| `tools/kernel-doctor` の Go テスト(`go test ./...`) | PASS | +| `just go-test`(`tools/kernel-doctor` を含む 5 module) | PASS | +| `just go-lint`(11 module。`tools/kernel-doctor` を含むことをログで確認) | PASS(0 issues) | +| `ruff format --check` / `ruff check` / `ty` | PASS | +| `shellcheck`(全 `*.sh`) | PASS | +| `markdownlint-cli2`(新規・変更の md) | PASS | +| `semgrep`(meta ルール) | PASS | +| `lint-claude` / `semgrep-test` / `test-iac` / `instruction-budget` / `skills-lock-check` / `emu-lint` / forbidden-token 2 種 / mcp-node-runner / mise-versions-check / storage-bounds | PASS | +| `kernel-doctor-build-all`(6 ターゲット) | PASS。`file(1)` で ELF / Mach-O / PE32+ を確認 | +| D2 の受入表 13 行(実バイナリに `--osrelease` を投入) | 期待どおり(表のとおり) | +| `just doctor` の kernel ブロック | `+2 OK, +1 WARN, 0 ERR`。`err=15` は変更前と同一 | + +### このホストで完走できなかった検証と、その切り分け + +- **`just ci` は完走していない。** `check_uv_exclude_newer` で中断する。`emulator/pyproject.toml` と `telemetry/examples/pyproject.toml` の `exclude-newer-package` の絶対 cutoff(`2026-10-20` / `2026-10-21`)が 7 日の quarantine 窓を過ぎたため(実行日 2026-10-30)。両ファイルは `HEAD` とバイト一致で、本作業は触っていない。**時間経過で自然に赤くなる既存の失敗。** +- 中断が `go-test` より手前だったので、**未実行の leg を個別に全部走らせた: 12 PASS / 1 FAIL。** 唯一の FAIL は `just test-unit`。 +- **`test-unit` の唯一の失敗** `tests/unit/test_wsl_clipboard.py::test_root_installs_and_repeat_is_noop` は、**pristine な `HEAD` の worktree で同一に失敗することを実測**した(`git worktree add /tmp/dt-head HEAD` → 同じ AssertionError)。このテストの入力は `install.sh` と `tests/unit/_bash_hook.py` だけで、どちらも `HEAD` とバイト一致。**既存のホスト固有の失敗。**(bash の版を疑ったが、`resolve_bash` はどちらでも Homebrew bash 5.3 を返し、仮説は反証された。) +- **`just test`(full sandbox)は実行できない。** 111 件すべてが `PermissionError: [Errno 13] Permission denied: 'docker'` で、サンドボックスの fixture が docker を probe する段で落ちる(15 passed / 111 errors)。**pristine な `HEAD` の worktree で同じ失敗を実測**(52 errors / 4 passed)。`scripts/doctor.sh` はサンドボックスの中でしか使われないので、外側の docker probe の成否に影響しない。**既存の環境要因。** + +### この検証で確かめられていないこと(重要) + +- **`test_doctor_sandbox`(`tests/test_just_sandbox.py:657`)は実行できていない。** Docker が使えないため。D1 の契約は代わりに 2 つで押さえた: (a) `tests/unit/test_doctor_kernel.py` が**実物の `doctor.sh` のブロックを切り出し**、偽バイナリに `ERR` を出させても doctor の `err` が 0 のまま・exit 0 のままであることを assert する(このテストは PASS)、(b) このホストでの実 `just doctor` 実行で kernel ブロックが `err` を変えないことを実測。**ただしサンドボックス内での実行そのものは未検証なので、CI で初めて走る。** +- **Linux 実機での `/proc` 読み取り経路は未検証。** このホストに Docker も Linux VM も無いため。経路自体は `Probe` を注入する単体テストで覆っている(`TestLinuxRunReportsTheRunningKernel` / `TestUnreadableRunningKernelIsUnverified`)が、実カーネルでの観測ではない。 +- **クロスビルド成功は Windows / WSL 実機での観測成功ではない。** 実機のカーネル版は本作業では確認していない(macOS から Windows ホストへ到達できない)。runbook の operator 手順がその口である。 +- `just ci` 全体の exit 0 は、上記の既存 2 件(uv quarantine と wsl-clipboard テスト)が直るまで得られない。**本作業の範囲外。** diff --git a/docs/runbook/kernel-doctor.md b/docs/runbook/kernel-doctor.md new file mode 100644 index 00000000..94a3c02c --- /dev/null +++ b/docs/runbook/kernel-doctor.md @@ -0,0 +1,150 @@ +# Kernel doctor + +`just kernel-doctor` は、Linux カーネルの版を「レビュー済みの出典つき修正床」と +突き合わせ、**証拠と不確かさ**を出す。判定ではない。 + +**このリポジトリは Linux カーネルを同梱・ビルド・固定しない。** だからこの道具は +カーネルを直さないし、直せない。パッチを当てず、更新せず、インストールしない。 +「修正済み」とも「脆弱」とも言わない。 + +証明書は [ca-doctor](ca-doctor.md)、バイナリ内包物は [brotli-doctor](brotli-doctor.md) を使う。 +WSL のホスト側の設定は [windows-host](windows-host.md) と [ollama-windows-wsl](ollama-windows-wsl.md)。 + +## 実行方法 + +```bash +just kernel-doctor-build # 明示ビルドのみ。doctor は自動ビルドしない +just kernel-doctor +just kernel-doctor --json +just kernel-doctor-build-all # linux/darwin/windows × amd64/arm64 +``` + +別のホストの版を、その OS のコマンドを実行せずに調べる。 +`--osrelease` を渡すとローカルの `/proc` と `/etc/os-release` は**読まない**。 + +```bash +# Windows ホストの WSL カーネルを macOS から調べる +just kernel-doctor --osrelease="$(wsl.exe -d Ubuntu -- uname -r)" +``` + +終了コードは 0 が警告なし、1 が警告または未判定、2 が引数エラー。 +**0 を「安全」の条件にしない。** 未判定の行が無いことを意味するだけである。 + +## 出力の読み方 + +版の比較結果は 5 つ。`patch_status` と `backport_state` は**常に `unverified`**。 + +| 結果 | 意味 | +| --- | --- | +| `below-reviewed-fix-floor` | クリーンな上流リリース形で、レビュー済み branch の修正床より下 | +| `at-or-above-reviewed-fix-floor` | 同じく、修正床以上。**ベンダーの backport や実行中のパッチ状態は依然として未確認** | +| `unverified-vendor` | ベンダー/ディストロのカーネル。数値が上流の patch level ではないので比較しない | +| `unverified-branch` | クリーンな形だが、その branch はレビューしていない | +| `unverified-version` | クリーンな上流の `x.y.z` ではない。比較しない | + +行の接頭辞は doctor と同じ `LEVEL name - detail`(`scripts/doctor_lines.py`)。 + +## なぜベンダーのカーネルを判定しないか + +Ubuntu の `6.8.0-45-generic` や Debian の `6.1.0-28-amd64` の第 3 成分は、 +**上流の patch level ではない**。ベンダー自身の ABI リビジョンである。 +上流の修正床と数値で比べると**両方向に間違える**。数値が古くても修正は +backport されていることがあり、逆もある。 + +だから比較するのは次の両方を満たすときだけにした。 + +- 文字列**全体**がクリーンな上流リリース形 `x.y.z` である。 + 先頭 3 成分を抜き出して残りを捨てると、`6.6.159+`(Linux が実際に付ける)・ + `6.6.159.2`・`6.6.159garbage` がすべて「6.6.159 以上」に化ける。 +- `major.minor` がレビュー済み branch に一致する(または mainline の修正版以上)。 + +それ以外は `unverified` とし、レビュー済み修正床の一覧を出す。 +**未判定を「修正済み」として数えない。** 保守側に倒れるのは意図した設計である。 + +レビュー済み branch の**あいだ**にある branch(例: 6.13〜6.18)は +`unverified-branch` になる。これは意図した挙動で、修正床の一覧と +その branch 自身の stable tree かベンダーの勧告を読む。 + +## CVE-2026-98432 について + +`ipv4: free inet_opt and ireq_opt after an RCU grace period`。CNA は kernel.org、公開 +2026-10-15。導入は 2015 年の commit `079096f103fa`(v4.4 期)。レビュー済みの +上流修正版は 5.10.272 / 5.15.223 / 6.1.190 / 6.6.159 / 6.12.113 / 6.18.56 / 7.2.10、 +mainline は 7.3。修正は `inet_sock_destruct()` と `tcp_v4_reqsk_destructor()` の +`kfree()` を `kfree_rcu()` にするもの。 + +**深刻度には争いがある。** 次の 2 つは同じ脆弱性の別の見積もりである。 + +| 情報源 | スコア | 攻撃複雑度 | +| --- | --- | --- | +| NVD / Tenable / Rapid7 | 9.8 | `AC:L`(容易) | +| Oracle の自社記録(暫定と明記) | 8.1 | `AC:H`(競合を勝ち抜く必要) | + +Red Hat の優先度は critical ではなく **high**(Bugzilla は `NEW`、修正版は未確定)。 +EPSS は 0.00388(約 0.4%)、CISA KEV 未収載。 +**KEV 未収載は「CISA が実悪用を把握していない」であり、公開 exploit の不在の証明ではない。** + +引き金は 2 つ。どちらも到達には条件が要る。 + +1. 再送 SYN を別 CPU が処理する競合(子ソケットが解放される間に SYNACK を組み立てる)。 + この経路は **IP options を持つソケット**(`ip_options_build()` が `optlen` を読む)を要する。 +2. request socket の migration。`net.ipv4.tcp_migrate_req=1`、**または** + `BPF_SK_REUSEPORT_SELECT_OR_MIGRATE` プログラム。 + +この道具は 2 の sysctl を 1 つ読むが、**0 でも免責にならない**(BPF の経路がある)。 +sysctl は network namespace ごとに保持されるので、読んだ値は**現在の namespace のもの**である。 +経路 1 と BPF は外部から内省できないので、**調べていない**。 + +IPv6 側にも同型の問題がある(`tcp_v6_reqsk_destructor()` の `ipv6_opt`。CALIPSO のときだけ設定される)。 +これは**別の patch** で扱われ、本 CVE のレビュー済み修正範囲の外である。 + +## 面ごとの是正 + +| 面 | カーネルの供給元 | 是正 | +| --- | --- | --- | +| WSL2 と self-hosted runner | Microsoft | **Windows 側で** `wsl --update` | +| ベンダーのカーネル(Debian / Ubuntu / RHEL / SUSE / Amazon) | ベンダー | ベンダーの勧告とパッケージ更新(`apt` / `dnf`)+ **ホストの再起動** | +| devcontainer / emulator / telemetry のコンテナ | ホスト VM / WSL2 | OrbStack・Docker Desktop・WSL2 のカーネルを更新。**イメージ更新では変わらない** | +| macOS ホスト | Apple | 対象外 | + +WSL の手順は次に固定する。**再起動の単位はカーネルであり、プロセスやコンテナの再起動では効かない。** + +```bash +# Windows 側(管理者 PowerShell) +wsl --update +wsl --shutdown +# 新しいカーネルで起動したことを確かめる +wsl.exe -d -- uname -r +``` + +`wsl --shutdown` は**すべての distro と WSL2 VM を停止する**。self-hosted runner は +その WSL の中で動いているので、**実行中のジョブが無い時**に行う([ADR 0035](../adr/0035-runner-disk-gc.md))。 + +`kernel=` で独自カーネルを指定している WSL は別管理である。`wsl --update` では変わらない。 +**「更新コマンドが成功した=修正済み」ではない。** 新しいカーネルで起動したことを +`uname -r` で確かめる。 + +## 限界 + +- 版の比較は「報告された文字列」と「レビュー済みの上流修正床」の比較である。 + ベンダーの backport も、実行中のカーネルのパッチ状態も見えない。 +- コンテナの中で実行すると `/proc/sys/kernel/osrelease` は**ホストの**カーネルを返す。 + これは露出の正しい答えだが、ホストとコンテナを区別しない。 +- 経路 1(IP options 付きソケット)と BPF による migration は調べていない。 + 現在の network namespace 以外は調べていない。 +- 知っているのは記録済みの CVE だけである。汎用の脆弱性スキャナではない。 +- クロスビルドが通ることは、Windows や WSL の実機で観測できたことの証明ではない。 +- レビュー記録には期限がある。過ぎると `policy_freshness=overdue` を出す。 + 一次情報を読み直して記録を更新する。 + +## 一次情報 + +- upstream 勧告: +- CVE レコード: +- Oracle の評価(8.1 / `AC:H`): +- Red Hat Bugzilla 2553390: +- Tenable(9.8 / EPSS / KEV): +- Red Hat の CVE ページ: + +レビュー日は 2026-10-29、レビュー期限は 2026-11-26。 +記録の正本は `tools/kernel-doctor/incidents/` にある。 diff --git a/justfile b/justfile index c81ca7c3..7fe3e64e 100644 --- a/justfile +++ b/justfile @@ -1459,6 +1459,22 @@ brotli-doctor *args: brotli-doctor-build-all: @bash -c 'set -eu; for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do os="${target%/*}"; arch="${target#*/}"; suffix=""; if [ "$os" = windows ]; then suffix=.exe; fi; (cd tools/brotli-doctor && CGO_ENABLED=0 GOTOOLCHAIN=local GOOS="$os" GOARCH="$arch" mise exec -- go build -trimpath -o "../../output/brotli-doctor/brotli-doctor-$os-$arch$suffix" .); done' +# Opt-in Linux kernel CVE evidence. Compares the kernel version a host +# reports against reviewed, source-cited fix floors; never a verdict, and it +# patches nothing. doctor treats its output as advisory only (ADR-0065). +[group('Setup')] +kernel-doctor-build: + @cd tools/kernel-doctor && CGO_ENABLED=0 GOTOOLCHAIN=local mise exec -- go build -trimpath -o ../../output/kernel-doctor/ . + +[group('Test')] +[positional-arguments] +kernel-doctor *args: + @bash -c 'bin=output/kernel-doctor/kernel-doctor; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) bin="$bin.exe" ;; esac; if [ ! -x "$bin" ]; then echo "WARN kernel-build - run just kernel-doctor-build first"; exit 1; fi; exec "$bin" --source tools/kernel-doctor "$@"' -- "$@" + +[group('Setup')] +kernel-doctor-build-all: + @bash -c 'set -eu; for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do os="${target%/*}"; arch="${target#*/}"; suffix=""; if [ "$os" = windows ]; then suffix=.exe; fi; (cd tools/kernel-doctor && CGO_ENABLED=0 GOTOOLCHAIN=local GOOS="$os" GOARCH="$arch" mise exec -- go build -trimpath -o "../../output/kernel-doctor/kernel-doctor-$os-$arch$suffix" .); done' + # Prune rogue global installs of mise-managed AI CLIs (codex/claude-code/ # copilot/pi). A stray `npm install -g` — codex's built-in `codex update` # most of all — drops the package into the active node's global each time, diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 0bbf3186..2fdf30cb 100644 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -389,6 +389,46 @@ if [ -x "$_ca_bin" ]; then else log_warn 'ca-build' 'CA check unavailable -- run: just ca-doctor-build; then just doctor (docs/runbook/ca-doctor.md)' fi +# --- end CA health and incidents --- + +# Linux kernel CVE evidence: native Go binary, advisory only. This repository +# ships, builds and pins no Linux kernel, so nothing here is the repository's +# to fix. That is why the findings below are added to `warn` and never to +# `err`: an ERR would make `just doctor` exit 1, which breaks `just self-check` +# and test_doctor_sandbox over a condition only the operator can fix, and only +# outside this repository. An ERR from the checker is therefore downgraded to +# a single WARN. See ADR-0065 and docs/runbook/kernel-doctor.md. +_kernel_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +_kernel_bin="${_kernel_root}/output/kernel-doctor/kernel-doctor" +case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) _kernel_bin="${_kernel_bin}.exe" ;; esac +if [ -x "$_kernel_bin" ]; then + _kernel_rc=0 + _kernel_out="$("$_kernel_bin" --source "${_kernel_root}/tools/kernel-doctor" 2>&1)" || _kernel_rc=$? + _kernel_ok=0; _kernel_warn=0; _kernel_err=0; _kernel_bad=0 + while IFS= read -r line; do + case "$line" in + 'OK '*) _kernel_ok=$((_kernel_ok+1)) ;; + 'WARN '*) _kernel_warn=$((_kernel_warn+1)) ;; + 'ERR '*) _kernel_err=$((_kernel_err+1)) ;; + *) _kernel_bad=1 ;; + esac + done <<<"$_kernel_out" + # An empty result, a crash, an odd line, a stray ERR or an inconsistent exit + # code all mean the same thing: nothing was verified. + _kernel_valid=0 + if [ "$_kernel_bad" -eq 0 ] && [ "$_kernel_err" -eq 0 ]; then + if [ "$_kernel_rc" -eq 0 ] && [ "$_kernel_ok" -gt 0 ] && [ "$_kernel_warn" -eq 0 ]; then _kernel_valid=1; fi + if [ "$_kernel_rc" -eq 1 ] && [ "$_kernel_warn" -gt 0 ]; then _kernel_valid=1; fi + fi + if [ "$_kernel_valid" -eq 1 ]; then + ok=$((ok+_kernel_ok)); warn=$((warn+_kernel_warn)) + printf '%s\n' "$_kernel_out" + else + log_warn 'kernel-check' "checker did not finish consistently (exit $_kernel_rc); kernel CVE evidence unverified" + fi +else + log_warn 'kernel-build' 'kernel check unavailable -- run: just kernel-doctor-build; then just doctor (docs/runbook/kernel-doctor.md)' +fi # AI tooling: rtk, headroom, their telemetry and wiring (Claude / Pi / Codex # hooks and extensions, headroom's proxy). The rules live in a tested script; diff --git a/tests/unit/test_doctor_ca.py b/tests/unit/test_doctor_ca.py index 5db4b783..59b7ec82 100644 --- a/tests/unit/test_doctor_ca.py +++ b/tests/unit/test_doctor_ca.py @@ -17,7 +17,13 @@ def _run(tmp_path: Path, output: str | None, rc: int = 0) -> str: text = (ROOT / "scripts" / "doctor.sh").read_text(encoding="utf-8") helpers = re.search(r"(?ms)^log_ok\(\).*?^has\(\).*?$", text) - block = re.search(r"(?ms)^# CA health and incidents:.*?(?=^# AI tooling:)", text) + # Stop at the CA block's own end marker, not at the next section: the + # kernel block sits in between and would otherwise be executed here too, + # adding its own `WARN kernel-build` to every count this file asserts. + block = re.search( + r"(?ms)^# CA health and incidents:.*?(?=^# --- end CA health and incidents ---)", + text, + ) assert helpers and block, "doctor must wire the CA incident check cross-platform" scripts = tmp_path / "scripts" scripts.mkdir() diff --git a/tests/unit/test_doctor_kernel.py b/tests/unit/test_doctor_kernel.py new file mode 100644 index 00000000..6db42049 --- /dev/null +++ b/tests/unit/test_doctor_kernel.py @@ -0,0 +1,126 @@ +"""The kernel check is advisory: its findings never change doctor's exit code. + +`scripts/doctor.sh` wires kernel-doctor the way it wires ca-doctor — accept a +consistent run, and treat a crash, an empty result or an inconsistent protocol +as unverified rather than clean. It differs in one deliberate way (ADR-0065): +this repository ships no Linux kernel, so nothing the kernel checker reports is +the repository's to fix. Its findings are added to `warn` and never to `err`. + +That matters because `just doctor` exits non-zero on any ERR, and two callers +assert exit 0: `just self-check` and `test_doctor_sandbox`. Without this, an +unpatched kernel on a development host — something only the operator can fix, +and only outside this repository — would turn the repo's own gates red. +""" + +import re +import sys +from pathlib import Path + +import pytest +from _bash_hook import run_bash + +ROOT = Path(__file__).resolve().parents[2] +# doctor.sh runs `kernel-doctor.exe` under Git Bash (uname MINGW*/MSYS*/CYGWIN*), +# and run_bash always picks Git Bash on native Windows. Git Bash runs a shebang +# script even when its name ends in .exe, so the fake only needs that name. +KERNEL_DOCTOR_NAME = "kernel-doctor.exe" if sys.platform == "win32" else "kernel-doctor" + + +def _run(tmp_path: Path, output: str | None, rc: int = 0) -> tuple[str, int]: + text = (ROOT / "scripts" / "doctor.sh").read_text(encoding="utf-8") + helpers = re.search(r"(?ms)^log_ok\(\).*?^has\(\).*?$", text) + block = re.search(r"(?ms)^# Linux kernel CVE evidence:.*?(?=^# AI tooling:)", text) + assert helpers and block, "doctor must wire the kernel CVE check cross-platform" + scripts = tmp_path / "scripts" + scripts.mkdir() + if output is not None: + binary = tmp_path / "output" / "kernel-doctor" / KERNEL_DOCTOR_NAME + binary.parent.mkdir(parents=True) + binary.write_text( + "#!/bin/sh\n" + + "printf '%s\\n' '" + + output.replace("'", "'\"'\"'") + + "'\n" + + f"exit {rc}\n", + encoding="utf-8", + newline="\n", + ) + binary.chmod(0o755) + harness = scripts / "doctor.sh" + harness.write_text( + helpers[0] + + "\nok=0; warn=0; err=0\n" + + block[0] + + '\necho "counts $ok $warn $err"\n', + encoding="utf-8", + newline="\n", + ) + result = run_bash( + harness, + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + return result.stdout + result.stderr, result.returncode + + +def test_missing_checker_is_not_a_clean_scan(tmp_path: Path) -> None: + out, rc = _run(tmp_path, None) + assert "WARN kernel-build" in out + assert "just kernel-doctor-build" in out + assert "counts 0 1 0" in out + assert rc == 0 + + +@pytest.mark.parametrize( + ("output", "rc", "counts"), + [ + ("OK kernel-scope - 6.6.159", 0, "1 0 0"), + ("OK a - b\nWARN c - d", 1, "1 1 0"), + ("WARN kernel-version-x - unverified-vendor", 1, "0 1 0"), + ], +) +def test_findings_are_counted( + tmp_path: Path, output: str, rc: int, counts: str +) -> None: + out, _ = _run(tmp_path, output, rc) + assert "counts " + counts in out + assert "WARN kernel-check" not in out + + +@pytest.mark.parametrize( + ("output", "rc"), + [ + ("", 0), + ("bad protocol", 0), + ("OK a - b", 139), + ("OK a - b", 2), + ("WARN a - b", 0), + # An ERR must not be accepted, even though the tool never emits one. + ("ERR a - b", 2), + ("OK a - b\nERR c - d", 2), + ], +) +def test_inconsistent_results_are_unverified_and_never_fatal( + tmp_path: Path, output: str, rc: int +) -> None: + """A stray ERR is downgraded to a WARN -- this is the ADR-0065 contract. + + The kernel is not this repository's to fix, so no kernel finding may reach + doctor's `err` count or make `just doctor` exit non-zero; `just self-check` + and test_doctor_sandbox both assert exit 0. + """ + out, code = _run(tmp_path, output, rc) + assert "WARN kernel-check" in out + assert "counts 0 1 0" in out + assert "ERR" not in out + assert code == 0 + + +def test_an_err_never_reaches_doctors_error_count(tmp_path: Path) -> None: + """Even a checker that crashes with a bare ERR line stays advisory.""" + out, code = _run(tmp_path, "ERR kernel-coverage - something is badly wrong", 2) + assert "counts 0 1 0" in out, out + assert " 0 0 1" not in out, out + assert code == 0 diff --git a/tests/unit/test_gitattributes_eol.py b/tests/unit/test_gitattributes_eol.py index e570f49a..75048961 100644 --- a/tests/unit/test_gitattributes_eol.py +++ b/tests/unit/test_gitattributes_eol.py @@ -9,10 +9,16 @@ `golangci-lint run` and `golangci-lint fmt --diff` in `go-lint` — report a CRLF working tree as "File is not properly formatted", so an untouched `.go` file fails `just check` on a Windows host while the committed blob is LF. +The `tools/*-doctor` modules embed their own data files and compare the +embedded bytes with the working tree to detect a stale binary, so those files +need the same pin: a CRLF checkout would report a stale build that does not +exist. That set is derived from the `//go:embed` directives, so a new doctor is +covered without editing this file. """ from __future__ import annotations +import re import shutil import subprocess from pathlib import Path @@ -85,3 +91,47 @@ def test_go_sources_are_lf_pinned() -> None: f"git check-attr returned {len(lines)} lines for {len(files)} files, " f"offenders: {offenders[:5]!r}" ) + + +_EMBED = re.compile(r"^//go:embed (.+)$", re.MULTILINE) + + +def _embedded_data_patterns(module: Path) -> list[str]: + """The non-Go patterns in a module's `//go:embed` directives.""" + text = (module / "integrity.go").read_text(encoding="utf-8") + patterns = [ + pattern for match in _EMBED.finditer(text) for pattern in match.group(1).split() + ] + return [pattern for pattern in patterns if not pattern.endswith(".go")] + + +def test_embedded_data_files_are_lf_pinned() -> None: + """Data embedded for byte comparison must be LF on every OS. + + `SourceMatches` compares the embedded bytes against the working tree, so a + CRLF checkout reports a stale binary for a commit that did not change — + exactly the false alarm the LF pin exists to prevent. The set is read from + the embed directives rather than listed here, so a future doctor module is + covered on arrival instead of on its first Windows failure. + """ + modules = sorted(p.parent for p in (ROOT / "tools").glob("*/integrity.go")) + assert modules, "expected a tools/*/integrity.go to check" + checked = 0 + for module in modules: + patterns = _embedded_data_patterns(module) + assert patterns, f"{module.name} embeds no data file to pin" + for pattern in patterns: + pathspec = f"{module.relative_to(ROOT).as_posix()}/{pattern}" + files = _tracked(pathspec) + assert files, f"nothing tracked matches {pathspec}" + offenders = [ + line for line in _eol_attr_lines(files) if not line.endswith(": lf") + ] + assert not offenders, ( + f"{pathspec} must be pinned `text eol=lf` in .gitattributes: " + f"{module.name} embeds these bytes and compares them with the " + f"working tree, so CRLF reports a stale build for an unchanged " + f"commit; offenders: {offenders[:5]!r}" + ) + checked += len(files) + assert checked > 0 diff --git a/tools/README.md b/tools/README.md index cf34f44d..06ed300c 100644 --- a/tools/README.md +++ b/tools/README.md @@ -3,6 +3,7 @@ 開発やテストで使う補助ツール。 - [ca-doctor](../docs/runbook/ca-doctor.md):CA設定の健全性とレビュー済みのCA事故を診断するGoツール +- [kernel-doctor](../docs/runbook/kernel-doctor.md):実行中または報告されたLinuxカーネル版を、レビュー済みのCVE修正床と突き合わせるGoツール(判定ではない) - [rttm](./rttm/README.md):RTTM ファイルを ELAN(EAF)形式に変換し、可視化する Python のツール - [simple-server](./simple-server/README.md):localhost の https を確かめる Go の簡易サーバー - [tmux](./tmux/CHEATSHEET.md):tmux のキー操作の早見表 diff --git a/tools/kernel-doctor/findings.go b/tools/kernel-doctor/findings.go new file mode 100644 index 00000000..7e0cd14b --- /dev/null +++ b/tools/kernel-doctor/findings.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "io" + "strings" +) + +// Finding is one line of the doctor protocol: `LEVEL name - detail`, LEVEL +// padded to four. scripts/doctor_lines.py defines both the writer and the +// reader; this file is the writer for kernel-doctor. +type Finding struct { + Level string `json:"level"` + Name string `json:"name"` + Detail string `json:"detail"` +} + +func (f Finding) String() string { + return fmt.Sprintf("%-4s %s - %s", f.Level, clean(f.Name), clean(f.Detail)) +} + +// Control characters would let a kernel version string (an untrusted input +// when it comes from --osrelease) forge a second doctor line. +func clean(s string) string { + return strings.Map(func(r rune) rune { + if r < 32 || r == 127 { + return ' ' + } + return r + }, s) +} + +// Report writes findings and returns the exit code. +// +// There is deliberately no ERR path. This repository does not ship, build or +// pin a Linux kernel, so a finding here is never something `just doctor` may +// fail on: `just doctor` exiting 1 would break `just self-check` and +// test_doctor_sandbox, both of which assert exit 0, over a condition only the +// operator can fix and only outside this repository. A version comparison that +// cannot be made is reported as WARN, never as a verdict. See ADR-0065. +func Report(out io.Writer, findings []Finding) int { + code := 0 + for _, f := range findings { + if _, err := fmt.Fprintln(out, f.String()); err != nil { + return 1 + } + if f.Level != "OK" && code == 0 { + code = 1 + } + } + return code +} diff --git a/tools/kernel-doctor/freshness.go b/tools/kernel-doctor/freshness.go new file mode 100644 index 00000000..7cc53ee2 --- /dev/null +++ b/tools/kernel-doctor/freshness.go @@ -0,0 +1,22 @@ +package main + +import "time" + +// Freshness reports whether a reviewed record is still inside its review +// window, and the note to print when it is not. +// +// The clock is a parameter, not time.Now() inside, so the boundary is a fixed +// test: the record is still fresh on the review-after date itself and turns +// overdue at 00:00 of the following day. A stale record must stay visible +// rather than quietly losing authority — the reviewed floors are only as good +// as the day they were read. +func Freshness(inc Incident, now time.Time) (bool, string) { + after, err := time.Parse(time.DateOnly, inc.ReviewAfter) + if err != nil { + return false, "review-after date unreadable; policy_freshness=unknown" + } + if now.Before(after.AddDate(0, 0, 1)) { + return true, "" + } + return false, "policy_freshness=overdue since " + inc.ReviewAfter +} diff --git a/tools/kernel-doctor/go.mod b/tools/kernel-doctor/go.mod new file mode 100644 index 00000000..6ed36641 --- /dev/null +++ b/tools/kernel-doctor/go.mod @@ -0,0 +1,3 @@ +module github.com/hironow/dotfiles/tools/kernel-doctor + +go 1.27.3 diff --git a/tools/kernel-doctor/host.go b/tools/kernel-doctor/host.go new file mode 100644 index 00000000..3efe9a6f --- /dev/null +++ b/tools/kernel-doctor/host.go @@ -0,0 +1,74 @@ +package main + +import ( + "os" + "strings" +) + +// Probe is everything observed about the kernel under assessment. Tests build +// it directly, so no test needs /proc, Linux, or a fixture file. +type Probe struct { + GOOS string + OSRelease string // raw contents of /proc/sys/kernel/osrelease + OSReleaseOK bool + MigrateReq string // raw contents of /proc/sys/net/ipv4/tcp_migrate_req + MigrateReqOK bool + DistroID string + // Override is set when --osrelease supplied the version. The local /proc + // and /etc/os-release then describe a different machine and must not be + // reported as evidence about the kernel being assessed. + Override bool +} + +// These are absolute literals on purpose: no path comes from user input, so no +// flag can redirect a read. +const ( + osReleasePath = "/proc/sys/kernel/osrelease" + migrateReqPath = "/proc/sys/net/ipv4/tcp_migrate_req" + osReleaseFile = "/etc/os-release" +) + +// Collect reads the local host. On a non-Linux GOOS there is no /proc to read. +func Collect(goos string) Probe { + probe := Probe{GOOS: goos} + if goos != "linux" { + return probe + } + if data, err := os.ReadFile(osReleasePath); err == nil { + probe.OSRelease, probe.OSReleaseOK = string(data), true + } + if data, err := os.ReadFile(migrateReqPath); err == nil { + probe.MigrateReq, probe.MigrateReqOK = string(data), true + } + if data, err := os.ReadFile(osReleaseFile); err == nil { + probe.DistroID = distroID(string(data)) + } + return probe +} + +// Override replaces the local observation with a version reported for another +// host. The local sysctl and distro are dropped: they belong to the local +// machine and would otherwise be attributed to the kernel being assessed. +func Override(goos, osrelease string) Probe { + return Probe{GOOS: goos, OSRelease: osrelease, OSReleaseOK: true, Override: true} +} + +func distroID(text string) string { + for _, line := range strings.Split(text, "\n") { + if value, found := strings.CutPrefix(strings.TrimSpace(line), "ID="); found { + return strings.Trim(value, `"'`) + } + } + return "" +} + +func (p Probe) version() string { return strings.TrimSpace(p.OSRelease) } + +// sysctlValue reports the migration sysctl, and whether it was observed about +// the kernel under assessment at all. +func (p Probe) sysctlValue() (string, bool) { + if p.Override || !p.MigrateReqOK { + return "", false + } + return strings.TrimSpace(p.MigrateReq), true +} diff --git a/tools/kernel-doctor/incidents.go b/tools/kernel-doctor/incidents.go new file mode 100644 index 00000000..cebd732a --- /dev/null +++ b/tools/kernel-doctor/incidents.go @@ -0,0 +1,206 @@ +package main + +import ( + "encoding/json/v2" + "errors" + "fmt" + "net" + "net/url" + "regexp" + "strings" + "time" +) + +// VersionFloor is one reviewed upstream branch and its fix floor. The shape +// follows ca-doctor's VersionFloor: a branch name plus the minimum version +// that carries the fix. +type VersionFloor struct { + Branch string `json:"branch"` + Fixed string `json:"fixed"` +} + +// Sysctl is a kernel setting worth reporting as reachability evidence. +type Sysctl struct { + Path string `json:"path"` + Note string `json:"note"` +} + +// Observation is a third-party number (CVSS, EPSS, KEV membership) with the +// day it was observed. Numbers without an observation day go stale silently. +type Observation struct { + Label string `json:"label"` + Value string `json:"value"` + ObservedAt string `json:"observedAt"` + Source string `json:"source"` +} + +// Incident is one reviewed, source-cited record. Everything the tool reports +// comes from here; nothing is fetched at run time. +type Incident struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + CVE string `json:"cve"` + Source string `json:"source"` + Record string `json:"record"` + ReviewedAt string `json:"reviewedAt"` + ReviewAfter string `json:"reviewAfter"` + Branches []VersionFloor `json:"branches"` + FixedInMainline string `json:"fixedInMainline"` + Sysctls []Sysctl `json:"sysctls"` + Observations []Observation `json:"observations"` + Advice []string `json:"advice"` +} + +var ( + incidentID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) + cveForm = regexp.MustCompile(`^CVE-[0-9]{4}-[0-9]{4,}$`) + dnsHost = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$`) + sysctlPath = regexp.MustCompile(`^[a-z0-9_]+(?:\.[a-z0-9_]+)+$`) + incidents, incidentError = loadIncidents() +) + +// floorFor returns the reviewed fix floor for a major.minor branch. +func (i Incident) floorFor(branch string) (string, bool) { + for _, candidate := range i.Branches { + if candidate.Branch == branch { + return candidate.Fixed, true + } + } + return "", false +} + +// atOrAboveMainline reports whether a version is on a branch at or above the +// reviewed mainline fix. Versions at or above it carry the fix by construction. +func (i Incident) atOrAboveMainline(parts [3]int) bool { + fixed, ok := versionParts(i.FixedInMainline) + if !ok { + return false + } + return branchAtLeast(branchOf(parts), branchOf(fixed)) +} + +func publicHTTPS(raw string) bool { + u, err := url.Parse(raw) + return err == nil && u.Scheme == "https" && u.User == nil && u.RawQuery == "" && + u.Fragment == "" && u.Port() == "" && validHost(u.Hostname()) +} + +func validHost(host string) bool { + return strings.Contains(host, ".") && dnsHost.MatchString(host) && + net.ParseIP(host) == nil && !strings.Contains(host, "..") +} + +// ParseIncident rejects anything it does not fully understand: an unknown +// member, an overlapping or negative range, a non-https citation. A record +// the tool only half-reads would silently weaken every later finding. +func ParseIncident(data []byte) (Incident, error) { + var incident Incident + if err := json.Unmarshal(data, &incident, json.RejectUnknownMembers(true)); err != nil { + return incident, fmt.Errorf("parse incident: %w", err) + } + if err := validateMeta(incident); err != nil { + return incident, err + } + if err := validateBranches(incident); err != nil { + return incident, err + } + if err := validateDetail(incident); err != nil { + return incident, err + } + return incident, nil +} + +func validateMeta(i Incident) error { + if !incidentID.MatchString(i.ID) || i.Type != "kernel-version-range" || + i.Title == "" || len(i.Title) > 300 || !cveForm.MatchString(i.CVE) || + !publicHTTPS(i.Source) || !publicHTTPS(i.Record) { + return errors.New("invalid incident metadata") + } + reviewed, err := time.Parse(time.DateOnly, i.ReviewedAt) + if err != nil { + return fmt.Errorf("review date: %w", err) + } + after, err := time.Parse(time.DateOnly, i.ReviewAfter) + if err != nil || !after.After(reviewed) { + return errors.New("invalid review-after date") + } + return nil +} + +func validateBranches(i Incident) error { + if len(i.Branches) == 0 || len(i.Branches) > 32 { + return errors.New("incident needs 1-32 branches") + } + seen := map[string]bool{} + for _, floor := range i.Branches { + if _, valid := branchParts(floor.Branch); !valid || seen[floor.Branch] { + return errors.New("invalid/duplicate branch") + } + fixed, valid := versionParts(floor.Fixed) + if !valid || branchOf(fixed) != floor.Branch { + return errors.New("fix floor must be an x.y.z release on its own branch") + } + seen[floor.Branch] = true + } + if !cleanRelease.MatchString(i.FixedInMainline) { + return errors.New("fixedInMainline must be an x.y.z release") + } + return nil +} + +func validateDetail(i Incident) error { + if len(i.Advice) == 0 || len(i.Advice) > 32 { + return errors.New("incident needs 1-32 advice lines") + } + for _, advice := range i.Advice { + if strings.TrimSpace(advice) == "" || len(advice) > 500 { + return errors.New("invalid advice line") + } + } + for _, sysctl := range i.Sysctls { + if !sysctlPath.MatchString(sysctl.Path) || strings.TrimSpace(sysctl.Note) == "" { + return errors.New("invalid sysctl entry") + } + } + if len(i.Observations) == 0 { + return errors.New("incident needs at least one dated observation") + } + seen := map[string]bool{} + for _, o := range i.Observations { + if o.Label == "" || seen[o.Label] || strings.TrimSpace(o.Value) == "" || + !publicHTTPS(o.Source) { + return errors.New("invalid/duplicate observation") + } + if _, err := time.Parse(time.DateOnly, o.ObservedAt); err != nil { + return errors.New("observation needs an observation date") + } + seen[o.Label] = true + } + return nil +} + +func loadIncidents() ([]Incident, error) { + entries, err := buildSource.ReadDir("incidents") + if err != nil { + return nil, fmt.Errorf("incident directory: %w", err) + } + var result []Incident + seen := map[string]bool{} + for _, entry := range entries { + data, err := buildSource.ReadFile("incidents/" + entry.Name()) + if err != nil { + return nil, fmt.Errorf("incident read: %w", err) + } + incident, err := ParseIncident(data) + if err != nil { + return nil, fmt.Errorf("incident %s: %w", entry.Name(), err) + } + if seen[incident.ID] { + return nil, errors.New("duplicate incident id") + } + seen[incident.ID] = true + result = append(result, incident) + } + return result, nil +} diff --git a/tools/kernel-doctor/incidents/cve-2026-98432.json b/tools/kernel-doctor/incidents/cve-2026-98432.json new file mode 100644 index 00000000..005b57d3 --- /dev/null +++ b/tools/kernel-doctor/incidents/cve-2026-98432.json @@ -0,0 +1,65 @@ +{ + "id": "cve-2026-98432", + "type": "kernel-version-range", + "title": "ipv4: free inet_opt and ireq_opt after an RCU grace period", + "cve": "CVE-2026-98432", + "source": "https://git.kernel.org/pub/scm/linux/security/vulns.git/plain/cve/published/2026/CVE-2026-98432.mbox", + "record": "https://osv.dev/vulnerability/CVE-2026-98432", + "reviewedAt": "2026-10-29", + "reviewAfter": "2026-11-26", + "branches": [ + { "branch": "5.10", "fixed": "5.10.272" }, + { "branch": "5.15", "fixed": "5.15.223" }, + { "branch": "6.1", "fixed": "6.1.190" }, + { "branch": "6.6", "fixed": "6.6.159" }, + { "branch": "6.12", "fixed": "6.12.113" }, + { "branch": "6.18", "fixed": "6.18.56" }, + { "branch": "7.2", "fixed": "7.2.10" } + ], + "fixedInMainline": "7.3.0", + "sysctls": [ + { + "path": "net.ipv4.tcp_migrate_req", + "note": "one of the two trigger paths for request socket migration; 0 does not exclude a BPF_SK_REUSEPORT_SELECT_OR_MIGRATE program" + } + ], + "observations": [ + { + "label": "nvd-cvss", + "value": "9.8 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "observedAt": "2026-10-29", + "source": "https://www.tenable.com/cve/CVE-2026-98432" + }, + { + "label": "oracle-cvss", + "value": "8.1 CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H (marked preliminary; the upstream race needs to be won)", + "observedAt": "2026-10-29", + "source": "https://linux.oracle.com/cve/CVE-2026-98432.html" + }, + { + "label": "redhat-priority", + "value": "high (not critical); Fixed In Version empty at observation time", + "observedAt": "2026-10-29", + "source": "https://access.redhat.com/security/cve/CVE-2026-98432" + }, + { + "label": "epss", + "value": "0.00388 (percentile 5%); a probability estimate, not proof that no public exploit exists", + "observedAt": "2026-10-29", + "source": "https://www.tenable.com/cve/CVE-2026-98432" + }, + { + "label": "cisa-kev", + "value": "not listed (CISA has no record of exploitation in the wild; absence is not proof)", + "observedAt": "2026-10-29", + "source": "https://www.tenable.com/cve/CVE-2026-98432" + } + ], + "advice": [ + "WSL and its self-hosted runner: update from Windows with `wsl --update`, then `wsl --shutdown` and re-check with `wsl.exe -d -- uname -r`. dotfiles does not manage the WSL kernel or .wslconfig, and a custom kernel= is managed separately. `wsl --shutdown` stops every distro and the WSL2 VM, so run it when no runner job is in flight (ADR-0035).", + "Vendor kernels (Debian, Ubuntu, RHEL, SUSE, Amazon) carry their own ABI revisions and backports, so this comparison cannot speak for them: use the vendor advisory and package update. At observation time Debian and Oracle had fixes, one vendor had none, and CIP 4.19/5.10/6.1/6.12 had none.", + "Containers (devcontainer, emulator, telemetry) share the host kernel, so updating an image does not change it; and a kernel update only takes effect when the host or VM boots the new kernel. Restarting a process or a container is not enough.", + "Branches that are not in the reviewed set (for example 6.13-6.18) report unverified-branch by design: read the reviewed floors here and the branch's own stable tree or vendor advisory.", + "IPv6 has the same shape of bug in tcp_v6_reqsk_destructor (ipv6_opt, set only by CALIPSO) and is addressed by a separate patch, outside this CVE's reviewed fix range." + ] +} diff --git a/tools/kernel-doctor/incidents_test.go b/tools/kernel-doctor/incidents_test.go new file mode 100644 index 00000000..04a0c0e5 --- /dev/null +++ b/tools/kernel-doctor/incidents_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "encoding/json/v2" + "testing" +) + +func recordBytes(t *testing.T) []byte { + t.Helper() + data, err := buildSource.ReadFile("incidents/cve-2026-98432.json") + if err != nil { + t.Fatalf("the shipped record must be embedded: %v", err) + } + return data +} + +// The shipped record must parse. If it does not, every finding degrades to +// kernel-policy and this test says why. +func TestParseIncidentAcceptsTheShippedRecord(t *testing.T) { + incident, err := ParseIncident(recordBytes(t)) + if err != nil { + t.Fatalf("the shipped record must parse: %v", err) + } + if incident.ID != "cve-2026-98432" || incident.CVE != "CVE-2026-98432" { + t.Errorf("unexpected identity: %+v", incident) + } + if len(incident.Branches) != 7 { + t.Errorf("expected 7 reviewed branches, got %d", len(incident.Branches)) + } + if incident.FixedInMainline != "7.3.0" { + t.Errorf("FixedInMainline = %q, want 7.3.0", incident.FixedInMainline) + } + for _, floor := range incident.Branches { + if floor.Branch == "6.6" && floor.Fixed != "6.6.159" { + t.Errorf("6.6 floor = %q, want 6.6.159", floor.Fixed) + } + } +} + +// A record the tool only half-understands would silently weaken every later +// finding, so anything unclear must be rejected outright. +func TestParseIncidentRejectsMalformedRecords(t *testing.T) { + observations := func() []any { + return []any{map[string]any{ + "label": "nvd-cvss", + "value": "9.8", + "observedAt": "2026-10-29", + "source": "https://osv.dev/vulnerability/CVE-2026-98432", + }} + } + cases := []struct { + name string + mutate func(map[string]any) + }{ + {"unknown member", func(m map[string]any) { m["surprise"] = true }}, + {"wrong type", func(m map[string]any) { m["type"] = "something-else" }}, + {"bad cve id", func(m map[string]any) { m["cve"] = "CVE-2026" }}, + {"non-https source", func(m map[string]any) { m["source"] = "http://osv.dev/x" }}, + {"source with credentials", func(m map[string]any) { m["source"] = "https://u:p@osv.dev/x" }}, + {"source with a port", func(m map[string]any) { m["source"] = "https://osv.dev:8443/x" }}, + {"review-after before reviewed-at", func(m map[string]any) { m["reviewAfter"] = "2026-01-01" }}, + {"review-after equal to reviewed-at", func(m map[string]any) { m["reviewAfter"] = "2026-10-29" }}, + {"review date not a date", func(m map[string]any) { m["reviewedAt"] = "29/10/2026" }}, + {"no branches", func(m map[string]any) { m["branches"] = []any{} }}, + {"duplicate branch", func(m map[string]any) { + m["branches"] = []any{ + map[string]any{"branch": "6.6", "fixed": "6.6.159"}, + map[string]any{"branch": "6.6", "fixed": "6.6.160"}, + } + }}, + {"floor on the wrong branch", func(m map[string]any) { + m["branches"] = []any{map[string]any{"branch": "6.6", "fixed": "6.12.113"}} + }}, + {"floor is not a release", func(m map[string]any) { + m["branches"] = []any{map[string]any{"branch": "6.6", "fixed": "6.6"}} + }}, + {"branch is not major.minor", func(m map[string]any) { + m["branches"] = []any{map[string]any{"branch": "6", "fixed": "6.1.190"}} + }}, + {"mainline is not a release", func(m map[string]any) { m["fixedInMainline"] = "7.3" }}, + {"no advice", func(m map[string]any) { m["advice"] = []any{} }}, + {"blank advice", func(m map[string]any) { m["advice"] = []any{" "} }}, + {"no observations", func(m map[string]any) { m["observations"] = []any{} }}, + {"observation without a real date", func(m map[string]any) { + list := observations() + list[0].(map[string]any)["observedAt"] = "later" + m["observations"] = list + }}, + {"duplicate observation label", func(m map[string]any) { + list := observations() + m["observations"] = append(list, list[0].(map[string]any)) + }}, + {"sysctl without a note", func(m map[string]any) { + m["sysctls"] = []any{map[string]any{"path": "net.ipv4.tcp_migrate_req", "note": ""}} + }}, + {"sysctl path is not dotted", func(m map[string]any) { + m["sysctls"] = []any{map[string]any{"path": "tcp_migrate_req", "note": "x"}} + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var record map[string]any + if err := json.Unmarshal(recordBytes(t), &record); err != nil { + t.Fatalf("baseline record must decode: %v", err) + } + tc.mutate(record) + data, err := json.Marshal(record) + if err != nil { + t.Fatalf("re-encode: %v", err) + } + if _, err := ParseIncident(data); err == nil { + t.Fatalf("ParseIncident accepted a malformed record: %s", data) + } + }) + } +} + +func TestIncidentFloorLookup(t *testing.T) { + incident := mustIncident(t) + if floor, ok := incident.floorFor("6.6"); !ok || floor != "6.6.159" { + t.Errorf("floorFor(6.6) = %q,%v; want 6.6.159,true", floor, ok) + } + if _, ok := incident.floorFor("6.17"); ok { + t.Error("floorFor(6.17) must not find a reviewed floor") + } +} diff --git a/tools/kernel-doctor/integrity.go b/tools/kernel-doctor/integrity.go new file mode 100644 index 00000000..d3598deb --- /dev/null +++ b/tools/kernel-doctor/integrity.go @@ -0,0 +1,61 @@ +package main + +import ( + "bytes" + "embed" + "io/fs" + "os" + "path/filepath" +) + +// Include tests too: a stale build must not silently bypass a changed policy +// or oracle. There is no generated hash constant that can itself drift. +// +//go:embed *.go go.mod incidents/*.json +var buildSource embed.FS + +// SourceMatches compares bytes and file sets before any finding is accepted. +// This is freshness detection, not authentication of an untrusted executable. +// +// The comparison is byte-exact, so the embedded files must be pinned to LF in +// .gitattributes: otherwise a CRLF checkout on Windows reports a stale build +// for an unchanged commit. +func SourceMatches(dir string) bool { + var embedded []string + names := map[string]bool{} + for _, pattern := range []string{"*.go", "go.mod", "incidents/*.json"} { + matches, err := fs.Glob(buildSource, pattern) + if err != nil { + return false + } + embedded = append(embedded, matches...) + current, err := filepath.Glob(filepath.Join(dir, filepath.FromSlash(pattern))) + if err != nil { + return false + } + for _, name := range current { + relative, err := filepath.Rel(dir, name) + if err != nil { + return false + } + names[filepath.ToSlash(relative)] = true + } + } + if len(names) != len(embedded) { + return false + } + for _, name := range embedded { + if !names[name] { + return false + } + expected, err := buildSource.ReadFile(name) + if err != nil { + return false + } + actual, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name))) + if err != nil || !bytes.Equal(actual, expected) { + return false + } + } + return true +} diff --git a/tools/kernel-doctor/kernel.go b/tools/kernel-doctor/kernel.go new file mode 100644 index 00000000..b84b9d48 --- /dev/null +++ b/tools/kernel-doctor/kernel.go @@ -0,0 +1,181 @@ +package main + +import ( + "regexp" + "strconv" + "strings" +) + +// Outcomes of comparing a reported kernel version against the reviewed +// upstream fix floors. These are evidence labels, never a vulnerability +// verdict: no version string can show whether a vendor backported the fix, and +// nothing here observes a running kernel's patch state. +const ( + // BelowFloor: a clean upstream release, on a reviewed branch, below that + // branch's reviewed fix floor. + BelowFloor = "below-reviewed-fix-floor" + // AtOrAboveFloor: a clean upstream release, on a reviewed branch (or a + // branch at/above the reviewed mainline fix), at or above the floor. + AtOrAboveFloor = "at-or-above-reviewed-fix-floor" + // UnverifiedFormat: not a clean upstream x.y.z release string. + UnverifiedFormat = "unverified-version" + // UnverifiedVendor: a vendor/distro kernel, where the numeric components + // are the vendor's own ABI revision, not the upstream patch level — so the + // comparison would be wrong in both directions. + UnverifiedVendor = "unverified-vendor" + // UnverifiedBranch: clean upstream form, but that branch was not reviewed. + UnverifiedBranch = "unverified-branch" +) + +// A kernel version string cannot attest either of these. +const ( + unverified = "unverified" + // The fields below are always unverified; keeping them explicit is what + // stops a comparison from being read as a clearance (cf. brotli-doctor's + // RuntimeReview). +) + +var ( + // cleanRelease is the only form safe to compare. The whole string must + // match: taking a leading prefix and discarding the rest would turn + // `6.6.159+`, `6.6.159.2` and `6.6.159garbage` all into "6.6.159 or later". + cleanRelease = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + leadingNums = regexp.MustCompile(`^[0-9]+(?:\.[0-9]+)*`) + // vendorSuffix: distro/vendor shapes. Ubuntu `6.8.0-45-generic`, Debian + // `6.1.0-28-amd64`, WSL `6.6.87.2-microsoft-standard-WSL2`, RHEL + // `5.14.0-427.40.1.el9_4.x86_64`. + vendorSuffix = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?-[^+]+$`) + prerelease = regexp.MustCompile(`^(rc|alpha|beta|pre|git)`) + branchForm = regexp.MustCompile(`^[0-9]+\.[0-9]+$`) +) + +// Assessment is what a single reported kernel version yields. +type Assessment struct { + Raw string `json:"raw"` + Prefix string `json:"prefix,omitempty"` + Outcome string `json:"outcome"` + Branch string `json:"branch,omitempty"` + FixFloor string `json:"fixed_floor,omitempty"` + Reason string `json:"reason"` + // Always unverified, deliberately. + PatchStatus string `json:"patch_status"` + BackportState string `json:"backport_state"` +} + +// Assess compares a reported kernel version against the reviewed floors. +// +// It only compares when the whole string is a clean upstream release AND its +// major.minor is a reviewed branch (or at/above the reviewed mainline fix). +// Everything else is reported as unverified with a reason. Refusing to compare +// is the safe failure: an unverified result is never counted as fixed. +func Assess(raw string, inc Incident) Assessment { + a := Assessment{Raw: raw, PatchStatus: unverified, BackportState: unverified} + trimmed := strings.TrimSpace(raw) + a.Prefix = leadingNums.FindString(trimmed) + if trimmed == "" { + a.Outcome = UnverifiedFormat + a.Reason = "no kernel version available; nothing compared" + return a + } + if !cleanRelease.MatchString(trimmed) { + a.Outcome, a.Reason = incomparableReason(trimmed) + return a + } + parts, ok := versionParts(trimmed) + if !ok { + a.Outcome = UnverifiedFormat + a.Reason = "unparseable version; nothing compared" + return a + } + a.Branch = branchOf(parts) + if floor, found := inc.floorFor(a.Branch); found { + a.FixFloor = floor + a.Outcome = compareToFloor(parts, floor) + a.Reason = "compared as a clean upstream release on a reviewed branch; vendor backports and the running patch state stay unverified" + return a + } + if inc.atOrAboveMainline(parts) { + a.FixFloor = inc.FixedInMainline + a.Outcome = AtOrAboveFloor + a.Reason = "at or above the reviewed mainline fix; vendor backports and the running patch state stay unverified" + return a + } + a.Outcome = UnverifiedBranch + a.Reason = "clean upstream form, but this branch is not in the reviewed set; read the reviewed floors and the branch's own stable tree or vendor advisory" + return a +} + +// incomparableReason separates a vendor kernel from a malformed string. A +// prerelease suffix (`-rc1`) is not a vendor kernel. +func incomparableReason(s string) (string, string) { + if strings.Contains(s, "-") && vendorSuffix.MatchString(s) { + if suffix := s[strings.Index(s, "-")+1:]; !prerelease.MatchString(suffix) { + return UnverifiedVendor, "vendor/distro kernel: the numeric components are the vendor's own ABI revision, not upstream patch levels, and a backported fix is invisible here; use the vendor's advisory for this package" + } + } + return UnverifiedFormat, "not a clean upstream x.y.z release; refusing to compare a partly-numeric string" +} + +func branchOf(parts [3]int) string { + return strconv.Itoa(parts[0]) + "." + strconv.Itoa(parts[1]) +} + +// compareToFloor is 0 only for the exact floor; both operands are x.y.z. +func compareToFloor(parts [3]int, floor string) string { + f, ok := versionParts(floor) + if !ok { + return UnverifiedFormat + } + for i := range parts { + if parts[i] < f[i] { + return BelowFloor + } + if parts[i] > f[i] { + return AtOrAboveFloor + } + } + return AtOrAboveFloor +} + +func versionParts(s string) ([3]int, bool) { + var v [3]int + if !cleanRelease.MatchString(s) { + return v, false + } + for i, p := range strings.Split(s, ".") { + n, err := strconv.Atoi(p) + if err != nil { + return v, false + } + v[i] = n + } + return v, true +} + +// branchAtLeast compares a major.minor branch against another. +func branchAtLeast(branch, other string) bool { + b, okB := branchParts(branch) + o, okO := branchParts(other) + if !okB || !okO { + return false + } + if b[0] != o[0] { + return b[0] > o[0] + } + return b[1] >= o[1] +} + +func branchParts(s string) ([2]int, bool) { + var v [2]int + if !branchForm.MatchString(s) { + return v, false + } + for i, p := range strings.Split(s, ".") { + n, err := strconv.Atoi(p) + if err != nil { + return v, false + } + v[i] = n + } + return v, true +} diff --git a/tools/kernel-doctor/kernel_test.go b/tools/kernel-doctor/kernel_test.go new file mode 100644 index 00000000..c1da745b --- /dev/null +++ b/tools/kernel-doctor/kernel_test.go @@ -0,0 +1,123 @@ +package main + +import "testing" + +// mustIncident returns the one embedded record, failing loudly rather than +// letting a load error masquerade as a wrong comparison. +func mustIncident(t *testing.T) Incident { + t.Helper() + if incidentError != nil { + t.Fatalf("embedded incident data must load: %v", incidentError) + } + if len(incidents) != 1 { + t.Fatalf("expected 1 embedded record, got %d", len(incidents)) + } + return incidents[0] +} + +// The exact table the independent plan review required before implementation. +// Every row is a case where comparing naively would have produced a wrong +// answer; unverified is the safe failure and is never counted as fixed. +func TestAssessMatchesTheReviewedAcceptanceTable(t *testing.T) { + incident := mustIncident(t) + cases := []struct{ input, want string }{ + {"6.6.150", BelowFloor}, + {"6.6.159", AtOrAboveFloor}, + {"7.3.0", AtOrAboveFloor}, + {"6.6.87.2-microsoft-standard-WSL2", UnverifiedVendor}, + {"6.8.0-45-generic", UnverifiedVendor}, + {"6.1.0-28-amd64", UnverifiedVendor}, + {"6.6.159+", UnverifiedFormat}, + {"6.6.159.2", UnverifiedFormat}, + {"6.6.159garbage", UnverifiedFormat}, + {"7.3.0-rc1", UnverifiedFormat}, + {"4.19.999", UnverifiedBranch}, + {"7.3", UnverifiedFormat}, + {"", UnverifiedFormat}, + // Whitespace is not a version. + {" ", UnverifiedFormat}, + // Every other reviewed branch, floor and floor-minus-one. + {"5.10.272", AtOrAboveFloor}, + {"5.10.271", BelowFloor}, + {"5.15.223", AtOrAboveFloor}, + {"5.15.222", BelowFloor}, + {"6.1.190", AtOrAboveFloor}, + {"6.1.189", BelowFloor}, + {"6.12.113", AtOrAboveFloor}, + {"6.12.112", BelowFloor}, + {"6.18.56", AtOrAboveFloor}, + {"6.18.55", BelowFloor}, + {"7.2.10", AtOrAboveFloor}, + {"7.2.9", BelowFloor}, + // A clean form on a branch nobody reviewed. + {"6.17.0", UnverifiedBranch}, + {"8.0.0", AtOrAboveFloor}, + } + for _, tc := range cases { + got := Assess(tc.input, incident) + if got.Outcome != tc.want { + t.Errorf("Assess(%q).Outcome = %q, want %q (reason: %s)", tc.input, got.Outcome, tc.want, got.Reason) + } + } +} + +// No input may ever produce a patch-state claim: the tool compares a string +// against reviewed floors and observes nothing about the running kernel. +func TestAssessAlwaysLeavesPatchStateUnverified(t *testing.T) { + incident := mustIncident(t) + for _, input := range []string{"6.6.159", "6.6.150", "6.6.87.2-microsoft-standard-WSL2", "junk", ""} { + got := Assess(input, incident) + if got.PatchStatus != unverified || got.BackportState != unverified { + t.Errorf("Assess(%q) attested a patch state: %+v", input, got) + } + if got.Reason == "" { + t.Errorf("Assess(%q) gave no reason", input) + } + } +} + +// A below-floor result must name the floor it was compared against, or the +// warning is not actionable. +func TestAssessNamesTheFloorItCompared(t *testing.T) { + incident := mustIncident(t) + got := Assess("6.6.150", incident) + if got.FixFloor != "6.6.159" { + t.Errorf("FixFloor = %q, want 6.6.159", got.FixFloor) + } + if got.Branch != "6.6" { + t.Errorf("Branch = %q, want 6.6", got.Branch) + } + if got.Prefix != "6.6.150" { + t.Errorf("Prefix = %q, want 6.6.150", got.Prefix) + } +} + +// A vendor kernel still shows its numeric prefix for a human to read, even +// though it must not be compared. +func TestAssessKeepsTheNumericPrefixForDisplay(t *testing.T) { + incident := mustIncident(t) + if got := Assess("6.6.87.2-microsoft-standard-WSL2", incident); got.Prefix != "6.6.87.2" { + t.Errorf("Prefix = %q, want 6.6.87.2", got.Prefix) + } + if got := Assess("junk", incident); got.Prefix != "" { + t.Errorf("Prefix = %q, want empty for a non-numeric string", got.Prefix) + } +} + +func TestBranchHelpers(t *testing.T) { + if !branchAtLeast("7.3", "7.3") { + t.Error("7.3 must be at least 7.3") + } + if branchAtLeast("7.2", "7.3") { + t.Error("7.2 must not be at least 7.3") + } + if branchAtLeast("6.99", "7.3") { + t.Error("6.99 must not be at least 7.3") + } + if _, ok := branchParts("6"); ok { + t.Error("a single component is not a branch") + } + if _, ok := versionParts("6.6"); ok { + t.Error("6.6 is not a three-component release") + } +} diff --git a/tools/kernel-doctor/main.go b/tools/kernel-doctor/main.go new file mode 100644 index 00000000..89d00055 --- /dev/null +++ b/tools/kernel-doctor/main.go @@ -0,0 +1,242 @@ +// kernel-doctor reports the Linux kernel a host would run, and compares its +// reported version against reviewed, source-cited CVE fix floors. It reports +// evidence and uncertainty, never a vulnerability verdict: it does not patch, +// build, install or update a kernel, and it never claims a host is safe. +package main + +import ( + "encoding/json/v2" + "errors" + "flag" + "fmt" + "io" + "os" + "runtime" + "strings" + "time" +) + +const ( + notice = "Evidence only, NOT a security clearance. A version string shows neither whether a vendor backported the fix nor whether a running kernel carries it. Unverified is never counted as fixed." + help = `usage: kernel-doctor [--json] [--osrelease=] [--source=] +Compares the kernel version a host reports against reviewed, source-cited CVE +fix floors. Never a vulnerability verdict; vendor backports, the running patch +state and exploitability stay unverified. +With --osrelease, only that reported version is assessed and the local /proc +and /etc/os-release are not read (use it to assess another host, e.g. from a +Windows host: wsl.exe -e uname -r). +Exit: 0 no warnings, 1 warnings or unverified, 2 usage error. +It never installs, builds or updates a kernel. +` +) + +// JSONReport is the --json shape. +type JSONReport struct { + Notice string `json:"notice"` + Scope string `json:"scope"` + Assessments []Assessment `json:"assessments"` + Findings []Finding `json:"findings"` +} + +func main() { os.Exit(RunCLI(os.Args[1:], os.Stdout)) } + +// RunCLI is exported for the tests; the process entrypoint only wires stdio. +func RunCLI(args []string, out io.Writer) int { + flags := flag.NewFlagSet("kernel-doctor", flag.ContinueOnError) + flags.SetOutput(io.Discard) + asJSON := flags.Bool("json", false, "JSON output") + source := flags.String("source", "tools/kernel-doctor", "source directory for build freshness verification") + osrelease := flags.String("osrelease", "", "assess this reported kernel version instead of the local one") + if err := flags.Parse(args); err != nil { + _, _ = io.WriteString(out, help) + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if flags.NArg() != 0 { + _, _ = io.WriteString(out, help) + return 2 + } + // Visit distinguishes "not supplied" from "supplied empty": an empty + // override is an explicit input, not a missing one. + override := false + flags.Visit(func(f *flag.Flag) { + if f.Name == "osrelease" { + override = true + } + }) + + probe := Collect(runtime.GOOS) + if override { + probe = Override(runtime.GOOS, *osrelease) + } + found := findings(probe, *source, time.Now()) + + if *asJSON { + report := JSONReport{Notice: notice, Scope: scopeText(probe), Assessments: assessments(probe), Findings: found} + data, err := json.Marshal(report) + if err != nil { + return 1 + } + if _, err := out.Write(append(data, '\n')); err != nil { + return 1 + } + for _, f := range found { + if f.Level != "OK" { + return 1 + } + } + return 0 + } + return Report(out, found) +} + +func findings(probe Probe, source string, now time.Time) []Finding { + if !SourceMatches(source) { + return []Finding{{"WARN", "kernel-build", "missing/changed source or stale binary; run just kernel-doctor-build before trusting any finding"}} + } + if incidentError != nil { + // The record lives in this repository, so naming the fault is a + // developer aid, not a leak of host data. + return []Finding{{"WARN", "kernel-policy", "invalid embedded incident data (" + incidentError.Error() + "); review the source and rebuild; no comparison performed"}} + } + out := []Finding{{"OK", "kernel-build", fmt.Sprintf("binary source and incident data match the checkout; %d reviewed record(s)", len(incidents))}} + out = append(out, scopeFindings(probe)...) + if probe.GOOS != "linux" && !probe.Override { + // No Linux kernel to assess. There is nothing to compare, and saying + // more would read as a clearance. + return append(out, coverageFinding()) + } + for _, incident := range incidents { + out = append(out, assessmentFindings(probe, incident)...) + } + for _, incident := range incidents { + out = append(out, freshnessFindings(incident, now)...) + } + return append(out, coverageFinding()) +} + +func scopeFindings(probe Probe) []Finding { + switch { + case probe.Override: + return []Finding{{"WARN", "kernel-scope", "assessing only the version given to --osrelease; the local /proc and /etc/os-release were not read, so this host's own kernel and the target host's settings are not assessed by this run"}} + case probe.GOOS != "linux": + return []Finding{{"OK", "kernel-not-applicable", "no Linux kernel runs on this local OS (" + probe.GOOS + "); any WSL or Linux VM used from this machine is NOT assessed by this run"}} + case !probe.OSReleaseOK: + return []Finding{{"WARN", "kernel-scope", "cannot read " + osReleasePath + "; the running kernel version is unverified"}} + } + return []Finding{{"OK", "kernel-scope", "running kernel " + probe.version() + " (" + hostOrigin(probe) + ")"}} +} + +func hostOrigin(probe Probe) string { + if probe.DistroID == "" { + return "this is the host kernel and containers share it" + } + return "distro " + probe.DistroID + "; this is the host kernel and containers share it" +} + +func assessments(probe Probe) []Assessment { + if !probe.OSReleaseOK { + return nil + } + result := make([]Assessment, 0, len(incidents)) + for _, incident := range incidents { + result = append(result, Assess(probe.version(), incident)) + } + return result +} + +func scopeText(probe Probe) string { + if probe.Override { + return "override-version-only" + } + return probe.GOOS +} + +func assessmentFindings(probe Probe, incident Incident) []Finding { + var out []Finding + if !probe.OSReleaseOK { + out = append(out, Finding{"WARN", "kernel-version-" + incident.ID, "running kernel version unavailable; nothing compared"}) + } else { + a := Assess(probe.version(), incident) + level := "WARN" + if a.Outcome == AtOrAboveFloor { + level = "OK" + } + detail := fmt.Sprintf("%s %s; %s; patch_status=%s backport_state=%s; %s", + incident.CVE, a.Outcome, a.Reason, a.PatchStatus, a.BackportState, floorList(incident)) + out = append(out, Finding{level, "kernel-version-" + incident.ID, detail}) + } + out = append(out, sysctlFindings(probe, incident)...) + // One line for every third-party figure: they are context for a human, not + // separate actions, and one line each would dominate `just doctor`. + out = append(out, Finding{ + "WARN", "kernel-observed-" + incident.ID, + observationList(incident) + "; third-party figures read on their observation dates, not measured here", + }) + for _, advice := range incident.Advice { + out = append(out, Finding{"WARN", "kernel-advice-" + incident.ID, advice}) + } + return out +} + +func sysctlFindings(probe Probe, incident Incident) []Finding { + var out []Finding + for _, setting := range incident.Sysctls { + name := "kernel-sysctl-" + incident.ID + "-" + strings.ReplaceAll(setting.Path, ".", "-") + value, observed := probe.sysctlValue() + switch { + case probe.Override: + out = append(out, Finding{"WARN", name, setting.Path + " not observed: --osrelease assesses a version only, so the target host's settings are unverified; " + setting.Note}) + case !observed: + out = append(out, Finding{"WARN", name, setting.Path + " unreadable; " + setting.Note}) + default: + level := "OK" + if value != "0" { + level = "WARN" + } + out = append(out, Finding{level, name, setting.Path + "=" + value + + " in this network namespace only (a BPF reuseport migration is invisible to this value, and other namespaces are not inspected); " + + setting.Note}) + } + } + return out +} + +func freshnessFindings(incident Incident, now time.Time) []Finding { + fresh, note := Freshness(incident, now) + if !fresh { + return []Finding{{ + "WARN", "kernel-freshness-" + incident.ID, + note + "; reviewed " + incident.ReviewedAt + "; re-read the primary sources and refresh the record", + }} + } + return []Finding{{"OK", "kernel-freshness-" + incident.ID, "reviewed " + incident.ReviewedAt + "; review by " + incident.ReviewAfter}} +} + +func observationList(incident Incident) string { + values := make([]string, 0, len(incident.Observations)) + for _, o := range incident.Observations { + values = append(values, o.Label+"="+o.Value+" ("+o.ObservedAt+")") + } + return strings.Join(values, "; ") +} + +func floorList(incident Incident) string { + floors := make([]string, 0, len(incident.Branches)) + for _, floor := range incident.Branches { + floors = append(floors, floor.Branch+"->"+floor.Fixed) + } + return "reviewed upstream floors: " + strings.Join(floors, " ") + "; mainline " + incident.FixedInMainline +} + +func coverageFinding() Finding { + return Finding{ + "WARN", "kernel-coverage", + "not a kernel attestation: a kernel update takes effect only when the host or VM boots the new kernel (restarting processes or containers is not enough); " + + "a vendor backport is invisible to this comparison; " + + "the first trigger path of this CVE (sockets carrying IP options) and any BPF reuseport migration are not inspected; " + + "see docs/runbook/kernel-doctor.md", + } +} diff --git a/tools/kernel-doctor/main_test.go b/tools/kernel-doctor/main_test.go new file mode 100644 index 00000000..5c42587f --- /dev/null +++ b/tools/kernel-doctor/main_test.go @@ -0,0 +1,314 @@ +package main + +import ( + "bytes" + "encoding/json/v2" + "strings" + "testing" + "time" +) + +func localProbe() Probe { return Collect("linux") } + +func linuxProbe(version, migrateReq string, migrateOK bool) Probe { + return Probe{GOOS: "linux", OSRelease: version, OSReleaseOK: true, MigrateReq: migrateReq, MigrateReqOK: migrateOK} +} + +func darwinProbe() Probe { return Probe{GOOS: "darwin"} } + +// The freshness boundary the review asked to pin: still fresh on the +// review-after date, overdue from 00:00 the next day. +func TestFreshnessBoundary(t *testing.T) { + incident := mustIncident(t) + freshAt := func(stamp string) bool { + now, err := time.Parse(time.RFC3339, stamp) + if err != nil { + t.Fatalf("bad test stamp %q: %v", stamp, err) + } + fresh, _ := Freshness(incident, now) + return fresh + } + if !freshAt("2026-11-26T23:59:59Z") { + t.Error("the record must still be fresh on the review-after date") + } + if freshAt("2026-11-27T00:00:00Z") { + t.Error("the record must be overdue from 00:00 the day after review-after") + } + if freshAt("2026-12-31T00:00:00Z") { + t.Error("a long-expired record must be overdue") + } + broken := incident + broken.ReviewAfter = "not-a-date" + if fresh, note := Freshness(broken, time.Now()); fresh || !strings.Contains(note, "policy_freshness=unknown") { + t.Errorf("an unreadable review-after date must not read as fresh: %q", note) + } +} + +// --osrelease replaces the local observation entirely: the local sysctl +// belongs to a different machine and must not be attributed to the target. +func TestOverrideDropsLocalEvidence(t *testing.T) { + probe := Override("darwin", "6.6.159") + if !probe.Override || !probe.OSReleaseOK || probe.GOOS != "darwin" { + t.Fatalf("override probe is wrong: %+v", probe) + } + if value, ok := probe.sysctlValue(); ok { + t.Errorf("an override must not report a local sysctl value (got %q)", value) + } + if _, ok := darwinProbe().sysctlValue(); ok { + t.Error("a non-Linux host has no migration sysctl to report") + } +} + +func TestCollectNonLinuxKeepsProcUnread(t *testing.T) { + probe := darwinProbe() + if probe.OSReleaseOK || probe.MigrateReqOK { + t.Errorf("a non-Linux host must not report /proc readings: %+v", probe) + } +} + +// The P1 the review found: on a non-Linux host the tool must still let an +// explicit --osrelease through, or its main use (assessing a Windows host's +// WSL kernel from macOS) is unimplementable. +func TestOverrideOutranksTheNonLinuxGate(t *testing.T) { + var out bytes.Buffer + if code := RunCLI([]string{"--source", ".", "--osrelease", "6.6.150"}, &out); code == 2 { + t.Fatalf("an explicit override must not be a usage error:\n%s", out.String()) + } + text := out.String() + if strings.Contains(text, "kernel-not-applicable") { + t.Errorf("an override must not short-circuit as not-applicable:\n%s", text) + } + if !strings.Contains(text, "below-reviewed-fix-floor") { + t.Errorf("an override must still be compared:\n%s", text) + } + if !strings.Contains(text, "kernel-scope") { + t.Errorf("an override must state its scope:\n%s", text) + } +} + +// An empty override is an explicit input, not a missing one, and must not be +// confused with the local host's version. +func TestEmptyOverrideIsAnExplicitInput(t *testing.T) { + var out bytes.Buffer + _ = RunCLI([]string{"--source", ".", "--osrelease", ""}, &out) + text := out.String() + if !strings.Contains(text, "assessing only the version given to --osrelease") { + t.Errorf("an empty override must still be treated as an override:\n%s", text) + } + if !strings.Contains(text, "unverified-version") { + t.Errorf("an empty override must yield no comparison:\n%s", text) + } +} + +// A non-Linux host must say what it is NOT covering, rather than reading as a +// clean bill of health for the WSL or Linux VM the operator actually uses. +func TestNonLinuxScopeExcludesWSLAndLinuxVMs(t *testing.T) { + found := findings(darwinProbe(), ".", time.Now()) + joined := dump(found) + if !strings.Contains(joined, "kernel-not-applicable") { + t.Fatalf("expected a not-applicable finding:\n%s", joined) + } + if !strings.Contains(joined, "WSL") { + t.Errorf("the not-applicable finding must scope out WSL/Linux VMs:\n%s", joined) + } + if strings.Contains(joined, "kernel-version-cve") { + t.Errorf("there is no kernel to compare on this host:\n%s", joined) + } +} + +// D1: no input may make this checker emit an ERR, because an ERR is what turns +// `just doctor` into exit 1 and breaks `just self-check` and +// test_doctor_sandbox over a condition only the operator can fix. +func TestNoFindingIsEverAnError(t *testing.T) { + probes := []Probe{ + darwinProbe(), + {GOOS: "windows"}, + { + GOOS: "linux", + }, + linuxProbe("6.6.150", "0", true), + linuxProbe("6.6.150", "1", true), + linuxProbe("6.6.159", "0", false), + linuxProbe("6.6.87.2-microsoft-standard-WSL2", "0", true), + linuxProbe("garbage", "0", true), + Override("darwin", "6.6.150"), + Override("linux", "6.6.159"), + } + for _, probe := range probes { + found := findings(probe, ".", time.Now()) + if len(found) == 0 { + t.Fatalf("probe %+v produced no findings at all", probe) + } + for _, f := range found { + if f.Level != "OK" && f.Level != "WARN" { + t.Errorf("probe %+v produced level %q: %s", probe, f.Level, f.Detail) + } + } + var out bytes.Buffer + if code := Report(&out, found); code > 1 { + t.Errorf("probe %+v made Report return %d; it must never exceed 1", probe, code) + } + } +} + +// The migration sysctl is one of two trigger paths, so its line must never +// claim more than it observed: 0 does not exclude a BPF program. +func TestSysctlCoverageNamesTheBpfPathInEveryState(t *testing.T) { + incident := mustIncident(t) + cases := []struct { + name string + probe Probe + want string + }{ + {"zero", linuxProbe("6.6.159", "0", true), "BPF reuseport migration is invisible"}, + {"one", linuxProbe("6.6.159", "1", true), "BPF reuseport migration is invisible"}, + {"unreadable", linuxProbe("6.6.159", "", false), "unreadable"}, + {"override", Override("linux", "6.6.159"), "not observed"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + joined := dump(sysctlFindings(tc.probe, incident)) + if !strings.Contains(joined, tc.want) { + t.Errorf("sysctl finding must contain %q:\n%s", tc.want, joined) + } + }) + } +} + +// A non-zero migration sysctl is the one state the tool has positive evidence +// for, so it must be raised rather than reported as fine. +func TestNonZeroMigrationSysctlRaisesAWarning(t *testing.T) { + incident := mustIncident(t) + found := sysctlFindings(linuxProbe("6.6.159", "1", true), incident) + if len(found) != 1 || found[0].Level != "WARN" { + t.Fatalf("migration enabled must warn: %+v", found) + } + ok := sysctlFindings(linuxProbe("6.6.159", "0", true), incident) + if len(ok) != 1 || ok[0].Level != "OK" { + t.Fatalf("migration disabled must not warn: %+v", ok) + } +} + +// A stale or missing binary must not be laundered into a finding about the +// kernel. +func TestStaleSourceYieldsOnlyABuildWarning(t *testing.T) { + found := findings(darwinProbe(), t.TempDir(), time.Now()) + if len(found) != 1 || found[0].Name != "kernel-build" { + t.Fatalf("a stale source must stop at the build finding: %+v", found) + } + if found[0].Level != "WARN" { + t.Errorf("a stale source must warn, not error: %+v", found[0]) + } +} + +func TestSourceMatchesTheCheckout(t *testing.T) { + if !SourceMatches(".") { + t.Fatal("the freshly built binary must match its own source tree") + } +} + +func TestRunCLIUsageErrors(t *testing.T) { + var out bytes.Buffer + if code := RunCLI([]string{"--nope"}, &out); code != 2 { + t.Errorf("an unknown flag must be a usage error, got %d", code) + } + out.Reset() + if code := RunCLI([]string{"unexpected"}, &out); code != 2 { + t.Errorf("a positional argument must be a usage error, got %d", code) + } + out.Reset() + if code := RunCLI([]string{"--help"}, &out); code != 0 { + t.Errorf("--help must exit 0, got %d", code) + } +} + +func TestRunCLIJSONCarriesTheNoticeAndScope(t *testing.T) { + var out bytes.Buffer + _ = RunCLI([]string{"--source", ".", "--osrelease", "6.6.150", "--json"}, &out) + var report JSONReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatalf("--json output must decode: %v\n%s", err, out.String()) + } + if !strings.Contains(report.Notice, "NOT a security clearance") { + t.Errorf("the notice must not read as a clearance: %q", report.Notice) + } + if report.Scope != "override-version-only" { + t.Errorf("scope = %q, want override-version-only", report.Scope) + } + if len(report.Assessments) != 1 || report.Assessments[0].Outcome != BelowFloor { + t.Fatalf("assessment is wrong: %+v", report.Assessments) + } + if len(report.Findings) == 0 { + t.Error("the JSON report must carry the findings") + } +} + +func TestRunCLIJSONWithoutAVersionSkipsAssessments(t *testing.T) { + var out bytes.Buffer + _ = RunCLI([]string{"--source", ".", "--json"}, &out) + var report JSONReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatalf("--json output must decode: %v", err) + } + if len(report.Assessments) != 0 { + t.Errorf("nothing to assess means no assessments: %+v", report.Assessments) + } +} + +func TestLinuxRunReportsTheRunningKernel(t *testing.T) { + probe := linuxProbe("6.6.87.2-microsoft-standard-WSL2", "0", true) + joined := dump(findings(probe, ".", time.Now())) + if !strings.Contains(joined, "6.6.87.2-microsoft-standard-WSL2") { + t.Errorf("the running kernel must be reported:\n%s", joined) + } + if !strings.Contains(joined, "containers share it") { + t.Errorf("the host/container relationship must be stated:\n%s", joined) + } +} + +func TestUnreadableRunningKernelIsUnverified(t *testing.T) { + joined := dump(findings(Probe{GOOS: "linux"}, ".", time.Now())) + if !strings.Contains(joined, "running kernel version unavailable") { + t.Errorf("an unreadable kernel must be unverified, not clean:\n%s", joined) + } + if strings.Contains(joined, "kernel-version-cve-2026-98432 - CVE-2026-98432 at-or-above") { + t.Errorf("an unreadable kernel must not be reported as fixed:\n%s", joined) + } +} + +// The coverage caveat is the one line that must survive every path, including +// the non-Linux early return, or the tool reads as an attestation. +func TestCoverageCaveatAlwaysAppears(t *testing.T) { + for _, probe := range []Probe{darwinProbe(), linuxProbe("6.6.150", "0", true), Override("darwin", "6.6.159")} { + if !strings.Contains(dump(findings(probe, ".", time.Now())), "kernel-coverage") { + t.Errorf("probe %+v lost the coverage caveat", probe) + } + } +} + +// A version string is untrusted input (it can arrive through --osrelease), so +// it must not be able to forge a second doctor line. +func TestVersionCannotForgeADoctorLine(t *testing.T) { + probe := Override("darwin", "6.6.150\nOK kernel-fake - everything is fine") + var out bytes.Buffer + _ = Report(&out, findings(probe, ".", time.Now())) + if strings.Contains(out.String(), "\nOK kernel-fake") { + t.Errorf("a newline in a version string forged a doctor line:\n%s", out.String()) + } +} + +func TestLocalProbeDoesNotPanicOffLinux(t *testing.T) { + // Collect must be safe to call on any GOOS; on non-Linux it reads nothing. + if probe := localProbe(); probe.GOOS != "linux" { + t.Errorf("GOOS = %q, want linux", probe.GOOS) + } +} + +func dump(found []Finding) string { + var builder strings.Builder + for _, f := range found { + builder.WriteString(f.String()) + builder.WriteByte('\n') + } + return builder.String() +} From a7d6749a68a2945b3ac602c406ae60c609171741 Mon Sep 17 00:00:00 2001 From: hironow Date: Fri, 30 Oct 2026 18:18:29 +0900 Subject: [PATCH 3/6] fix(kernel-doctor): address the implementation review findings An independent review of 41551b2e found two real defects and one over-claim. This fixes them. Its third finding did not reproduce; that is recorded rather than patched. 1. The JSON output bypassed the source-freshness check. With a stale or absent --source, the findings said only "run just kernel-doctor-build before trusting any finding", while the same report still carried `at-or-above-reviewed-fix-floor` in `assessments`. Both outputs now come from one evaluate() result, so a comparison is withheld for exactly the reason the line findings are withheld. Reverting the change makes the new test fail, so it does pin the behaviour. 2. A test asserted a macOS-only outcome. It required the assessment list to be empty when no version is obtained, which holds here only because there is no /proc; on Linux the collector succeeds and the list is not empty. The test would have failed `just go-test` on the sandbox after passing locally. run() now takes the host OS and the collector, so every host shape is driven from a test: readable -> one assessment, unreadable -> none. That also makes the documented --osrelease contract literally true. The help says the local /proc and /etc/os-release are not read, but Collect ran unconditionally and only its result was discarded. With injection the override performs no local read at all, which a test asserts by counting collector calls. 3. The CISA KEV observation claimed "CISA has no record of exploitation in the wild". Absence from KEV establishes nothing about exploitation, about public exploits, or about CISA's assessment status. The record and the runbook now say only what was observed: not listed at observation time. The review also reported that a large checker output aborts doctor before the WARN downgrade, via a here-string temp file. Not reproduced: with a verified-unwritable TMPDIR, bash 5.3.20 here-strings handle 20 MB without creating a temp file. The same construct already exists in the CA block, so this is an open constraint for doctor.sh as a whole, not something to patch in one place. docs/plan/kernel-cve-evidence.md records the evidence. --- docs/runbook/kernel-doctor.md | 2 +- .../incidents/cve-2026-98432.json | 2 +- tools/kernel-doctor/main.go | 48 ++++-- tools/kernel-doctor/main_test.go | 140 +++++++++++++++--- 4 files changed, 155 insertions(+), 37 deletions(-) diff --git a/docs/runbook/kernel-doctor.md b/docs/runbook/kernel-doctor.md index 94a3c02c..e5486bb6 100644 --- a/docs/runbook/kernel-doctor.md +++ b/docs/runbook/kernel-doctor.md @@ -82,7 +82,7 @@ mainline は 7.3。修正は `inet_sock_destruct()` と `tcp_v4_reqsk_destructor Red Hat の優先度は critical ではなく **high**(Bugzilla は `NEW`、修正版は未確定)。 EPSS は 0.00388(約 0.4%)、CISA KEV 未収載。 -**KEV 未収載は「CISA が実悪用を把握していない」であり、公開 exploit の不在の証明ではない。** +**観測時点で KEV 未収載。これは実悪用の有無・公開 exploit の有無・CISA の評価状況のいずれについても証拠にならない。** 引き金は 2 つ。どちらも到達には条件が要る。 diff --git a/tools/kernel-doctor/incidents/cve-2026-98432.json b/tools/kernel-doctor/incidents/cve-2026-98432.json index 005b57d3..4e84df66 100644 --- a/tools/kernel-doctor/incidents/cve-2026-98432.json +++ b/tools/kernel-doctor/incidents/cve-2026-98432.json @@ -50,7 +50,7 @@ }, { "label": "cisa-kev", - "value": "not listed (CISA has no record of exploitation in the wild; absence is not proof)", + "value": "not listed in CISA KEV at observation time; absence is not evidence about exploitation in the wild, about public exploits, or about CISA's assessment status", "observedAt": "2026-10-29", "source": "https://www.tenable.com/cve/CVE-2026-98432" } diff --git a/tools/kernel-doctor/main.go b/tools/kernel-doctor/main.go index 89d00055..99ac5a3d 100644 --- a/tools/kernel-doctor/main.go +++ b/tools/kernel-doctor/main.go @@ -42,6 +42,18 @@ func main() { os.Exit(RunCLI(os.Args[1:], os.Stdout)) } // RunCLI is exported for the tests; the process entrypoint only wires stdio. func RunCLI(args []string, out io.Writer) int { + return run(args, out, runtime.GOOS, Collect) +} + +// run takes the host OS and the collector as parameters, so a test can drive +// every host shape on any machine — a test that instead relied on the real +// runtime.GOOS asserted a macOS-only outcome and would have failed the gate on +// the Linux the sandbox runs. +// +// Injecting the collector is also what makes the --osrelease contract +// checkable: a supplied version must perform NO local read, not merely discard +// the result afterwards. +func run(args []string, out io.Writer, goos string, collect func(string) Probe) int { flags := flag.NewFlagSet("kernel-doctor", flag.ContinueOnError) flags.SetOutput(io.Discard) asJSON := flags.Bool("json", false, "JSON output") @@ -67,14 +79,16 @@ func RunCLI(args []string, out io.Writer) int { } }) - probe := Collect(runtime.GOOS) + var probe Probe if override { - probe = Override(runtime.GOOS, *osrelease) + probe = Override(goos, *osrelease) + } else { + probe = collect(goos) } - found := findings(probe, *source, time.Now()) + result := evaluate(probe, *source, time.Now()) if *asJSON { - report := JSONReport{Notice: notice, Scope: scopeText(probe), Assessments: assessments(probe), Findings: found} + report := JSONReport{Notice: notice, Scope: scopeText(probe), Assessments: result.assessments, Findings: result.findings} data, err := json.Marshal(report) if err != nil { return 1 @@ -82,31 +96,43 @@ func RunCLI(args []string, out io.Writer) int { if _, err := out.Write(append(data, '\n')); err != nil { return 1 } - for _, f := range found { + for _, f := range result.findings { if f.Level != "OK" { return 1 } } return 0 } - return Report(out, found) + return Report(out, result.findings) +} + +// evaluation holds the line findings and the structured assessments together, +// so the two outputs cannot disagree about whether a comparison was allowed. +// +// They used to be computed independently, and the JSON then advertised +// `at-or-above-reviewed-fix-floor` while the findings said only that the binary +// was stale and no finding should be trusted. The structured output has to be +// withheld for exactly the reason the line output is. +type evaluation struct { + findings []Finding + assessments []Assessment } -func findings(probe Probe, source string, now time.Time) []Finding { +func evaluate(probe Probe, source string, now time.Time) evaluation { if !SourceMatches(source) { - return []Finding{{"WARN", "kernel-build", "missing/changed source or stale binary; run just kernel-doctor-build before trusting any finding"}} + return evaluation{findings: []Finding{{"WARN", "kernel-build", "missing/changed source or stale binary; run just kernel-doctor-build before trusting any finding"}}} } if incidentError != nil { // The record lives in this repository, so naming the fault is a // developer aid, not a leak of host data. - return []Finding{{"WARN", "kernel-policy", "invalid embedded incident data (" + incidentError.Error() + "); review the source and rebuild; no comparison performed"}} + return evaluation{findings: []Finding{{"WARN", "kernel-policy", "invalid embedded incident data (" + incidentError.Error() + "); review the source and rebuild; no comparison performed"}}} } out := []Finding{{"OK", "kernel-build", fmt.Sprintf("binary source and incident data match the checkout; %d reviewed record(s)", len(incidents))}} out = append(out, scopeFindings(probe)...) if probe.GOOS != "linux" && !probe.Override { // No Linux kernel to assess. There is nothing to compare, and saying // more would read as a clearance. - return append(out, coverageFinding()) + return evaluation{findings: append(out, coverageFinding())} } for _, incident := range incidents { out = append(out, assessmentFindings(probe, incident)...) @@ -114,7 +140,7 @@ func findings(probe Probe, source string, now time.Time) []Finding { for _, incident := range incidents { out = append(out, freshnessFindings(incident, now)...) } - return append(out, coverageFinding()) + return evaluation{findings: append(out, coverageFinding()), assessments: assessments(probe)} } func scopeFindings(probe Probe) []Finding { diff --git a/tools/kernel-doctor/main_test.go b/tools/kernel-doctor/main_test.go index 5c42587f..695b1ee0 100644 --- a/tools/kernel-doctor/main_test.go +++ b/tools/kernel-doctor/main_test.go @@ -16,6 +16,23 @@ func linuxProbe(version, migrateReq string, migrateOK bool) Probe { func darwinProbe() Probe { return Probe{GOOS: "darwin"} } +// findingsOf runs the real evaluation and returns only its line findings. +func findingsOf(probe Probe, source string, now time.Time) []Finding { + return evaluate(probe, source, now).findings +} + +// fakeCollector stands in for the host so a test can drive a Linux shape on +// macOS, and can count the reads a run actually performs. +type fakeCollector struct { + probe Probe + calls int +} + +func (f *fakeCollector) collect(string) Probe { + f.calls++ + return f.probe +} + // The freshness boundary the review asked to pin: still fresh on the // review-after date, overdue from 00:00 the next day. func TestFreshnessBoundary(t *testing.T) { @@ -66,7 +83,7 @@ func TestCollectNonLinuxKeepsProcUnread(t *testing.T) { } } -// The P1 the review found: on a non-Linux host the tool must still let an +// The P1 the plan review found: on a non-Linux host the tool must still let an // explicit --osrelease through, or its main use (assessing a Windows host's // WSL kernel from macOS) is unimplementable. func TestOverrideOutranksTheNonLinuxGate(t *testing.T) { @@ -86,11 +103,33 @@ func TestOverrideOutranksTheNonLinuxGate(t *testing.T) { } } +// The documented contract is that the local /proc and /etc/os-release are NOT +// READ, not merely that their values are discarded. Reading and discarding +// would still touch the target host's files on a Linux run. +func TestOverridePerformsNoLocalCollection(t *testing.T) { + fake := &fakeCollector{probe: linuxProbe("6.6.150", "0", true)} + var out bytes.Buffer + if code := run([]string{"--source", ".", "--json", "--osrelease", "6.6.159"}, &out, "linux", fake.collect); code == 2 { + t.Fatalf("an override must not be a usage error:\n%s", out.String()) + } + if fake.calls != 0 { + t.Errorf("--osrelease must not consult the local host; collector ran %d time(s)", fake.calls) + } + var report JSONReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatalf("--json output must decode: %v", err) + } + if len(report.Assessments) != 1 || report.Assessments[0].Outcome != AtOrAboveFloor { + t.Errorf("the supplied version must still be assessed: %+v", report.Assessments) + } +} + // An empty override is an explicit input, not a missing one, and must not be // confused with the local host's version. func TestEmptyOverrideIsAnExplicitInput(t *testing.T) { + fake := &fakeCollector{probe: linuxProbe("6.6.159", "0", true)} var out bytes.Buffer - _ = RunCLI([]string{"--source", ".", "--osrelease", ""}, &out) + _ = run([]string{"--source", ".", "--osrelease", ""}, &out, "linux", fake.collect) text := out.String() if !strings.Contains(text, "assessing only the version given to --osrelease") { t.Errorf("an empty override must still be treated as an override:\n%s", text) @@ -98,12 +137,79 @@ func TestEmptyOverrideIsAnExplicitInput(t *testing.T) { if !strings.Contains(text, "unverified-version") { t.Errorf("an empty override must yield no comparison:\n%s", text) } + if fake.calls != 0 { + t.Errorf("an empty override must not fall back to the local host (calls=%d)", fake.calls) + } +} + +// A host shape must be driven by the injected collector, not by the machine +// running the test: this is the assertion that used to encode a macOS-only +// outcome and would have failed the gate on Linux. +func TestAReadableHostIsAssessed(t *testing.T) { + fake := &fakeCollector{probe: linuxProbe("6.6.150", "0", true)} + var out bytes.Buffer + _ = run([]string{"--source", ".", "--json"}, &out, "linux", fake.collect) + if fake.calls != 1 { + t.Errorf("a local run must consult the host exactly once, got %d", fake.calls) + } + var report JSONReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatalf("--json output must decode: %v", err) + } + if len(report.Assessments) != 1 || report.Assessments[0].Outcome != BelowFloor { + t.Fatalf("a readable kernel must produce exactly one assessment: %+v", report.Assessments) + } +} + +// The complement, on the same entry point: nothing read means nothing claimed. +func TestUnreadableHostYieldsNoAssessments(t *testing.T) { + fake := &fakeCollector{probe: Probe{GOOS: "linux"}} + var out bytes.Buffer + _ = run([]string{"--source", ".", "--json"}, &out, "linux", fake.collect) + var report JSONReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatalf("--json output must decode: %v", err) + } + if len(report.Assessments) != 0 { + t.Errorf("an unreadable kernel must yield no assessment: %+v", report.Assessments) + } + if !strings.Contains(dump(report.Findings), "running kernel version unavailable") { + t.Errorf("an unreadable kernel must be reported as unverified:\n%s", dump(report.Findings)) + } +} + +// A stale binary must withhold the structured comparison for the same reason +// it withholds the line findings: the JSON used to carry +// `at-or-above-reviewed-fix-floor` while the findings said not to trust any of +// them. +func TestAssessmentsAreWithheldWhenTheSourceIsUnverified(t *testing.T) { + fake := &fakeCollector{probe: linuxProbe("6.6.159", "0", true)} + var jsonOut bytes.Buffer + if code := run([]string{"--source", t.TempDir(), "--json"}, &jsonOut, "linux", fake.collect); code != 1 { + t.Errorf("a stale source must still be a warning, got exit %d", code) + } + var report JSONReport + if err := json.Unmarshal(jsonOut.Bytes(), &report); err != nil { + t.Fatalf("--json output must decode: %v", err) + } + if len(report.Assessments) != 0 { + t.Errorf("a stale binary must not publish a comparison in --json: %+v", report.Assessments) + } + if len(report.Findings) != 1 || report.Findings[0].Name != "kernel-build" { + t.Fatalf("a stale source must stop at the build finding: %+v", report.Findings) + } + + var lineOut bytes.Buffer + _ = run([]string{"--source", t.TempDir()}, &lineOut, "linux", fake.collect) + if strings.Contains(lineOut.String(), "kernel-version-cve") { + t.Errorf("the line output must be withheld too:\n%s", lineOut.String()) + } } // A non-Linux host must say what it is NOT covering, rather than reading as a // clean bill of health for the WSL or Linux VM the operator actually uses. func TestNonLinuxScopeExcludesWSLAndLinuxVMs(t *testing.T) { - found := findings(darwinProbe(), ".", time.Now()) + found := findingsOf(darwinProbe(), ".", time.Now()) joined := dump(found) if !strings.Contains(joined, "kernel-not-applicable") { t.Fatalf("expected a not-applicable finding:\n%s", joined) @@ -123,9 +229,7 @@ func TestNoFindingIsEverAnError(t *testing.T) { probes := []Probe{ darwinProbe(), {GOOS: "windows"}, - { - GOOS: "linux", - }, + {GOOS: "linux"}, linuxProbe("6.6.150", "0", true), linuxProbe("6.6.150", "1", true), linuxProbe("6.6.159", "0", false), @@ -135,7 +239,7 @@ func TestNoFindingIsEverAnError(t *testing.T) { Override("linux", "6.6.159"), } for _, probe := range probes { - found := findings(probe, ".", time.Now()) + found := findingsOf(probe, ".", time.Now()) if len(found) == 0 { t.Fatalf("probe %+v produced no findings at all", probe) } @@ -192,7 +296,7 @@ func TestNonZeroMigrationSysctlRaisesAWarning(t *testing.T) { // A stale or missing binary must not be laundered into a finding about the // kernel. func TestStaleSourceYieldsOnlyABuildWarning(t *testing.T) { - found := findings(darwinProbe(), t.TempDir(), time.Now()) + found := findingsOf(darwinProbe(), t.TempDir(), time.Now()) if len(found) != 1 || found[0].Name != "kernel-build" { t.Fatalf("a stale source must stop at the build finding: %+v", found) } @@ -243,21 +347,9 @@ func TestRunCLIJSONCarriesTheNoticeAndScope(t *testing.T) { } } -func TestRunCLIJSONWithoutAVersionSkipsAssessments(t *testing.T) { - var out bytes.Buffer - _ = RunCLI([]string{"--source", ".", "--json"}, &out) - var report JSONReport - if err := json.Unmarshal(out.Bytes(), &report); err != nil { - t.Fatalf("--json output must decode: %v", err) - } - if len(report.Assessments) != 0 { - t.Errorf("nothing to assess means no assessments: %+v", report.Assessments) - } -} - func TestLinuxRunReportsTheRunningKernel(t *testing.T) { probe := linuxProbe("6.6.87.2-microsoft-standard-WSL2", "0", true) - joined := dump(findings(probe, ".", time.Now())) + joined := dump(findingsOf(probe, ".", time.Now())) if !strings.Contains(joined, "6.6.87.2-microsoft-standard-WSL2") { t.Errorf("the running kernel must be reported:\n%s", joined) } @@ -267,7 +359,7 @@ func TestLinuxRunReportsTheRunningKernel(t *testing.T) { } func TestUnreadableRunningKernelIsUnverified(t *testing.T) { - joined := dump(findings(Probe{GOOS: "linux"}, ".", time.Now())) + joined := dump(findingsOf(Probe{GOOS: "linux"}, ".", time.Now())) if !strings.Contains(joined, "running kernel version unavailable") { t.Errorf("an unreadable kernel must be unverified, not clean:\n%s", joined) } @@ -280,7 +372,7 @@ func TestUnreadableRunningKernelIsUnverified(t *testing.T) { // the non-Linux early return, or the tool reads as an attestation. func TestCoverageCaveatAlwaysAppears(t *testing.T) { for _, probe := range []Probe{darwinProbe(), linuxProbe("6.6.150", "0", true), Override("darwin", "6.6.159")} { - if !strings.Contains(dump(findings(probe, ".", time.Now())), "kernel-coverage") { + if !strings.Contains(dump(findingsOf(probe, ".", time.Now())), "kernel-coverage") { t.Errorf("probe %+v lost the coverage caveat", probe) } } @@ -291,7 +383,7 @@ func TestCoverageCaveatAlwaysAppears(t *testing.T) { func TestVersionCannotForgeADoctorLine(t *testing.T) { probe := Override("darwin", "6.6.150\nOK kernel-fake - everything is fine") var out bytes.Buffer - _ = Report(&out, findings(probe, ".", time.Now())) + _ = Report(&out, findingsOf(probe, ".", time.Now())) if strings.Contains(out.String(), "\nOK kernel-fake") { t.Errorf("a newline in a version string forged a doctor line:\n%s", out.String()) } From 1892024ce94dde2cc93a7b77e1ea33fae23b360e Mon Sep 17 00:00:00 2001 From: hironow Date: Fri, 30 Oct 2026 18:19:44 +0900 Subject: [PATCH 4/6] docs(plan): record the implementation review and the verification SHA The verification record predated the Tidy First commit and still described the quarantine gate as blocking; 973f7d4 resolved it, so the section now states the SHA it was measured against and what actually remains red. Adds the commit-time review record: the re-measurement of the eight plan-review findings, the three new findings with what confirmed each, the one finding I could not reproduce and why, and the falsifications the review attempted and failed to land. --- docs/plan/kernel-cve-evidence.md | 42 ++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/docs/plan/kernel-cve-evidence.md b/docs/plan/kernel-cve-evidence.md index 0eb53ab3..e49efe2b 100644 --- a/docs/plan/kernel-cve-evidence.md +++ b/docs/plan/kernel-cve-evidence.md @@ -165,6 +165,8 @@ D1(WARN 止まり)・D2(ベンダー版を断定しない)・D3(`unver ## 検証記録(実測) +**検証した SHA:** base `556eedbc`(main)+ 本ブランチの 2 commit。以下の「既存の失敗」は**この base の状態**を指す。 + 環境: macOS `Darwin 27.0.0 arm64`、Go 1.27.3、golangci-lint 2.15.0。**Docker はこのホストで実行できない**(`just test` の recipe 自身が `⚠️ docker or @devcontainers/cli not found` と出す)。 ### 通った検証 @@ -185,7 +187,7 @@ D1(WARN 止まり)・D2(ベンダー版を断定しない)・D3(`unver ### このホストで完走できなかった検証と、その切り分け -- **`just ci` は完走していない。** `check_uv_exclude_newer` で中断する。`emulator/pyproject.toml` と `telemetry/examples/pyproject.toml` の `exclude-newer-package` の絶対 cutoff(`2026-10-20` / `2026-10-21`)が 7 日の quarantine 窓を過ぎたため(実行日 2026-10-30)。両ファイルは `HEAD` とバイト一致で、本作業は触っていない。**時間経過で自然に赤くなる既存の失敗。** +- **`just ci` は完走していない。** `check_uv_exclude_newer` で中断する。`emulator/pyproject.toml` と `telemetry/examples/pyproject.toml` の `exclude-newer-package` の絶対 cutoff(`2026-10-20` / `2026-10-21`)が 7 日の quarantine 窓を過ぎたため(実行日 2026-10-30)。両ファイルは base とバイト一致で、本作業は触っていない。**時間経過で自然に赤くなる既存の失敗。** これは `pre-commit` の `just lint` hook も落とすので、どの commit も通せなかった。**本ブランチの `973f7d4`(Tidy First)で解消済み**(期限切れ 5 エントリを削除 + `uv lock`。lock の差分は 7 削除・**version 変更 0**)。 - 中断が `go-test` より手前だったので、**未実行の leg を個別に全部走らせた: 12 PASS / 1 FAIL。** 唯一の FAIL は `just test-unit`。 - **`test-unit` の唯一の失敗** `tests/unit/test_wsl_clipboard.py::test_root_installs_and_repeat_is_noop` は、**pristine な `HEAD` の worktree で同一に失敗することを実測**した(`git worktree add /tmp/dt-head HEAD` → 同じ AssertionError)。このテストの入力は `install.sh` と `tests/unit/_bash_hook.py` だけで、どちらも `HEAD` とバイト一致。**既存のホスト固有の失敗。**(bash の版を疑ったが、`resolve_bash` はどちらでも Homebrew bash 5.3 を返し、仮説は反証された。) - **`just test`(full sandbox)は実行できない。** 111 件すべてが `PermissionError: [Errno 13] Permission denied: 'docker'` で、サンドボックスの fixture が docker を probe する段で落ちる(15 passed / 111 errors)。**pristine な `HEAD` の worktree で同じ失敗を実測**(52 errors / 4 passed)。`scripts/doctor.sh` はサンドボックスの中でしか使われないので、外側の docker probe の成否に影響しない。**既存の環境要因。** @@ -195,4 +197,40 @@ D1(WARN 止まり)・D2(ベンダー版を断定しない)・D3(`unver - **`test_doctor_sandbox`(`tests/test_just_sandbox.py:657`)は実行できていない。** Docker が使えないため。D1 の契約は代わりに 2 つで押さえた: (a) `tests/unit/test_doctor_kernel.py` が**実物の `doctor.sh` のブロックを切り出し**、偽バイナリに `ERR` を出させても doctor の `err` が 0 のまま・exit 0 のままであることを assert する(このテストは PASS)、(b) このホストでの実 `just doctor` 実行で kernel ブロックが `err` を変えないことを実測。**ただしサンドボックス内での実行そのものは未検証なので、CI で初めて走る。** - **Linux 実機での `/proc` 読み取り経路は未検証。** このホストに Docker も Linux VM も無いため。経路自体は `Probe` を注入する単体テストで覆っている(`TestLinuxRunReportsTheRunningKernel` / `TestUnreadableRunningKernelIsUnverified`)が、実カーネルでの観測ではない。 - **クロスビルド成功は Windows / WSL 実機での観測成功ではない。** 実機のカーネル版は本作業では確認していない(macOS から Windows ホストへ到達できない)。runbook の operator 手順がその口である。 -- `just ci` 全体の exit 0 は、上記の既存 2 件(uv quarantine と wsl-clipboard テスト)が直るまで得られない。**本作業の範囲外。** +- `just ci` 全体の exit 0 はまだ得られない。残るのは (a) `test_wsl_clipboard.py::test_root_installs_and_repeat_is_noop`(既存・ホスト固有)と (b) Docker が実行できないための `just test` のサンドボックス leg。**どちらも本作業の範囲外。** + +## 実装レビュー記録(commit 後) + +**レビュー:** Codex CLI 0.164.0(`codex exec --skip-git-repo-check`)。対象は `556eedbc..41551b2e`、worktree 上で読み取りのみ。2026-10-30。結論 **REVISE**。 + +### 前回(計画段階)8 指摘の再測定 + +| # | 分類 | 根拠 | +| --- | --- | --- | +| 1 | partial → **本 commit で解消** | `--osrelease` でも比較はできていたが、`Collect` が override 判定の前に走っていた(Linux では `/proc` を実際に読んでいた)。help の「読まない」という記述を文字どおりにするため、注入した collector で override 時はゼロ読み取りにした | +| 2 | partial | 境界と CA テストの切り出しは意図どおり。巨大出力時の終了契約は指摘 3(下記) | +| 3 | **resolved** | 全体一致で比較を制限。`+` / `.2` / `garbage` / `-rc1` は実行でもすべて unverified | +| 4 | **resolved** | `.gitattributes` + `test_gitattributes_eol.py` が埋め込み対象から検査対象を導出 | +| 5 | partial → **本 commit で解消** | 鮮度の境界は正しいが、KEV の文言が CISA の把握状況を断定していた(下記) | +| 6 | **resolved** | sysctl の namespace / BPF の限界を明記 | +| 7 | **resolved** | 再起動の単位と `wsl --shutdown` の runner 影響を明記 | +| 8 | partial | module は gate に入っている。Linux 固有のテスト不整合が指摘 1(下記) | + +### 新規指摘への対応 + +| # | 指摘 | 自分の検証 | 対応 | +| --- | --- | --- | --- | +| 1 | **P1** `main_test.go` が Linux の正常な収集結果を失敗扱いする | コードから確定(`assessments` は版が取れたときだけ非空。macOS では `/proc` が無いので 0、Linux では 1)。実ホストで `--osrelease` あり→ 1 件 / なし→ 0 件を実測 | **採用。** `run(args, out, goos, collect)` にして collector を注入し、「読める host は 1 件」「読めない host は 0 件」を**ホスト非依存のテスト**にした。指摘 1 の副次点(override でも `/proc` を読んでいた)も同時に解消 | +| 2 | **P2** JSON 出力だけが source 鮮度検査を迂回する | **再現。** `--json --osrelease=6.6.159 --source=/nonexistent` で findings は `kernel-build` のみなのに `assessments` に `at-or-above-reviewed-fix-floor` が入っていた | **採用。** `evaluate()` が findings と assessments を**一緒に**返す構造にし、検証が通らなければ両出力とも比較を出さない。mutation 実験で、修正を戻すとテストが意図どおり落ちることを確認 | +| 3 | **P2** 一時ファイルを作れない環境で、巨大出力が D1 の降格前に doctor を終了させる | **再現できず。** 書き込み不可を確認した `TMPDIR`(`chmod 500`、probe で `Permission denied` を実証)でも、bash 5.3.20 の here-string は **20 MB まで一時ファイルを作らず**通過した。一時ファイルが作られないことは `TMPDIR` を監視しても確認した | **不採用(反論)。** 報告された事象はレビュアーのサンドボックスが一時ファイル作成自体を拒否していたことによる(そのログに `cannot create temp file for here document: Operation not permitted` がある)。加えてこの形は **CA ブロックにも既にある**(`<<<"$_ca_out"`)ので、カーネル側だけ直しても種類としては直らない。**未解決の制約として記録**し、doctor.sh の数え上げを一時ファイル非依存にする作業は別件とする(本ブランチの範囲外) | +| 4 | 記録の KEV 文言が過剰(「CISA が実悪用を把握していない」) | 一次情報が示すのは「観測時点で KEV 未載」だけ | **採用。** JSON と runbook を「実悪用・公開 exploit・CISA の評価状況のいずれの証拠にもならない」に限定 | +| 5 | 計画の quarantine 記述が最終 HEAD に当てはまらない | `973f7d4` で解消済みなのに「中断する」と書いたままだった | **採用。** 本節を検証 SHA つきで更新 | + +### レビューが確認したこと(反証を試みて失敗した点) + +- ベンダー風の 5 入力はすべて `unverified-vendor`。`8.0.0` / `999.0.0` が at-or-above になるのは D2 の明示仕様。 +- 改行・CR・ANSI・Unicode 行区切り・`%`・10 万文字を override に渡しても、行注入も ERR 件数の変化も起きない。 +- 偽 checker の表(空 / 不正行 / rc=139 / rc=2 / ERR / OK+ERR / 非 UTF-8)で `err=0`・exit 0 を維持。CA ブロックが ERR で `err=1`・exit 1 になる非対称も意図どおり。 +- 修正床 7 本・mainline・両 CVSS・EPSS・Red Hat・IPv6 の別 patch は一次情報と一致。Oracle の暫定 8.1 / `AC:H` も live と一致。 +- `973f7d4` の lock 差分は metadata の 7 行削除のみで、package 本文は base と完全一致。 +- 既存赤 2 件の入力は base と無差分で、この変更を原因とする証拠は無い。 From bfd8d4693611274df3b0287519b401ac98afb2a6 Mon Sep 17 00:00:00 2001 From: hironow Date: Fri, 30 Oct 2026 22:30:30 +0900 Subject: [PATCH 5/6] docs(plan): record owner-reported Windows checks --- docs/plan/kernel-cve-evidence.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/plan/kernel-cve-evidence.md b/docs/plan/kernel-cve-evidence.md index e49efe2b..e3d3a9dc 100644 --- a/docs/plan/kernel-cve-evidence.md +++ b/docs/plan/kernel-cve-evidence.md @@ -234,3 +234,12 @@ D1(WARN 止まり)・D2(ベンダー版を断定しない)・D3(`unver - 修正床 7 本・mainline・両 CVSS・EPSS・Red Hat・IPv6 の別 patch は一次情報と一致。Oracle の暫定 8.1 / `AC:H` も live と一致。 - `973f7d4` の lock 差分は metadata の 7 行削除のみで、package 本文は base と完全一致。 - 既存赤 2 件の入力は base と無差分で、この変更を原因とする証拠は無い。 + +## Windows 側の確認と ready 化(2026-10-30) + +所有者から「Windows 側で、更新・再起動・既存処理の停止なしでのチェック検証は OK」と報告を受け、PR #520 を ready にする指示を受けた。 +これは所有者によるチェック検証の完了報告であり、agent による実機観測ではない。 +実行コマンド・生ログ・カーネル版は今回提示されていないため、修正済みカーネルへの更新確認や CVE 解消を意味しない。 +`wsl --update`・`wsl --shutdown`・既存処理の停止は、この確認に際して行っていない。 +機体固有の確認は本節と PR コメントに記録し、公開出典つきの第三者数値を扱う incident の observations には追加しない。 +ready 化後の CI(sandbox を含む)の成否は別途確認する。 From 091d759845eec109da10682051391d136ebd3367 Mon Sep 17 00:00:00 2001 From: hironow Date: Fri, 30 Oct 2026 22:47:56 +0900 Subject: [PATCH 6/6] test(kernel-doctor): exercise CLI host injection across operating systems --- docs/plan/kernel-cve-evidence.md | 10 ++++++ tools/kernel-doctor/main_test.go | 57 ++++++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/docs/plan/kernel-cve-evidence.md b/docs/plan/kernel-cve-evidence.md index e3d3a9dc..16e55c0e 100644 --- a/docs/plan/kernel-cve-evidence.md +++ b/docs/plan/kernel-cve-evidence.md @@ -243,3 +243,13 @@ D1(WARN 止まり)・D2(ベンダー版を断定しない)・D3(`unver `wsl --update`・`wsl --shutdown`・既存処理の停止は、この確認に際して行っていない。 機体固有の確認は本節と PR コメントに記録し、公開出典つきの第三者数値を扱う incident の observations には追加しない。 ready 化後の CI(sandbox を含む)の成否は別途確認する。 + +## ready 後の Linux CI 失敗(2026-10-30) + +head `bfd8d469` の [Just Sandbox Tests](https://github.com/hironow/dotfiles/actions/runs/41063897374) は 123 passed / 3 failed。 +3 件とも `just check` 内の Go lint による早期終了が原因で、`tools/kernel-doctor/main.go:56` の `run` の `goos` 引数に対する `unparam`(常に `linux`)だった。 +これは本 PR の不備であり、既存赤ではない。 +`runtime.GOOS` はビルド対象で定数となり、既存の CLI 注入テストも `linux` だけを渡していたため、macOS で見逃した。 +所有者の承認を受け、override と通常実行のテストを macOS / Windows にも広げ、OS ごとの scope・assessment・collector 呼び出し回数を確認する。 +lint は無効化せず、修正後の Go テストは PASS、macOS / Linux / Windows を対象にした `golangci-lint run` はすべて 0 issues(Linux / Windows は対象 OS 指定での静的検証であり、実機テストではない)。 +CI 全成功まで squash merge は行わない。 diff --git a/tools/kernel-doctor/main_test.go b/tools/kernel-doctor/main_test.go index 695b1ee0..509ca312 100644 --- a/tools/kernel-doctor/main_test.go +++ b/tools/kernel-doctor/main_test.go @@ -107,20 +107,27 @@ func TestOverrideOutranksTheNonLinuxGate(t *testing.T) { // READ, not merely that their values are discarded. Reading and discarding // would still touch the target host's files on a Linux run. func TestOverridePerformsNoLocalCollection(t *testing.T) { - fake := &fakeCollector{probe: linuxProbe("6.6.150", "0", true)} - var out bytes.Buffer - if code := run([]string{"--source", ".", "--json", "--osrelease", "6.6.159"}, &out, "linux", fake.collect); code == 2 { - t.Fatalf("an override must not be a usage error:\n%s", out.String()) - } - if fake.calls != 0 { - t.Errorf("--osrelease must not consult the local host; collector ran %d time(s)", fake.calls) - } - var report JSONReport - if err := json.Unmarshal(out.Bytes(), &report); err != nil { - t.Fatalf("--json output must decode: %v", err) - } - if len(report.Assessments) != 1 || report.Assessments[0].Outcome != AtOrAboveFloor { - t.Errorf("the supplied version must still be assessed: %+v", report.Assessments) + for _, goos := range []string{"linux", "darwin", "windows"} { + t.Run(goos, func(t *testing.T) { + fake := &fakeCollector{probe: Probe{GOOS: goos}} + var out bytes.Buffer + if code := run([]string{"--source", ".", "--json", "--osrelease", "6.6.159"}, &out, goos, fake.collect); code != 1 { + t.Fatalf("an override must report advisory warnings, got exit %d:\n%s", code, out.String()) + } + if fake.calls != 0 { + t.Errorf("--osrelease must not consult the local host; collector ran %d time(s)", fake.calls) + } + var report JSONReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatalf("--json output must decode: %v", err) + } + if report.Scope != "override-version-only" { + t.Errorf("override scope = %q", report.Scope) + } + if len(report.Assessments) != 1 || report.Assessments[0].Outcome != AtOrAboveFloor { + t.Errorf("the supplied version must still be assessed: %+v", report.Assessments) + } + }) } } @@ -161,6 +168,28 @@ func TestAReadableHostIsAssessed(t *testing.T) { } } +// Drive non-Linux hosts through the real CLI, not evaluate alone. Otherwise +// all test calls pass linux, and unparam sees goos as constant on Linux CI. +func TestNonLinuxCLIYieldsNoAssessments(t *testing.T) { + for _, goos := range []string{"darwin", "windows"} { + t.Run(goos, func(t *testing.T) { + fake := &fakeCollector{probe: Probe{GOOS: goos}} + var out bytes.Buffer + _ = run([]string{"--source", ".", "--json"}, &out, goos, fake.collect) + var report JSONReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatalf("--json output must decode: %v", err) + } + if fake.calls != 1 || report.Scope != goos || len(report.Assessments) != 0 { + t.Fatalf("non-Linux scope/collection mismatch: calls=%d report=%+v", fake.calls, report) + } + if !strings.Contains(dump(report.Findings), "kernel-not-applicable") { + t.Errorf("non-Linux CLI must report its limited scope: %+v", report.Findings) + } + }) + } +} + // The complement, on the same entry point: nothing read means nothing claimed. func TestUnreadableHostYieldsNoAssessments(t *testing.T) { fake := &fakeCollector{probe: Probe{GOOS: "linux"}}