diff --git a/.gitignore b/.gitignore index cfad6835..76a99252 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,7 @@ __pycache__/ tools/simple-server/simple-server tools/simple-server/simple-server.exe /output/ca-doctor/ +/output/brotli-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/runbook/brotli-doctor.md b/docs/runbook/brotli-doctor.md new file mode 100644 index 00000000..ce2352c2 --- /dev/null +++ b/docs/runbook/brotli-doctor.md @@ -0,0 +1,136 @@ +# Brotli doctor + +`just brotli-doctor` は、導入済みCLIのバイナリからBrotliに関する証拠を集める。 +CVEの影響判定や「Brotliが含まれない」という保証は行わない。 +証明書の診断は [ca-doctor](ca-doctor.md) を使う。 + +## 実行方法 + +ビルドには導入済みのGo 1.27以降が必要。 +診断中にコンパイラやパッケージをインストールしない。 +コードを更新したら再ビルドする。 + +```bash +just brotli-doctor-build +just brotli-doctor +just brotli-doctor --json +``` + +既定の走査範囲は次のとおり。 + +- `mise ls --installed --json` が返す全導入版の配置先を再帰走査する。非アクティブ版も含む。 +- PATHの各ディレクトリは直下だけを調べる。symlinkで別ディレクトリへ再帰しない。 +- Unixでは実行ビットのある通常ファイル、Windowsでは `.exe` または `.com` の通常ファイルを候補にする。 +- 候補のうちELF、Mach-O、PEを解析する。スクリプトやラッパーは実行せず、非ネイティブとして数える。 + +mise配下から外へ出るsymlinkは除外して未完了を報告する。 +PATH直下のファイルsymlinkは実体を調べ、同じ実体を重複解析しない。 +ホーム全体、その祖先、ファイルシステムのルートをmiseの再帰走査先として受け付けない。 +空または相対パスのPATH要素も除外して報告する。 +mise自体はPATHから起動するため、PATHとmiseは信頼できるものを使う。 + +対象を狭める場合は次のように実行する。 +ファイル指定時はmiseもPATHも走査しない。 + +```bash +just brotli-doctor --mise=false +just brotli-doctor --path=false +just brotli-doctor '/path/with spaces/tool' +just brotli-doctor --json ./output/brotli-doctor/brotli-doctor +``` + +`--max-bytes` は1ファイルの解析上限で、既定値と最大値は512MiB。 +上限超過、読み取り失敗、解析失敗、走査打ち切りは未完了になる。 +走査項目は最大200,000件、miseの出力は最大8MiB。 +`--timeout=2m` は走査と文字列読み取りの協調的な期限であり、ファイルI/Oを強制中断する保証ではない。 +変更され続けるディレクトリの一貫したスナップショットや、敵対的な同時ファイル差し替えに対する隔離は提供しない。 + +## 出力の読み方 + +| 表示 | 意味 | +| --- | --- | +| `dynamic-import` | 直接のimportにBrotli名のライブラリがある。実際に解決されるパスと導入版を確認する | +| `c-defined-symbol` | 定義済みC API名がある。Cコード内包の手掛かりだが、静的リンクや実際の呼び出しの確定ではない | +| `c-undefined-symbol` | 未定義C API名がある。供給元は別途確認する | +| `go-build` | Goのビルド版。これ単独ではBrotliの証拠にならない | +| `go-module` | Brotli名のGoモジュールの記録。置換先があれば併記する | +| `c-api-string` / `rust-path-string` | 文字列の手掛かり。リンク方式、Rustクレート版、使用実態は確定できない | +| `UNKNOWN` | 当該アーキテクチャでBrotliの証拠を取得できなかった。未使用という意味ではない | +| `HINT` | 文字列の手掛かりだけがある | +| `EVIDENCE` | import、シンボル、モジュールの証拠がある。脆弱という意味ではない | +| `INCOMPLETE` | 走査または解析の一部が完了していない。JSONでは `incomplete: true` | + +Pure Goに対応するモジュール名やPure Rustらしい文字列があっても、同じバイナリにC実装が共存しうる。 +動的リンクが見つかっても、別途静的リンクされたコピーの不在は保証できない。 +fat Mach-Oは各スライスを別々に扱い、対応できない形式や壊れたスライスは未完了として報告する。 + +終了コードは0が解析完了、1が未完了、2が引数エラー。 +**0を脆弱性検査の合格条件として使わない。** +非ネイティブなラッパーしか見つからない場合も、正常に走査できれば0になる。 + +## 影響調査と更新 + +直接importは、そのライブラリがさらに読み込むライブラリまで追跡しない。 +`dlopen`、PEのdelay-load、stripや最適化で消えた情報、スクリプトの実行先、コンテナ内の別環境も自動追跡しない。 +Goのビルド情報が読めない場合やRustのクレート版が不明な場合は、ビルド記録、lockfile、SBOMを照合する。 + +CVEの対象実装と修正版を一次情報で確認してから更新する。 +動的ライブラリを更新した場合、既に起動しているプロセスは再起動が必要になる場合がある。 +影響を受けるコードを静的に含むバイナリは、依存更新後の再ビルドと置き換えが必要。 +Pure実装もアルゴリズム由来の問題や独自の脆弱性を持ちうるため、C実装とは分けて調べる。 +このコマンドは更新、再ビルド、再起動、ネットワーク経由のCVE照会を行わない。 + +## Bunの内包BrotliとCVE-2026-111497 + +2026-10-24に確認した公式情報では、Bun 1.4.4はこの修正を取り込んだリリースである。 +根拠は次のとおり。 + +- [提案PR #46351](https://github.com/oven-sh/bun/pull/46351) は、C版Brotliへの修正に加えてRustのFFI enumに `ERROR_FORMAT_BLOCK_SWITCH = -17` を追加し、Rust側のエラー変換も変更する。このPR自体は確認時点で未マージ。 +- 実際の取り込みは [マージ済みPR #46392](https://github.com/oven-sh/bun/pull/46392)(`2a5cbbcb084770dec7d7e2f21cdfd4f7f49b1ee3`)によるBrotli 1.2.1への更新。このコミットをbase、Bun 1.4.4の解決済みコミット `38afee6c84fcf48c0edb2466aa4b27d287a59c30` をheadとする [比較](https://github.com/oven-sh/bun/compare/2a5cbbcb084770dec7d7e2f21cdfd4f7f49b1ee3...38afee6c84fcf48c0edb2466aa4b27d287a59c30) は `behind_by: 0` で、取り込みを確認できる。 +- [Bun 1.4.4のビルド定義](https://github.com/oven-sh/bun/blob/38afee6c84fcf48c0edb2466aa4b27d287a59c30/scripts/build/deps/brotli.ts) はBrotli 1.2.1の `db24a98aed38a16de78cc05ca15cdd8dc2a0d92d` を指定し、Cソースをアーカイブへビルドする。[Rust側のFFI定義](https://github.com/oven-sh/bun/blob/38afee6c84fcf48c0edb2466aa4b27d287a59c30/src/brotli_sys/brotli_c.rs) にも新しいエラー値がある。 +- [Brotli 1.2.1の公式リリース](https://github.com/google/brotli/releases/tag/v1.2.1) は、このCVEの境界外書き込みをセキュリティ修正として記載する。[1.2.0からの差分](https://github.com/google/brotli/compare/v1.2.0...v1.2.1) に修正コミット `b418fa81c48262a86a95332b0bc6d62c30aa78b6` が含まれる。 + +したがって「Rustには関係ない」という説明は不正確。 +脆弱性の修正対象は内包されたCデコーダだが、Rust側も新しいFFIエラー値に対応する必要がある。 + +`bun build --compile` の生成物はランタイムを内包するため、ビルドホストのBun更新だけでは既存の生成物は変わらない。 +修正を取り込んだランタイムで生成物を再ビルドし、配布先のバイナリを置き換える。 +コンテナは、確認した修正版のタグとdigestをDockerfileへ反映して再ビルドし、通常のデプロイ手順で置き換える。 +`latest` というタグや、ホストのBunの版だけを修正取り込みの証拠にしない。 + +診断は `Bun v...` または `bun-v...` の版文字列を候補として検出する。 +出力の `runtime_reviews` は候補ごとに1.4.4との比較を示すが、`binary_identity` と `patch_status` は常に `unverified` のまま。 +`below-reviewed-release-threshold` は候補が修正取り込み版より古いこと、`at-or-above-reviewed-release-threshold` は候補の数値がその版以上であることだけを示す。 +実際のバイナリ内容や将来版の修正保持を保証しない。 +バックポートされた旧版も、文字列だけで影響ありとは断定しない。 + +プレリリース、不正な版、256文字を超える版、読み取り中断中の版は `unverified-version` になる。 +有効なbuild metadataは数値比較から除外し、候補文字列には残す。 +複数の候補を見つけても最大版だけを選ばず、最大16個までそれぞれ表示する。 +候補数の上限超過は未完了として報告する。 +文字列がなければBun不在と判断せず、出所とビルド記録を照合する。 + +信頼できる実行環境では、担当者が次のコマンドで版を確認できる。 +これらは `brotli-doctor` が自動実行するコマンドではない。 + +```bash +bun --version +# 実環境の名前に置き換えて実行する +# podman exec bun --version +# kubectl exec -c -- bun --version +``` + +実際の攻撃到達性は、`fetch()` のBrotliレスポンス展開、`node:zlib`、`DecompressionStream("brotli")` などの利用状況で別途確認する。 +受信リクエストの `Content-Encoding: br` をプロキシで遮断しても、外部レスポンスの展開は防げない。 +この診断だけから、サービスが脆弱な処理へ到達可能かを断定しない。 + +## 他OSへの配置 + +```bash +just brotli-doctor-build-all +``` + +Linux、macOS、Windowsの各amd64とarm64向けバイナリを `output/brotli-doctor/` に生成する。 +対象OS用のファイルを `brotli-doctor`(Windowsは `brotli-doctor.exe`)という名前で同ディレクトリに置く。 +WindowsのrecipeはGit Bashを経由するが、診断自体はネイティブのGoバイナリが行う。 +既存の `just doctor` からは自動起動しない。 diff --git a/justfile b/justfile index b0cec455..c81ca7c3 100644 --- a/justfile +++ b/justfile @@ -1445,6 +1445,20 @@ ca-doctor *args: ca-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/ca-doctor && CGO_ENABLED=0 GOTOOLCHAIN=local GOOS="$os" GOARCH="$arch" mise exec -- go build -trimpath -o "../../output/ca-doctor/ca-doctor-$os-$arch$suffix" .); done' +# Opt-in binary evidence inventory; never called implicitly by doctor. +[group('Setup')] +brotli-doctor-build: + @cd tools/brotli-doctor && CGO_ENABLED=0 GOTOOLCHAIN=local mise exec -- go build -trimpath -o ../../output/brotli-doctor/ . + +[group('Test')] +[positional-arguments] +brotli-doctor *args: + @bash -c 'bin=output/brotli-doctor/brotli-doctor; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) bin="$bin.exe" ;; esac; if [ ! -x "$bin" ]; then echo "WARN brotli-build - run just brotli-doctor-build first"; exit 1; fi; exec "$bin" "$@"' -- "$@" + +[group('Setup')] +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' + # 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/tools/brotli-doctor/bounds_test.go b/tools/brotli-doctor/bounds_test.go new file mode 100644 index 00000000..af6afa58 --- /dev/null +++ b/tools/brotli-doctor/bounds_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "debug/pe" + "encoding/binary" + "io" + "os" + "path/filepath" + "testing" +) + +type countedSection struct { + prefix []byte + read int +} + +func (r *countedSection) ReadAt(p []byte, off int64) (int, error) { + r.read += len(p) + clear(p) + if off < int64(len(r.prefix)) { + copy(p, r.prefix[off:]) + } + return len(p), nil +} + +func TestPEImportsReadBoundedRangesInsteadOfWholeSections(t *testing.T) { + prefix := make([]byte, 100) + binary.LittleEndian.PutUint32(prefix[12:16], 1060) + copy(prefix[60:], "libbrotli.dll\x00") + reader := &countedSection{prefix: prefix} + header := &pe.OptionalHeader64{NumberOfRvaAndSizes: 2} + header.DataDirectory[pe.IMAGE_DIRECTORY_ENTRY_IMPORT] = pe.DataDirectory{VirtualAddress: 1000, Size: 40} + f := &pe.File{OptionalHeader: header, Sections: []*pe.Section{{SectionHeader: pe.SectionHeader{VirtualAddress: 1000, Size: 1 << 20}, ReaderAt: reader}}} + libraries, err := peLibraries(context.Background(), f) + if err != nil || len(libraries) != 1 || libraries[0] != "libbrotli.dll" || reader.read > 8192 { + t.Fatalf("libraries=%v bytes=%d err=%v", libraries, reader.read, err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + reader.read = 0 + if _, err := peLibraries(ctx, f); err == nil || reader.read != 0 { + t.Fatalf("cancellation ignored: bytes=%d err=%v", reader.read, err) + } +} + +func TestInventoryStopsBeforeOpeningMoreDirectoriesAfterLimit(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"a.exe", "b.exe", "c.exe"} { + putFile(t, filepath.Join(root, name), "hello") + } + c := collector{seen: map[string]bool{}, ctx: context.Background(), limit: 2} + if err := c.readPATH(root); err == nil || len(c.result.Paths) != 2 { + t.Fatalf("limit not enforced: %+v", c.result) + } + // A missing directory must not even be opened after exhaustion. + if err := c.readPATH(filepath.Join(root, "missing")); err == nil || os.IsNotExist(err) { + t.Fatalf("enumerated after limit: %v", err) + } +} + +// Keep io.ReaderAt conformance checked separately from PE implementation details. +var _ io.ReaderAt = (*countedSection)(nil) diff --git a/tools/brotli-doctor/bun.go b/tools/brotli-doctor/bun.go new file mode 100644 index 00000000..17218b6e --- /dev/null +++ b/tools/brotli-doctor/bun.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "errors" + "regexp" + "strconv" +) + +const bunFixSource = "https://github.com/oven-sh/bun/commit/2a5cbbcb084770dec7d7e2f21cdfd4f7f49b1ee3" + +// A comparison concerns a string candidate, never an attested binary version. +type RuntimeReview struct { + CandidateVersion string `json:"candidate_version"` + Comparison string `json:"comparison"` + BinaryIdentity string `json:"binary_identity"` + PatchStatus string `json:"patch_status"` + CVE string `json:"cve"` + ReviewedRelease string `json:"reviewed_release"` + ReviewedAt string `json:"reviewed_at"` + Source string `json:"source"` +} + +var stableBunVersion = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) + +func reviewBun(candidate string) RuntimeReview { + review := RuntimeReview{CandidateVersion: candidate, Comparison: "unverified-version", BinaryIdentity: "unverified", PatchStatus: "unverified", CVE: "CVE-2026-111497", ReviewedRelease: "1.4.4", ReviewedAt: "2026-10-24", Source: bunFixSource} + match := stableBunVersion.FindStringSubmatch(candidate) + if match == nil { + return review + } + var version [3]uint64 + for i := range version { + n, err := strconv.ParseUint(match[i+1], 10, 64) + if err != nil { + return review + } + version[i] = n + } + review.Comparison = "at-or-above-reviewed-release-threshold" + floor := [3]uint64{1, 4, 4} + for i := range version { + if version[i] < floor[i] { + review.Comparison = "below-reviewed-release-threshold" + break + } + if version[i] > floor[i] { + break + } + } + return review +} + +func bunReviews(evidence []Evidence) []RuntimeReview { + var reviews []RuntimeReview + for _, e := range evidence { + if e.Kind == "bun-version-string" { + reviews = append(reviews, reviewBun(e.Detail)) + } + } + return reviews +} + +type bunScanner struct { + tail []byte + token []byte + reading bool + overlong bool + versions []string + seen map[string]bool + err error +} + +func newBunScanner() *bunScanner { return &bunScanner{seen: map[string]bool{}} } + +func versionByte(b byte) bool { + return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b == '.' || b == '+' || b == '-' || b == '_' +} + +func (s *bunScanner) feed(chunk []byte) { + s.tail = append(s.tail, chunk...) + data := s.tail + s.tail = nil + for len(data) > 0 && s.err == nil { + if s.reading { + n := 0 + for n < len(data) && versionByte(data[n]) { + n++ + } + keep := min(n, 256-len(s.token)) + s.token = append(s.token, data[:keep]...) + if n > keep { + s.overlong = true + } + data = data[n:] + if len(data) == 0 { + return + } + s.endToken(false) + data = data[1:] + continue + } + index := bytes.Index(data, []byte("Bun v")) + other := bytes.Index(data, []byte("bun-v")) + if index < 0 || other >= 0 && other < index { + index = other + } + if index < 0 { + s.tail = append([]byte(nil), data[max(0, len(data)-4):]...) + return + } + data = data[index+5:] + s.reading = true + } +} + +func (s *bunScanner) endToken(interrupted bool) { + value := string(s.token) + if s.overlong || interrupted { + value += "" + } + if len(value) > 0 && value[0] >= '0' && value[0] <= '9' && !s.seen[value] { + if len(s.versions) == 16 { + s.err = errors.New("bun candidate limit exceeded (16); remaining candidates uninspected") + } else { + s.versions = append(s.versions, value) + s.seen[value] = true + } + } + s.reading = false + s.overlong = false + s.token = nil +} + +func (s *bunScanner) finish(complete bool) { + if s.reading { + s.endToken(!complete) + } +} + +func (s *bunScanner) evidence() []Evidence { + var result []Evidence + for _, version := range s.versions { + result = append(result, Evidence{"bun-version-string", version}) + } + return result +} diff --git a/tools/brotli-doctor/bun_test.go b/tools/brotli-doctor/bun_test.go new file mode 100644 index 00000000..52af8158 --- /dev/null +++ b/tools/brotli-doctor/bun_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "bytes" + "context" + "encoding/json/v2" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBunVersionComparisonNeverVerifiesIdentityOrPatchStatus(t *testing.T) { + for _, tt := range []struct{ version, want string }{ + {"1.4.3", "below-reviewed-release-threshold"}, + {"1.4.4", "at-or-above-reviewed-release-threshold"}, + {"1.4.4+38afee6c8", "at-or-above-reviewed-release-threshold"}, + {"2.0.0", "at-or-above-reviewed-release-threshold"}, + {"1.4.4-canary", "unverified-version"}, + {"1.4.4.1", "unverified-version"}, + {"01.4.4", "unverified-version"}, + {"1.4.4evil", "unverified-version"}, + {"999999999999999999999999.4.4", "unverified-version"}, + {"1.4.4+", "unverified-version"}, + {"1.4.3+backported", "below-reviewed-release-threshold"}, + } { + t.Run(tt.version, func(t *testing.T) { + got := reviewBun(tt.version) + if got.Comparison != tt.want || got.BinaryIdentity != "unverified" || got.PatchStatus != "unverified" { + t.Fatalf("%+v", got) + } + }) + } +} + +func TestBunScannerWholeTokensEverySplitAndNUL(t *testing.T) { + input := []byte("other Bun v1.4.4-canary\x00bun-v1.4.3/x Bun v1.4.4+abc\x00Bun v1.4.4.1 rest") + for split := 0; split <= len(input); split++ { + scanner := newBunScanner() + scanner.feed(input[:split]) + scanner.feed(input[split:]) + scanner.finish(true) + if got := strings.Join(scanner.versions, ","); got != "1.4.4-canary,1.4.3,1.4.4+abc,1.4.4.1" { + t.Fatalf("split=%d: %q", split, got) + } + } + scanner := newBunScanner() + scanner.feed([]byte("Bun v" + strings.Repeat("9", 1024))) + scanner.finish(true) + if len(scanner.versions) != 1 || reviewBun(scanner.versions[0]).Comparison != "unverified-version" { + t.Fatal("overlong token accepted") + } + scanner = newBunScanner() + scanner.feed([]byte("Bun v1.4.4")) + scanner.finish(false) + if reviewBun(scanner.versions[0]).Comparison != "unverified-version" { + t.Fatal("interrupted token accepted") + } +} + +func TestBunCandidateCapIsIncompleteAndMixedCandidatesArePreserved(t *testing.T) { + input := "Bun v1.4.3\x00Bun v1.4.4\x00Bun v1.4.4-canary\x00" + evidence, err := stringEvidence(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + image := Image{Evidence: evidence, RuntimeReviews: bunReviews(evidence)} + if len(image.RuntimeReviews) != 3 || evidenceState(evidence) != "HINT" { + t.Fatalf("%+v", image) + } + report := Report{Notice: disclaimer, Findings: []Finding{{Path: "unknown-name", Images: []Image{image}}}} + for _, asJSON := range []bool{false, true} { + var out bytes.Buffer + if err := outputReport(&out, report, asJSON); err != nil { + t.Fatal(err) + } + for _, want := range []string{"1.4.3", "1.4.4-canary", "below-reviewed-release-threshold", "at-or-above-reviewed-release-threshold", "unverified"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("lost %q: %s", want, &out) + } + } + if asJSON { + var decoded Report + if err := json.Unmarshal(out.Bytes(), &decoded); err != nil { + t.Fatal(err) + } + if decoded.Incomplete { + t.Fatal("mixed candidates confused with operational failure") + } + } + } + var many strings.Builder + for i := 0; i < 17; i++ { + many.WriteString("Bun v1.4." + strings.Repeat("1", i+1) + "\x00") + } + _, err = stringEvidence(strings.NewReader(many.String())) + if err == nil { + t.Fatal("candidate cap silently truncated") + } + path := filepath.Join(t.TempDir(), "image") + if err := os.WriteFile(path, append(elfFixture(t, true, false), []byte(many.String())...), 0o700); err != nil { + t.Fatal(err) + } + if !scan(context.Background(), Inventory{Paths: []string{path}}, 512<<20).Incomplete { + t.Fatal("cap not propagated") + } +} + +func TestBunScannerIgnoresWordsThatAreNotVersionCandidates(t *testing.T) { + scanner := newBunScanner() + scanner.feed([]byte("Bun version bun-vfs Bun v%prefix Bun v1.4.4\x00")) + scanner.finish(true) + if len(scanner.versions) != 1 || scanner.versions[0] != "1.4.4" { + t.Fatalf("not version candidates: %v", scanner.versions) + } +} + +func TestBunScannerReadFailureDoesNotAcceptPrefix(t *testing.T) { + evidence, err := stringEvidence(io.MultiReader(strings.NewReader("Bun v1.4.4"), brokenReader{})) + if err == nil { + t.Fatal("expected read failure") + } + for _, review := range bunReviews(evidence) { + if review.Comparison != "unverified-version" { + t.Fatalf("%+v", review) + } + } +} diff --git a/tools/brotli-doctor/elf_test.go b/tools/brotli-doctor/elf_test.go new file mode 100644 index 00000000..94a508a8 --- /dev/null +++ b/tools/brotli-doctor/elf_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "bytes" + "context" + "debug/elf" + "encoding/binary" + "os" + "path/filepath" + "testing" +) + +func writeLE(t *testing.T, w *bytes.Buffer, value any) { + t.Helper() + if err := binary.Write(w, binary.LittleEndian, value); err != nil { + t.Fatal(err) + } +} + +func elfFixture(t *testing.T, defined bool, brokenSymbols bool) []byte { + t.Helper() + dynstr := []byte("\x00libbrotlidec.so.1\x00") + strtab := []byte("\x00BrotliDecoderCreateInstance\x00") + var dyn, symbols bytes.Buffer + writeLE(t, &dyn, elf.Dyn64{Tag: int64(elf.DT_NEEDED), Val: 1}) + writeLE(t, &dyn, elf.Dyn64{Tag: int64(elf.DT_NULL)}) + writeLE(t, &symbols, elf.Sym64{}) + symbol := elf.Sym64{Name: 1} + if defined { + symbol.Shndx = 1 + } + writeLE(t, &symbols, symbol) + data := make([]byte, 64) + sections := []elf.Section64{{}} + for i, body := range [][]byte{dynstr, dyn.Bytes(), strtab, symbols.Bytes()} { + section := elf.Section64{Off: uint64(len(data)), Size: uint64(len(body))} + switch i { + case 0, 2: + section.Type = uint32(elf.SHT_STRTAB) + case 1: + section.Type = uint32(elf.SHT_DYNAMIC) + section.Link = 1 + section.Entsize = 16 + case 3: + section.Type = uint32(elf.SHT_SYMTAB) + section.Link = 3 + section.Entsize = 24 + if brokenSymbols { + section.Link = 999 + } + } + data = append(data, body...) + sections = append(sections, section) + } + header := elf.Header64{Type: uint16(elf.ET_EXEC), Machine: uint16(elf.EM_X86_64), Version: 1, Shoff: uint64(len(data)), Ehsize: 64, Shentsize: 64, Shnum: uint16(len(sections))} + copy(header.Ident[:], []byte{0x7f, 'E', 'L', 'F', byte(elf.ELFCLASS64), byte(elf.ELFDATA2LSB), 1}) + var encoded bytes.Buffer + writeLE(t, &encoded, header) + copy(data, encoded.Bytes()) + for _, section := range sections { + encoded.Reset() + writeLE(t, &encoded, section) + data = append(data, encoded.Bytes()...) + } + return data +} + +func TestELFImportsDefinedUndefinedAndBrokenSymbols(t *testing.T) { + for _, tt := range []struct { + name string + defined, broken bool + kind string + }{{"defined", true, false, "c-defined-symbol"}, {"undefined", false, false, "c-undefined-symbol"}, {"broken", true, true, ""}} { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "image") + if err := os.WriteFile(path, elfFixture(t, tt.defined, tt.broken), 0o700); err != nil { + t.Fatal(err) + } + finding, native := inspect(context.Background(), path, 512<<20) + if !native || len(finding.Images) != 1 { + t.Fatalf("%+v", finding) + } + image := finding.Images[0] + if !hasEvidence(image.Evidence, "dynamic-import", "libbrotlidec.so.1") { + t.Fatalf("import missing: %+v", finding) + } + if tt.broken { + if len(image.Issues) == 0 { + t.Fatal("symbol failure hidden") + } + return + } + if len(image.Issues) > 0 || !hasEvidence(image.Evidence, tt.kind, "BrotliDecoder") { + t.Fatalf("%+v", finding) + } + }) + } +} diff --git a/tools/brotli-doctor/evidence.go b/tools/brotli-doctor/evidence.go new file mode 100644 index 00000000..f4cc7599 --- /dev/null +++ b/tools/brotli-doctor/evidence.go @@ -0,0 +1,92 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "strings" +) + +// Evidence records observations, not vulnerability or absence verdicts. +type Evidence struct { + Kind string `json:"kind"` + Detail string `json:"detail"` +} + +type Image struct { + Format string `json:"format"` + Architecture string `json:"architecture"` + Evidence []Evidence `json:"evidence"` + Issues []string `json:"issues"` + RuntimeReviews []RuntimeReview `json:"runtime_reviews"` +} + +type Finding struct { + Path string `json:"path"` + Images []Image `json:"images"` + Issues []string `json:"issues"` +} + +func evidenceState(evidence []Evidence) string { + state := "UNKNOWN" + for _, e := range evidence { + switch e.Kind { + case "go-build": + continue + case "c-api-string", "rust-path-string", "bun-version-string": + state = "HINT" + default: + return "EVIDENCE" + } + } + return state +} + +func brotliLibrary(name string) bool { return strings.Contains(strings.ToLower(name), "brotli") } +func cAPI(name string) bool { + name = strings.TrimPrefix(name, "_") + return strings.HasPrefix(name, "BrotliDecoder") || strings.HasPrefix(name, "BrotliEncoder") || strings.HasPrefix(name, "BrotliGet") +} + +func stringEvidence(r io.Reader) ([]Evidence, error) { + patterns := []Evidence{ + {"c-api-string", "BrotliDecoderCreateInstance"}, + {"c-api-string", "BrotliEncoderCreateInstance"}, + {"c-api-string", "BrotliDecoderDecompress"}, + {"c-api-string", "BrotliEncoderCompress"}, + {"rust-path-string", "brotli_decompressor::"}, + {"rust-path-string", "brotli::"}, + {"rust-path-string", "brotli_sys::"}, + } + seen := map[string]bool{} + var found []Evidence + chunk := make([]byte, 32768) + var overlap []byte + versions := newBunScanner() + for { + n, err := r.Read(chunk) + versions.feed(chunk[:n]) + overlap = append(overlap, chunk[:n]...) + data := overlap + for _, p := range patterns { + if !seen[p.Detail] && bytes.Contains(data, []byte(p.Detail)) { + seen[p.Detail] = true + found = append(found, p) + } + } + if err != nil || versions.err != nil { + versions.finish(errors.Is(err, io.EOF)) + found = append(found, versions.evidence()...) + if errors.Is(err, io.EOF) { + return found, versions.err + } + if err != nil { + err = fmt.Errorf("string scan: %w", err) + } + return found, errors.Join(err, versions.err) + } + keep := min(len(data), 64) + overlap = append([]byte(nil), data[len(data)-keep:]...) + } +} diff --git a/tools/brotli-doctor/evidence_test.go b/tools/brotli-doctor/evidence_test.go new file mode 100644 index 00000000..143f92b1 --- /dev/null +++ b/tools/brotli-doctor/evidence_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "bytes" + "io" + "strings" + "testing" +) + +func TestEvidenceDoesNotClearUnknownOrMixedImplementations(t *testing.T) { + cases := []struct { + name string + evidence []Evidence + want string + }{ + {"nothing", nil, "UNKNOWN"}, + {"strings only", []Evidence{{"c-api-string", "BrotliDecoderCreateInstance"}}, "HINT"}, + {"pure is not clearance", []Evidence{{"go-module", "github.com/andybalholm/brotli@v1"}}, "EVIDENCE"}, + {"mixed", []Evidence{{"go-module", "github.com/andybalholm/brotli@v1"}, {"dynamic-import", "libbrotlidec.so"}}, "EVIDENCE"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := evidenceState(tt.evidence); got != tt.want { + t.Fatalf("got %s want %s", got, tt.want) + } + }) + } +} + +func TestStringEvidenceAcrossChunksAndReadFailure(t *testing.T) { + input := strings.Repeat("x", 32760) + "BrotliDecoderCreateInstance\x00brotli_decompressor::decode" + got, err := stringEvidence(strings.NewReader(input)) + if err != nil || len(got) != 2 { + t.Fatalf("evidence=%v err=%v", got, err) + } + _, err = stringEvidence(io.MultiReader(bytes.NewBufferString("BrotliEncoderCreateInstance"), brokenReader{})) + if err == nil { + t.Fatal("read failure silently accepted") + } +} + +type brokenReader struct{} + +func (brokenReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } diff --git a/tools/brotli-doctor/fat_test.go b/tools/brotli-doctor/fat_test.go new file mode 100644 index 00000000..b27db4a1 --- /dev/null +++ b/tools/brotli-doctor/fat_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "bytes" + "context" + "debug/macho" + "encoding/binary" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestFatMachOSeparatesArchitecturesAndReportsBrokenSlice(t *testing.T) { + if testing.Short() { + t.Skip("cross-build integration") + } + dir := t.TempDir() + source := filepath.Join(dir, "main.go") + putFile(t, source, "package main\nfunc main() {}\n") + payloads := [][]byte{} + arches := []macho.FatArchHeader{} + offset := uint32(4096) + for _, arch := range []string{"amd64", "arm64"} { + path := filepath.Join(dir, arch) + cmd := exec.Command("go", "build", "-o", path, source) + cmd.Env = append(os.Environ(), "GOOS=darwin", "GOARCH="+arch, "CGO_ENABLED=0", "GOTOOLCHAIN=local") + if data, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build: %v %s", err, data) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + f, err := macho.NewFile(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + arches = append(arches, macho.FatArchHeader{Cpu: f.Cpu, SubCpu: f.SubCpu, Offset: offset, Size: uint32(len(data)), Align: 12}) + payloads = append(payloads, data) + offset = (offset + uint32(len(data)) + 4095) &^ 4095 + } + var buf bytes.Buffer + if err := binary.Write(&buf, binary.BigEndian, []uint32{macho.MagicFat, 2}); err != nil { + t.Fatal(err) + } + for _, arch := range arches { + if err := binary.Write(&buf, binary.BigEndian, arch); err != nil { + t.Fatal(err) + } + } + image := buf.Bytes() + for i, arch := range arches { + image = append(image, make([]byte, int(arch.Offset)-len(image))...) + image = append(image, payloads[i]...) + } + path := filepath.Join(dir, "fat") + if err := os.WriteFile(path, image, 0o700); err != nil { + t.Fatal(err) + } + result, native := inspect(context.Background(), path, 512<<20) + if !native || len(result.Issues) > 0 || len(result.Images) != 2 { + t.Fatalf("%+v", result) + } + for _, slice := range result.Images { + if len(slice.Issues) > 0 || !hasEvidence(slice.Evidence, "go-build", "go1.") { + t.Fatalf("%+v", slice) + } + } + if result.Images[0].Architecture == result.Images[1].Architecture { + t.Fatal("architecture attribution lost") + } + if err := os.WriteFile(path, image[:int(arches[1].Offset)+4], 0o700); err != nil { + t.Fatal(err) + } + report := scan(context.Background(), Inventory{Paths: []string{path}}, 512<<20) + if !report.Incomplete { + t.Fatal("broken slice accepted as complete") + } +} diff --git a/tools/brotli-doctor/go.mod b/tools/brotli-doctor/go.mod new file mode 100644 index 00000000..11a5cb61 --- /dev/null +++ b/tools/brotli-doctor/go.mod @@ -0,0 +1,3 @@ +module github.com/hironow/dotfiles/tools/brotli-doctor + +go 1.27.0 diff --git a/tools/brotli-doctor/inspect.go b/tools/brotli-doctor/inspect.go new file mode 100644 index 00000000..55541a64 --- /dev/null +++ b/tools/brotli-doctor/inspect.go @@ -0,0 +1,201 @@ +package main + +import ( + "bytes" + "context" + "debug/buildinfo" + "debug/elf" + "debug/macho" + "errors" + "fmt" + "io" + "os" +) + +func inspect(ctx context.Context, path string, maxBytes int64) (Finding, bool) { + result := Finding{Path: path} + f, err := openRegular(path, maxBytes) + if err != nil { + result.Issues = []string{err.Error()} + return result, false + } + defer f.Close() + info, err := f.Stat() + if err != nil { + result.Issues = []string{err.Error()} + return result, false + } + reader := io.NewSectionReader(f, 0, info.Size()) + header := make([]byte, 4) + if _, err := reader.ReadAt(header, 0); err != nil { + return result, false + } + format := nativeFormat(header) + if format == "" { + return result, false + } + images, err := readImages(ctx, reader, format) + result.Images = images + if err != nil { + result.Issues = append(result.Issues, err.Error()) + } + // Metadata and strings are read per slice, never attributed across fat architectures. + if format != "Mach-O-fat" && len(images) == 1 { + enrich(ctx, reader, &result.Images[0]) + } + return result, true +} + +func openRegular(path string, maxBytes int64) (*os.File, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, errors.New("nonregular file excluded") + } + if info.Size() > maxBytes { + return nil, fmt.Errorf("image exceeds inspection limit (%d bytes)", maxBytes) + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + current, err := f.Stat() + if err != nil || !current.Mode().IsRegular() || current.Size() > maxBytes || !os.SameFile(info, current) { + _ = f.Close() + return nil, errors.New("file changed or is not a bounded regular file") + } + return f, nil +} + +func nativeFormat(header []byte) string { + switch { + case bytes.Equal(header, []byte{0x7f, 'E', 'L', 'F'}): + return "ELF" + case bytes.Equal(header, []byte{0xca, 0xfe, 0xba, 0xbe}), bytes.Equal(header, []byte{0xca, 0xfe, 0xba, 0xbf}), bytes.Equal(header, []byte{0xbe, 0xba, 0xfe, 0xca}), bytes.Equal(header, []byte{0xbf, 0xba, 0xfe, 0xca}): + return "Mach-O-fat" + case bytes.Equal(header, []byte{0xfe, 0xed, 0xfa, 0xce}), bytes.Equal(header, []byte{0xce, 0xfa, 0xed, 0xfe}), bytes.Equal(header, []byte{0xfe, 0xed, 0xfa, 0xcf}), bytes.Equal(header, []byte{0xcf, 0xfa, 0xed, 0xfe}): + return "Mach-O" + case bytes.HasPrefix(header, []byte("MZ")): + return "PE" + default: + return "" + } +} + +func readImages(ctx context.Context, r *io.SectionReader, format string) ([]Image, error) { + switch format { + case "ELF": + image, err := readELF(r) + return []Image{image}, err + case "Mach-O": + f, err := macho.NewFile(r) + if err != nil { + return nil, err + } + return []Image{machoImage(f)}, nil + case "PE": + image, err := readPE(ctx, r) + return []Image{image}, err + case "Mach-O-fat": + return readFat(ctx, r) + default: + return nil, errors.New("unsupported image format") + } +} + +func readFat(ctx context.Context, r *io.SectionReader) ([]Image, error) { + fat, err := macho.NewFatFile(r) + if err != nil { + return nil, fmt.Errorf("fat Mach-O: %w", err) + } + var images []Image + for _, arch := range fat.Arches { + image := machoImage(arch.File) + slice := io.NewSectionReader(r, int64(arch.Offset), int64(arch.Size)) + enrich(ctx, slice, &image) + images = append(images, image) + } + return images, nil +} + +func readELF(r io.ReaderAt) (Image, error) { + image := Image{Format: "ELF"} + f, err := elf.NewFile(r) + if err != nil { + return image, fmt.Errorf("ELF: %w", err) + } + image.Architecture = f.Machine.String() + libraries, err := f.ImportedLibraries() + addLibraries(&image, libraries, err) + for _, read := range []func() ([]elf.Symbol, error){f.Symbols, f.DynamicSymbols} { + symbols, err := read() + if err != nil && !errors.Is(err, elf.ErrNoSymbols) { + image.Issues = append(image.Issues, "ELF symbols: "+err.Error()) + } + for _, s := range symbols { + if cAPI(s.Name) { + kind := "c-defined-symbol" + if s.Section == elf.SHN_UNDEF { + kind = "c-undefined-symbol" + } + image.Evidence = append(image.Evidence, Evidence{kind, s.Name}) + } + } + } + return image, nil +} + +func machoImage(f *macho.File) Image { + image := Image{Format: "Mach-O", Architecture: f.Cpu.String()} + libraries, err := f.ImportedLibraries() + addLibraries(&image, libraries, err) + if f.Symtab != nil { + for _, s := range f.Symtab.Syms { + if cAPI(s.Name) { + kind := "c-defined-symbol" + if s.Type&0x0e == 0 { + kind = "c-undefined-symbol" + } + image.Evidence = append(image.Evidence, Evidence{kind, s.Name}) + } + } + } + return image +} + +func addLibraries(image *Image, libraries []string, err error) { + if err != nil { + image.Issues = append(image.Issues, "dynamic imports: "+err.Error()) + } + for _, name := range libraries { + if brotliLibrary(name) { + image.Evidence = append(image.Evidence, Evidence{"dynamic-import", name}) + } + } +} + +func enrich(ctx context.Context, r *io.SectionReader, image *Image) { + if info, err := buildinfo.Read(r); err == nil { + image.Evidence = append(image.Evidence, moduleEvidence(info)...) + } + hints, err := stringEvidence(contextReader{ctx: ctx, r: io.NewSectionReader(r, 0, r.Size())}) + image.Evidence = append(image.Evidence, hints...) + image.RuntimeReviews = bunReviews(image.Evidence) + if err != nil { + image.Issues = append(image.Issues, err.Error()) + } +} + +type contextReader struct { + ctx context.Context + r io.Reader +} + +func (r contextReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.r.Read(p) +} diff --git a/tools/brotli-doctor/inspect_test.go b/tools/brotli-doctor/inspect_test.go new file mode 100644 index 00000000..3f7e5df9 --- /dev/null +++ b/tools/brotli-doctor/inspect_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestInspectSkipsScriptWithoutExecutingAndRejectsOversize(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "script") + marker := filepath.Join(dir, "executed") + putFile(t, path, "#!/bin/sh\ntouch "+marker+"\n") + finding, native := inspect(context.Background(), path, 512<<20) + if native || len(finding.Issues) != 0 { + t.Fatalf("%+v native=%v", finding, native) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("target executed") + } + putFile(t, path, "\x7fELFbroken") + finding, native = inspect(context.Background(), path, 512<<20) + if !native || len(finding.Issues) == 0 { + t.Fatal("malformed native image silently skipped") + } + finding, _ = inspect(context.Background(), path, 2) + if len(finding.Issues) == 0 { + t.Fatal("size limit silently accepted") + } +} + +func TestNativeFormatsAndStrippedBuildInfo(t *testing.T) { + if testing.Short() { + t.Skip("cross-build integration; run go test without -short") + } + dir := t.TempDir() + source := filepath.Join(dir, "main.go") + putFile(t, source, "package main\nfunc main() {}\n") + for _, target := range []struct{ os, arch, format string }{{"linux", "amd64", "ELF"}, {"darwin", "arm64", "Mach-O"}, {"windows", "amd64", "PE"}} { + t.Run(target.os, func(t *testing.T) { + path := filepath.Join(dir, target.os+".bin") + cmd := exec.Command("go", "build", "-ldflags=-s -w", "-o", path, source) + cmd.Env = append(os.Environ(), "GOOS="+target.os, "GOARCH="+target.arch, "CGO_ENABLED=0", "GOTOOLCHAIN=local") + if data, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build: %v %s", err, data) + } + finding, native := inspect(context.Background(), path, 512<<20) + if !native || len(finding.Issues) > 0 || len(finding.Images) != 1 || len(finding.Images[0].Issues) > 0 || finding.Images[0].Format != target.format { + t.Fatalf("%+v", finding) + } + if !hasEvidence(finding.Images[0].Evidence, "go-build", "go1.") { + t.Fatalf("stripped build metadata missing: %+v", finding) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + switch target.os { + case "windows": + if !bytes.Contains(data, []byte("kernel32.dll")) { + t.Fatal("fixture has no kernel32.dll") + } + data = bytes.ReplaceAll(data, []byte("kernel32.dll"), []byte("brotli64.dll")) + case "darwin": + old := []byte("/usr/lib/libSystem.B.dylib") + if !bytes.Contains(data, old) { + t.Fatal("fixture has no libSystem") + } + replacement := append([]byte("/usr/lib/libbrotli.dylib"), make([]byte, len(old)-len("/usr/lib/libbrotli.dylib"))...) + data = bytes.ReplaceAll(data, old, replacement) + default: + return + } + if err := os.WriteFile(path, data, 0o700); err != nil { + t.Fatal(err) + } + finding, _ = inspect(context.Background(), path, 512<<20) + if !hasEvidence(finding.Images[0].Evidence, "dynamic-import", "brotli") { + t.Fatalf("dynamic import not found: %+v", finding) + } + }) + } +} + +func hasEvidence(evidence []Evidence, kind, detail string) bool { + for _, e := range evidence { + if e.Kind == kind && strings.Contains(e.Detail, detail) { + return true + } + } + return false +} + +func TestPEImportDescriptorsIncludeOrdinalOnlyDLL(t *testing.T) { + // Two descriptors: the first imports only by ordinal (no function names needed). + data := make([]byte, 80) + binary.LittleEndian.PutUint32(data[12:16], 1060) + copy(data[60:], "brotlidec.dll\x00") + names, err := importDescriptors(context.Background(), bytes.NewReader(data), func(rva uint32) (string, error) { return cString(data[rva-1000:]) }) + if err != nil || len(names) != 1 || names[0] != "brotlidec.dll" { + t.Fatalf("%v %v", names, err) + } + _, err = importDescriptors(context.Background(), bytes.NewReader(data[:20]), func(uint32) (string, error) { return "", os.ErrInvalid }) + if err == nil { + t.Fatal("malformed descriptor accepted") + } +} diff --git a/tools/brotli-doctor/inventory.go b/tools/brotli-doctor/inventory.go new file mode 100644 index 00000000..2463a668 --- /dev/null +++ b/tools/brotli-doctor/inventory.go @@ -0,0 +1,280 @@ +package main + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +type Inventory struct { + Paths []string `json:"paths"` + Issues []string `json:"issues"` + Excluded int `json:"excluded"` + Duplicates int `json:"duplicates"` +} + +type collector struct { + result Inventory + seen map[string]bool + ctx context.Context + entries int + limit int +} + +func within(root, path string) bool { + rel, err := filepath.Rel(root, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +func safeRoot(root string) error { + if !filepath.IsAbs(root) || filepath.Dir(root) == root { + return errors.New("not a bounded absolute install root") + } + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("home boundary: %w", err) + } + // Resolve aliases so /var -> /private/var cannot defeat the boundary. + if canonical, err := filepath.EvalSymlinks(home); err == nil { + home = canonical + } + if within(root, home) { + return errors.New("install root contains the home directory") + } + return nil +} + +func collect(ctx context.Context, roots, pathDirs []string) Inventory { + c := collector{seen: map[string]bool{}, ctx: ctx} + for _, root := range roots { + if err := c.budget(); err != nil { + c.issue(root, err) + break + } + if err := c.walkRoot(root); err != nil { + c.issue(root, err) + } + } + for _, dir := range pathDirs { + if err := c.budget(); err != nil { + c.issue(dir, err) + break + } + if err := c.readPATH(dir); err != nil { + c.issue(dir, err) + } + } + sort.Strings(c.result.Paths) + return c.result +} + +func (c *collector) issue(path string, err error) { + c.result.Issues = append(c.result.Issues, fmt.Sprintf("%q: %v", path, err)) +} + +func (c *collector) budget() error { + if err := c.ctx.Err(); err != nil { + return err + } + if c.limit == 0 { + c.limit = 200000 + } + if c.entries >= c.limit { + return fmt.Errorf("inventory entry limit reached (%d)", c.limit) + } + return nil +} + +// Readdir is batched: caps apply before enumeration, not after allocation. +func (c *collector) enumerate(dir *os.File, visit func(fs.DirEntry) error) error { + for { + if err := c.budget(); err != nil { + return err + } + entries, err := dir.ReadDir(min(128, c.limit-c.entries)) + for _, entry := range entries { + if err := c.budget(); err != nil { + return err + } + c.entries++ + if err := visit(entry); err != nil { + return err + } + } + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + } +} + +func (c *collector) walkRoot(raw string) error { + if err := c.budget(); err != nil { + return err + } + if !filepath.IsAbs(raw) { + return errors.New("relative install root rejected") + } + root, err := filepath.EvalSymlinks(raw) + if err != nil { + return err + } + if err := safeRoot(root); err != nil { + return err + } + boundary, err := os.OpenRoot(root) + if err != nil { + return err + } + defer boundary.Close() + return c.walkDirectory(root, boundary, ".") +} + +func (c *collector) walkDirectory(boundary string, root *os.Root, relative string) error { + if err := c.budget(); err != nil { + return err + } + dir, err := root.Open(relative) + if err != nil { + return err + } + defer dir.Close() + return c.enumerate(dir, func(entry fs.DirEntry) error { + child := filepath.Join(relative, entry.Name()) + path := filepath.Join(boundary, child) + if entry.IsDir() { + if err := c.walkDirectory(boundary, root, child); err != nil { + if stopped := c.budget(); stopped != nil { + return stopped + } + c.issue(path, err) + } + } else { + c.add(path, boundary) + } + return nil + }) +} + +func (c *collector) readPATH(dir string) error { + if err := c.budget(); err != nil { + return err + } + // Never inspect cwd implicitly through an empty or relative PATH component. + if !filepath.IsAbs(dir) { + return errors.New("empty/relative PATH directory excluded") + } + root, err := os.OpenRoot(dir) + if err != nil { + return err + } + defer root.Close() + file, err := root.Open(".") + if err != nil { + return err + } + defer file.Close() + return c.enumerate(file, func(entry fs.DirEntry) error { + if !entry.IsDir() { + c.add(filepath.Join(dir, entry.Name()), "") + } + return nil + }) +} + +func (c *collector) add(path, root string) { + canonical, err := filepath.EvalSymlinks(path) + if err != nil { + c.issue(path, err) + return + } + if root != "" && !within(root, canonical) { + c.result.Excluded++ + c.issue(path, errors.New("link outside mise install root excluded")) + return + } + info, err := statCandidate(canonical, root) + if err != nil { + c.issue(path, err) + return + } + if !info.Mode().IsRegular() { + c.result.Excluded++ + return + } + if runtime.GOOS == "windows" && !strings.EqualFold(filepath.Ext(canonical), ".exe") && !strings.EqualFold(filepath.Ext(canonical), ".com") { + c.result.Excluded++ + return + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { + c.result.Excluded++ + return + } + key := canonical + if runtime.GOOS == "windows" { + key = strings.ToLower(key) + } + if c.seen[key] { + c.result.Duplicates++ + return + } + c.seen[key] = true + c.result.Paths = append(c.result.Paths, canonical) +} + +// Use os.Root for the final stat as well as lexical containment checks. +func statCandidate(path, boundary string) (fs.FileInfo, error) { + if boundary == "" { + boundary = filepath.Dir(path) + } + root, err := os.OpenRoot(boundary) + if err != nil { + return nil, err + } + defer root.Close() + relative, err := filepath.Rel(boundary, path) + if err != nil { + return nil, err + } + return root.Stat(relative) +} + +func parseMise(data []byte) ([]string, error) { + var tools map[string][]struct { + Installed bool `json:"installed"` + Path string `json:"install_path"` + } + if err := json.Unmarshal(data, &tools); err != nil { + return nil, fmt.Errorf("mise JSON: %w", err) + } + if tools == nil { + return nil, errors.New("mise JSON must be an object") + } + seen := map[string]bool{} + var roots []string + for _, versions := range tools { + for _, v := range versions { + if v.Installed { + if !filepath.IsAbs(v.Path) { + return nil, errors.New("mise installed entry has no absolute install_path") + } + if !seen[v.Path] { + seen[v.Path] = true + roots = append(roots, v.Path) + } + } + } + } + sort.Strings(roots) + return roots, nil +} diff --git a/tools/brotli-doctor/inventory_test.go b/tools/brotli-doctor/inventory_test.go new file mode 100644 index 00000000..b52f5e83 --- /dev/null +++ b/tools/brotli-doctor/inventory_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "encoding/json/v2" + "fmt" + "net" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func putFile(t *testing.T, path string, data string) { + t.Helper() + if err := os.WriteFile(path, []byte(data), 0o700); err != nil { + t.Fatal(err) + } +} + +func linkFile(t *testing.T, old, new string) { + t.Helper() + if err := os.Symlink(old, new); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } +} + +func TestInventoryKeepsMiseTargetsInsideRootAndDeduplicatesPATH(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + own := filepath.Join(root, "own.exe") + external := filepath.Join(outside, "external.exe") + putFile(t, own, "hello") + putFile(t, external, "hello") + linkFile(t, external, filepath.Join(root, "escape.exe")) + linkFile(t, outside, filepath.Join(root, "directory-escape")) + linkFile(t, own, filepath.Join(root, "alias.exe")) + got := collect(context.Background(), []string{root}, []string{root}) + if len(got.Paths) != 2 || got.Duplicates < 1 || len(got.Issues) == 0 || got.Excluded < 1 { + t.Fatalf("inventory %+v", got) + } + miseOnly := collect(context.Background(), []string{root}, nil) + if len(miseOnly.Paths) != 1 { + t.Fatalf("mise escaped: %+v", miseOnly) + } +} + +func TestInventoryRejectsBroadRootsRelativePATHAndMissingRoots(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + if safeRoot(home) == nil || safeRoot(filepath.VolumeName(home)+string(filepath.Separator)) == nil { + t.Fatal("broad root accepted") + } + got := collect(context.Background(), []string{filepath.Join(t.TempDir(), "missing")}, []string{"", "."}) + if len(got.Issues) != 3 { + t.Fatalf("issues: %+v", got) + } + if within("/a/tool", "/a/tool-sibling/bin") { + t.Fatal("sibling-prefix accepted") + } +} + +func TestInventoryCancellationAndNonRecursivePATH(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "nested"), 0o700); err != nil { + t.Fatal(err) + } + putFile(t, filepath.Join(root, "nested", "hidden"), "hello") + got := collect(context.Background(), nil, []string{root}) + if len(got.Paths) != 0 { + t.Fatal("PATH scanned recursively") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + got = collect(ctx, []string{root}, nil) + if !strings.Contains(strings.Join(got.Issues, " "), "canceled") { + t.Fatalf("cancellation hidden: %+v", got) + } +} + +func TestInventoryDoesNotOpenSpecialFiles(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix device path") + } + dir, err := os.MkdirTemp("", "br-sock") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + listener, err := net.Listen("unix", filepath.Join(dir, "socket")) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + got := collect(context.Background(), nil, []string{dir}) + if len(got.Paths) != 0 || got.Excluded != 1 { + t.Fatalf("accepted nonregular file: %+v", got) + } +} + +func TestParseMiseReportsInvalidOrEmptyRoots(t *testing.T) { + path := filepath.Join(t.TempDir(), "go") + encoded, err := json.Marshal(path) + if err != nil { + t.Fatal(err) + } + roots, err := parseMise(fmt.Appendf(nil, `{"go":[{"installed":true,"install_path":%s},{"installed":false,"install_path":"/ignore"}]}`, encoded)) + if err != nil || len(roots) != 1 || roots[0] != path { + t.Fatalf("%v %v", roots, err) + } + for _, raw := range []string{`{`, `{"go":[{"installed":true}]}`, `null`} { + if _, err := parseMise([]byte(raw)); err == nil { + t.Fatalf("accepted %s", raw) + } + } +} diff --git a/tools/brotli-doctor/main.go b/tools/brotli-doctor/main.go new file mode 100644 index 00000000..42d24270 --- /dev/null +++ b/tools/brotli-doctor/main.go @@ -0,0 +1,214 @@ +// brotli-doctor inventories native CLI images without executing them. +// It reports Brotli evidence and uncertainty, never a clean bill of health. +package main + +import ( + "context" + "encoding/json/v2" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + disclaimer = "Evidence inventory only, NOT a security clearance. UNKNOWN does not mean absent. Direct imports only; dlopen, transitive libraries, stripped/static code and CVE applicability require further review." + help = `usage: brotli-doctor [--json] [--mise=false] [--path=false] [--max-bytes=N] [--timeout=2m] [files...] +Without files, scans all mise installed versions recursively and PATH directories non-recursively. +With files, inspects only those files. Never executes targets, installs or updates packages. +Exit: 0 completed (NOT safe), 1 incomplete, 2 usage error. Size limit defaults to 512MiB. +The timeout is cooperative, not a hard filesystem I/O deadline. +` +) + +type Report struct { + Notice string `json:"notice"` + Inventory Inventory `json:"inventory"` + Findings []Finding `json:"findings"` + NonNative int `json:"non_native"` + UnknownImages int `json:"unknown_images"` + Incomplete bool `json:"incomplete"` +} + +func main() { os.Exit(runCLI(context.Background(), os.Args[1:], os.Stdout)) } + +func runCLI(parent context.Context, args []string, out io.Writer) int { + flags := flag.NewFlagSet("brotli-doctor", flag.ContinueOnError) + flags.SetOutput(io.Discard) + asJSON := flags.Bool("json", false, "JSON output") + useMise := flags.Bool("mise", true, "inventory installed mise versions") + usePATH := flags.Bool("path", true, "inventory direct PATH entries") + maxBytes := flags.Int64("max-bytes", 512<<20, "maximum bytes per file, up to 512MiB") + timeout := flags.Duration("timeout", 2*time.Minute, "cooperative scan deadline") + if err := flags.Parse(args); err != nil { + _, _ = io.WriteString(out, help) + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if *maxBytes <= 0 || *maxBytes > 512<<20 || *timeout <= 0 || *timeout > 30*time.Minute || (flags.NArg() == 0 && !*useMise && !*usePATH) { + _, _ = io.WriteString(out, help) + return 2 + } + ctx, cancel := context.WithTimeout(parent, *timeout) + defer cancel() + inventory := inventoryFor(ctx, flags.Args(), *useMise, *usePATH) + report := scan(ctx, inventory, *maxBytes) + if err := outputReport(out, report, *asJSON); err != nil { + return 1 + } + if report.Incomplete { + return 1 + } + return 0 +} + +func inventoryFor(ctx context.Context, files []string, useMise, usePATH bool) Inventory { + if len(files) > 0 { + c := collector{seen: map[string]bool{}, ctx: ctx} + for _, file := range files { + path, err := filepath.Abs(file) + if err != nil { + c.issue(file, err) + continue + } + // Explicit files may be cross-built images without executable permissions. + path, err = filepath.EvalSymlinks(path) + if err != nil { + c.issue(file, err) + continue + } + if !c.seen[path] { + c.seen[path] = true + c.result.Paths = append(c.result.Paths, path) + } else { + c.result.Duplicates++ + } + } + return c.result + } + var roots, dirs, issues []string + if useMise { + data, err := miseInventory(ctx) + if err == nil { + roots, err = parseMise(data) + } + if err != nil { + issues = append(issues, "mise inventory: "+err.Error()) + } + } + if usePATH { + dirs = filepath.SplitList(os.Getenv("PATH")) + if len(dirs) == 0 { + issues = append(issues, "PATH is empty; no PATH coverage") + } + } + result := collect(ctx, roots, dirs) + result.Issues = append(result.Issues, issues...) + return result +} + +func scan(ctx context.Context, inventory Inventory, maxBytes int64) Report { + report := Report{Notice: disclaimer, Inventory: inventory, Incomplete: len(inventory.Issues) > 0} + for _, path := range inventory.Paths { + if err := ctx.Err(); err != nil { + report.Inventory.Issues = append(report.Inventory.Issues, err.Error()+"; remaining candidates uninspected") + report.Incomplete = true + break + } + finding, native := inspect(ctx, path, maxBytes) + if native || len(finding.Issues) > 0 { + report.Findings = append(report.Findings, finding) + } else { + report.NonNative++ + } + if len(finding.Issues) > 0 { + report.Incomplete = true + } + for _, image := range finding.Images { + if evidenceState(image.Evidence) == "UNKNOWN" { + report.UnknownImages++ + } + if len(image.Issues) > 0 { + report.Incomplete = true + } + } + } + return report +} + +func outputReport(out io.Writer, report Report, asJSON bool) error { + if asJSON { + data, err := json.Marshal(report) + if err != nil { + return err + } + _, err = fmt.Fprintln(out, string(data)) + return err + } + var text strings.Builder + fmt.Fprintln(&text, report.Notice) + for _, issue := range report.Inventory.Issues { + fmt.Fprintf(&text, "INCOMPLETE inventory %q\n", issue) + } + for _, finding := range report.Findings { + fmt.Fprintf(&text, "FILE %q\n", finding.Path) + for _, issue := range finding.Issues { + fmt.Fprintf(&text, " INCOMPLETE %q\n", issue) + } + for _, image := range finding.Images { + fmt.Fprintf(&text, " %s %q %q\n", evidenceState(image.Evidence), image.Format, image.Architecture) + for _, e := range image.Evidence { + fmt.Fprintf(&text, " %s %q\n", e.Kind, e.Detail) + } + for _, review := range image.RuntimeReviews { + fmt.Fprintf(&text, " Bun candidate=%q comparison=%s binary-identity=%s patch-status=%s %s reviewed-release=%s reviewed-at=%s source=%s\n", review.CandidateVersion, review.Comparison, review.BinaryIdentity, review.PatchStatus, review.CVE, review.ReviewedRelease, review.ReviewedAt, review.Source) + } + for _, issue := range image.Issues { + fmt.Fprintf(&text, " INCOMPLETE %q\n", issue) + } + } + } + fmt.Fprintf(&text, "SUMMARY candidates=%d reports=%d non-native=%d unknown-images=%d excluded=%d duplicates=%d incomplete=%t\n", len(report.Inventory.Paths), len(report.Findings), report.NonNative, report.UnknownImages, report.Inventory.Excluded, report.Inventory.Duplicates, report.Incomplete) + fmt.Fprintln(&text, "Next: dynamic-import -> verify resolved library/package version; c-defined-symbol -> investigate embedded C and rebuild if affected; go-module -> review recorded version/replacement; string hints/UNKNOWN -> obtain build records or SBOM. Pure implementation evidence does not exclude C code. Bun string candidates never verify binary identity or patch status: verify provenance; rebuild standalone artifacts with a reviewed fixed runtime, replace images/binaries and restart through normal deployment procedures.") + _, err := io.WriteString(out, text.String()) + return err +} + +type cappedOutput struct { + data []byte + overflow bool +} + +func (b *cappedOutput) Write(p []byte) (int, error) { + if len(b.data)+len(p) > 8<<20 { + b.overflow = true + return 0, errors.New("mise output exceeds 8MiB") + } + b.data = append(b.data, p...) + return len(p), nil +} + +func miseInventory(parent context.Context) ([]byte, error) { + ctx, cancel := context.WithTimeout(parent, 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "mise", "ls", "--installed", "--json") + cmd.WaitDelay = time.Second + var stdout cappedOutput + cmd.Stdout = &stdout + // Suppress arbitrary stderr, including local environment secrets. + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("mise ls --installed --json: %w", err) + } + if stdout.overflow { + return nil, errors.New("mise output exceeded limit") + } + return stdout.data, nil +} diff --git a/tools/brotli-doctor/main_test.go b/tools/brotli-doctor/main_test.go new file mode 100644 index 00000000..0eec3153 --- /dev/null +++ b/tools/brotli-doctor/main_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "bytes" + "context" + "encoding/json/v2" + "path/filepath" + "strings" + "testing" +) + +func TestCLIReportsUnknownNotClearanceAndUsageErrors(t *testing.T) { + for _, args := range [][]string{{"--mise=false", "--path=false"}, {"--max-bytes=0"}, {"--bad-option"}} { + var out bytes.Buffer + if code := runCLI(context.Background(), args, &out); code != 2 { + t.Fatalf("%v code=%d: %s", args, code, &out) + } + } + dir := t.TempDir() + path := filepath.Join(dir, "malformed") + putFile(t, path, "\x7fELFbroken") + var out bytes.Buffer + code := runCLI(context.Background(), []string{"--json", path}, &out) + var report Report + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatal(err, out.String()) + } + if code != 1 || !report.Incomplete { + t.Fatalf("code=%d report=%+v", code, report) + } + out.Reset() + putFile(t, path, "#!/bin/sh\nexit 42\n") + code = runCLI(context.Background(), []string{path}, &out) + if code != 0 || !strings.Contains(out.String(), "NOT a security clearance") || !strings.Contains(out.String(), "non-native=1") { + t.Fatalf("code=%d: %s", code, &out) + } +} + +func TestGoBuildMetadataAloneIsUnknown(t *testing.T) { + if evidenceState([]Evidence{{"go-build", "go1.27.3"}}) != "UNKNOWN" { + t.Fatal("Go version became Brotli evidence") + } +} + +func TestCLIOutputsAreTerminalEscapedAndMissingMiseIsIncomplete(t *testing.T) { + var out bytes.Buffer + if err := outputReport(&out, Report{Findings: []Finding{{Path: "evil\x1b[31m", Issues: []string{"bad\x1b"}}}}, false); err != nil { + t.Fatal(err) + } + if strings.ContainsRune(out.String(), '\x1b') { + t.Fatal("raw terminal control emitted") + } + t.Setenv("PATH", t.TempDir()) + out.Reset() + if code := runCLI(context.Background(), []string{"--path=false", "--json"}, &out); code != 1 { + t.Fatalf("missing mise code=%d: %s", code, &out) + } +} diff --git a/tools/brotli-doctor/module.go b/tools/brotli-doctor/module.go new file mode 100644 index 00000000..2bb35ae9 --- /dev/null +++ b/tools/brotli-doctor/module.go @@ -0,0 +1,22 @@ +package main + +import ( + "runtime/debug" + "strings" +) + +func moduleEvidence(info *debug.BuildInfo) []Evidence { + evidence := []Evidence{{"go-build", info.GoVersion}} + modules := append([]*debug.Module{&info.Main}, info.Deps...) + for _, dep := range modules { + if !strings.Contains(strings.ToLower(dep.Path), "brotli") && (dep.Replace == nil || !strings.Contains(strings.ToLower(dep.Replace.Path), "brotli")) { + continue + } + detail := dep.Path + "@" + dep.Version + if dep.Replace != nil { + detail += " => " + dep.Replace.Path + "@" + dep.Replace.Version + } + evidence = append(evidence, Evidence{"go-module", detail}) + } + return evidence +} diff --git a/tools/brotli-doctor/module_test.go b/tools/brotli-doctor/module_test.go new file mode 100644 index 00000000..e23d746d --- /dev/null +++ b/tools/brotli-doctor/module_test.go @@ -0,0 +1,18 @@ +package main + +import ( + "runtime/debug" + "strings" + "testing" +) + +func TestGoModulesIncludeMainAndReplacementEvidence(t *testing.T) { + info := &debug.BuildInfo{GoVersion: "go1.27.3", Main: debug.Module{Path: "example.org/brotli-cli", Version: "v1"}, Deps: []*debug.Module{ + {Path: "example.org/compression", Version: "v1", Replace: &debug.Module{Path: "github.com/andybalholm/brotli", Version: "v1.2.0"}}, + {Path: "github.com/google/brotli", Version: "v0.1", Replace: &debug.Module{Path: "../local", Version: "(devel)"}}, + }} + got := moduleEvidence(info) + if len(got) != 4 || !strings.Contains(got[2].Detail, "=> github.com/andybalholm/brotli@v1.2.0") || !strings.Contains(got[3].Detail, "../local") { + t.Fatalf("%+v", got) + } +} diff --git a/tools/brotli-doctor/pe.go b/tools/brotli-doctor/pe.go new file mode 100644 index 00000000..d417d6f1 --- /dev/null +++ b/tools/brotli-doctor/pe.go @@ -0,0 +1,120 @@ +package main + +import ( + "bytes" + "context" + "debug/pe" + "encoding/binary" + "errors" + "fmt" + "io" +) + +func readPE(ctx context.Context, r io.ReaderAt) (Image, error) { + image := Image{Format: "PE"} + f, err := pe.NewFile(r) + if err != nil { + return image, fmt.Errorf("PE: %w", err) + } + image.Architecture = fmt.Sprintf("machine-%#x", f.Machine) + libraries, err := peLibraries(ctx, f) + addLibraries(&image, libraries, err) + for _, s := range f.Symbols { + if cAPI(s.Name) { + kind := "c-defined-symbol" + if s.SectionNumber <= 0 { + kind = "c-undefined-symbol" + } + image.Evidence = append(image.Evidence, Evidence{kind, s.Name}) + } + } + return image, nil +} + +// debug/pe.ImportedLibraries is an unimplemented stub in Go 1.27. +// Read descriptors directly, including DLLs imported only by ordinal. +func peLibraries(ctx context.Context, f *pe.File) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + var directory pe.DataDirectory + switch h := f.OptionalHeader.(type) { + case *pe.OptionalHeader64: + if h.NumberOfRvaAndSizes <= pe.IMAGE_DIRECTORY_ENTRY_IMPORT { + return nil, nil + } + directory = h.DataDirectory[pe.IMAGE_DIRECTORY_ENTRY_IMPORT] + case *pe.OptionalHeader32: + if h.NumberOfRvaAndSizes <= pe.IMAGE_DIRECTORY_ENTRY_IMPORT { + return nil, nil + } + directory = h.DataDirectory[pe.IMAGE_DIRECTORY_ENTRY_IMPORT] + default: + return nil, errors.New("PE executable has no optional header") + } + if directory.VirtualAddress == 0 { + return nil, nil + } + table, err := peRange(f, directory.VirtualAddress, 20*4096) + if err != nil { + return nil, err + } + return importDescriptors(ctx, table, func(rva uint32) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + r, err := peRange(f, rva, 4096) + if err != nil { + return "", err + } + data, err := io.ReadAll(contextReader{ctx: ctx, r: r}) + if err != nil { + return "", err + } + return cString(data) + }) +} + +// Bound each read, rather than repeatedly materializing an entire section. +func peRange(f *pe.File, rva uint32, limit int64) (io.Reader, error) { + for _, section := range f.Sections { + if rva < section.VirtualAddress { + continue + } + offset := int64(rva - section.VirtualAddress) + if offset >= int64(section.Size) { + continue + } + return io.NewSectionReader(section, offset, min(limit, int64(section.Size)-offset)), nil + } + return nil, errors.New("PE import RVA outside file-backed sections") +} + +func importDescriptors(ctx context.Context, r io.Reader, readName func(uint32) (string, error)) ([]string, error) { + var names []string + var descriptor [20]byte + for i := 0; i < 4096; i++ { + if err := ctx.Err(); err != nil { + return names, err + } + if _, err := io.ReadFull(r, descriptor[:]); err != nil { + return names, fmt.Errorf("truncated PE import descriptors: %w", err) + } + if bytes.Equal(descriptor[:], make([]byte, 20)) { + return names, nil + } + name, err := readName(binary.LittleEndian.Uint32(descriptor[12:16])) + if err != nil { + return names, err + } + names = append(names, name) + } + return names, errors.New("PE import descriptor limit exceeded") +} + +func cString(data []byte) (string, error) { + if end := bytes.IndexByte(data[:min(len(data), 4096)], 0); end > 0 { + return string(data[:end]), nil + } + return "", errors.New("missing/unterminated PE library name") +}