diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c0e7da7..19958de7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,9 @@ jobs: version: v2.18.3 args: build --clean + - name: Verify Go overlay + run: go run ./tools/go-overlay verify dist/windows_exporter-amd64.exe dist/windows_exporter-arm64.exe + - name: Stage build artifacts run: | mkdir -p output diff --git a/.gitignore b/.gitignore index f58aaba8..71ec0ba9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ local/ !/.idea/copyright/windows_exporter.xml !/.idea/vcs.xml !/.idea/go.imports.xml +/.go-overlay/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 12e4a5b2..3c1105e8 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -2,6 +2,12 @@ version: 2 +before: + hooks: + # Removes the 32 MiB FIPS 140-3 entropy buffer that Go 1.26+ links into + # every binary. See tools/go-overlay/main.go. + - go run ./tools/go-overlay generate .go-overlay + builds: - id: windows_exporter main: ./cmd/windows_exporter @@ -16,6 +22,7 @@ builds: - CGO_ENABLED=0 flags: - -trimpath + - -overlay=.go-overlay/overlay.json tags: - trimpath ldflags: diff --git a/AGENTS.md b/AGENTS.md index 71088180..f6a3b387 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,13 @@ go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 run ./... go test ./... ``` +Release builds apply a `go build -overlay` from +[`tools/go-overlay`](tools/go-overlay/main.go) that removes the 32 MiB FIPS +140-3 entropy buffer Go links into every binary (golang/go#81956). It is pinned +to the checksum of the patched standard library file, so a Go update that +changes that file fails the build job until the overlay is refreshed or, once Go +fixes the issue, removed. Plain `go build` and `make build` do not apply it. + For collector or concurrency changes, also run the affected packages natively on Windows with `go test -race -count=1 -timeout=10m `. Add regression tests for behavior changes and benchmarks for performance improvements. Check diff --git a/tools/go-overlay/entropy_fips140.go.overlay b/tools/go-overlay/entropy_fips140.go.overlay new file mode 100644 index 00000000..64048e50 --- /dev/null +++ b/tools/go-overlay/entropy_fips140.go.overlay @@ -0,0 +1,48 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Patched copy of $GOROOT/src/crypto/internal/fips140/drbg/entropy_fips140.go +// from Go 1.27.2, applied by tools/go-overlay. The only change is that the +// 32 MiB entropy scratch buffer is allocated on first use instead of being a +// package-level variable. On Windows, the package-level variable is part of +// the writable .data section of the executable image, which is charged as +// commit for every process even if FIPS 140-3 mode is never enabled. +// See https://github.com/golang/go/issues/81956. +// +//go:build !wasm + +package drbg + +import ( + entropy "crypto/internal/entropy/v1.0.0" + "sync" +) + +// scratchMemory returns a scratch buffer that is accessed between samples by the +// entropy source to expose it to memory access timings. +// +// It is allocated once and shared between Seed calls to avoid the cost of +// zeroing a new allocation every time. The entropy source accesses it using +// atomics (and doesn't care about its contents). +var scratchMemory = sync.OnceValue(func() *entropy.ScratchBuffer { + return new(entropy.ScratchBuffer) +}) + +func getEntropy() *[SeedSize]byte { + var retries int + seed, err := entropy.Seed(scratchMemory()) + for err != nil { + // The CPU jitter-based SP 800-90B entropy source has a non-negligible + // chance of failing the startup health tests. + // + // Each time it does, it enters a permanent failure state, and we + // restart it anew. This is not expected to happen more than a few times + // in a row. + if retries++; retries > 100 { + panic("fips140/drbg: failed to obtain initial entropy") + } + seed, err = entropy.Seed(scratchMemory()) + } + return &seed +} diff --git a/tools/go-overlay/main.go b/tools/go-overlay/main.go new file mode 100644 index 00000000..5e74f04c --- /dev/null +++ b/tools/go-overlay/main.go @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Command go-overlay prepares the `go build -overlay` file used for release +// builds and verifies its effect on the built executables. +// +// Go 1.26 added a 32 MiB package-level scratch buffer to the FIPS 140-3 +// entropy source (crypto/internal/fips140/drbg.memory). On Windows it is part +// of the writable data section of the executable image, which costs 32 MiB of +// commit charge per process even when FIPS 140-3 mode is disabled. The overlay +// replaces that file with a copy that allocates the buffer on first use. +// See https://github.com/golang/go/issues/81956. +// +// Usage: +// +// go run ./tools/go-overlay generate +// go run ./tools/go-overlay verify ... +// +// generate writes overlay.json and the patched source file to dir. Pass +// -overlay=/overlay.json to go build. verify fails if an executable still +// contains the package-level buffer. +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "debug/pe" + _ "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + // targetFile is the Go standard library file replaced by the overlay. + targetFile = "src/crypto/internal/fips140/drbg/entropy_fips140.go" + + // targetSHA256 is the checksum of targetFile in the Go version the + // overlay was written for. A Go update that changes the file must fail + // the build, so the overlay is re-checked or dropped once Go fixes + // golang/go#81956. + targetSHA256 = "bd3c834a29e31c93e56d81da54d4a874561814a86550de1456c8d6d40a9c5fda" + + // bufferSymbol is the package-level buffer removed by the overlay. + bufferSymbol = "crypto/internal/fips140/drbg.memory" +) + +//go:embed entropy_fips140.go.overlay +var patchedSource []byte + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "go-overlay:", err) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) < 2 { + return errors.New("usage: go-overlay generate | verify [ ...]") + } + + switch args[0] { + case "generate": + if len(args) != 2 { + return errors.New("usage: go-overlay generate ") + } + + return generate(context.Background(), args[1]) + case "verify": + for _, path := range args[1:] { + if err := verify(path); err != nil { + return err + } + } + + return nil + default: + return fmt.Errorf("unknown command %q", args[0]) + } +} + +func generate(ctx context.Context, dir string) error { + goroot, err := goEnv(ctx, "GOROOT") + if err != nil { + return err + } + + target := filepath.Join(goroot, filepath.FromSlash(targetFile)) + + original, err := os.ReadFile(target) + if err != nil { + return fmt.Errorf("read %s: %w", target, err) + } + + sum := sha256.Sum256(original) + if got := hex.EncodeToString(sum[:]); got != targetSHA256 { + return fmt.Errorf("%s has checksum %s, expected %s: the Go toolchain changed this file, "+ + "so update tools/go-overlay/entropy_fips140.go.overlay from the new version "+ + "or remove the overlay if golang/go#81956 is fixed", target, got, targetSHA256) + } + + // The go command rejects overlays for files in the module cache, which is + // where toolchains downloaded through GOTOOLCHAIN are stored. + gomodcache, err := goEnv(ctx, "GOMODCACHE") + if err != nil { + return err + } + + if gomodcache != "" && isWithin(goroot, gomodcache) { + return fmt.Errorf("GOROOT %s is inside GOMODCACHE %s, where go build does not allow overlays; "+ + "install the Go version from go.mod instead of using a downloaded toolchain", goroot, gomodcache) + } + + dir, err = filepath.Abs(dir) + if err != nil { + return err + } + + //nolint:gosec // The directory is chosen by the caller of this build tool. + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + patched := filepath.Join(dir, "entropy_fips140.go") + //nolint:gosec // Build input, not a secret. + if err := os.WriteFile(patched, patchedSource, 0o644); err != nil { + return err + } + + overlay, err := json.MarshalIndent(map[string]map[string]string{ + "Replace": {target: patched}, + }, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(dir, "overlay.json"), append(overlay, '\n'), 0o644) //nolint:gosec // Build input, not a secret. +} + +func verify(path string) error { + f, err := pe.Open(path) + if err != nil { + return fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + + if len(f.Symbols) == 0 { + return fmt.Errorf("%s has no symbol table, cannot verify the overlay", path) + } + + for _, sym := range f.Symbols { + if sym.Name == bufferSymbol { + return fmt.Errorf("%s contains %s: the go-overlay was not applied", path, bufferSymbol) + } + } + + return nil +} + +func goEnv(ctx context.Context, name string) (string, error) { + var stderr bytes.Buffer + + cmd := exec.CommandContext(ctx, "go", "env", name) + cmd.Stderr = &stderr + + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("go env %s: %w: %s", name, err, stderr.String()) + } + + return strings.TrimSpace(string(out)), nil +} + +func isWithin(path, dir string) bool { + rel, err := filepath.Rel(dir, path) + + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +}