From 486daf9a0b5aa802a252a06d2fe0d2f45c56bbbd Mon Sep 17 00:00:00 2001 From: cppla Date: Fri, 9 Oct 2026 18:01:19 +0800 Subject: [PATCH] Record replacement-aware provenance for pilot binaries --- .github/workflows/ci.yml | 4 +- Makefile | 2 +- docs/STEALTH-PILOT.md | 30 +- scripts/check-dependency-boundary.sh | 33 ++- scripts/stealth-build-info/main.go | 266 +++++++++++++++++ scripts/stealth-build-info/main_test.go | 376 ++++++++++++++++++++++++ scripts/stealth-pilot-config.py | 293 +++++++++++++++++- scripts/stealth-pilot.sh | 14 +- scripts/test_dependency_boundary.py | 65 ++++ 9 files changed, 1056 insertions(+), 27 deletions(-) create mode 100644 scripts/stealth-build-info/main.go create mode 100644 scripts/stealth-build-info/main_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8787a35..baba48b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,12 +55,14 @@ jobs: - name: Release contract regression tests if: matrix.go == '1.27.x' run: make release-recipe-check - - name: Browser runner diagnostic regression tests (offline) + - name: Browser and pilot diagnostic regression tests (offline) if: matrix.go == '1.27.x' run: | python3 -m unittest scripts/stealth-browser/test_run_cover.py python3 scripts/stealth-browser/run_cover.py --self-test python3 scripts/stealth-campaign.py --self-test + python3 scripts/stealth-pilot-config.py self-test + sh -n scripts/stealth-pilot.sh - name: Test with coverage run: go test -shuffle=on -count=1 -covermode=atomic -coverprofile=coverage.out ./... - name: Upload coverage diff --git a/Makefile b/Makefile index 886bad9..6687b2f 100644 --- a/Makefile +++ b/Makefile @@ -157,7 +157,7 @@ integration-netem: build stealth-tools-check: release-recipe-check $(GO) test -race ./scripts/stealth-probe - GOPROXY=off $(GO) test ./scripts/stealth-pilot + GOPROXY=off $(GO) test ./scripts/stealth-pilot ./scripts/stealth-build-info sh -n scripts/stealth-active.sh scripts/stealth-hysteria.sh scripts/stealth-passive.sh scripts/stealth-campaign-smoke.sh scripts/stealth-pilot.sh scripts/stealth-offline-container.sh scripts/stealth-full-lab.sh scripts/stealth-full-offline.sh sh -n scripts/check-stealth-hysteria-update-disabled.sh ./scripts/check-stealth-hysteria-update-disabled.sh diff --git a/docs/STEALTH-PILOT.md b/docs/STEALTH-PILOT.md index b9137d5..5f89ae7 100644 --- a/docs/STEALTH-PILOT.md +++ b/docs/STEALTH-PILOT.md @@ -43,9 +43,12 @@ The default pilot records two independent samples for each product in three - `parallel_20`: issue twenty concurrent 1 KiB requests through one client process, allowing each proxy to multiplex streams over its H3 connection. -New AutoCAR runs use `--transport h3` and `--h3-fingerprint chrome-2026-10`, -with `github.com/apernet/quic-go` pinned to -`v0.63.1-0.20261004180939-a10df75c260c`. +New AutoCAR runs use `--transport h3` and `--h3-fingerprint chrome-2026-10`. +The web-H3 module identity remains `github.com/apernet/quic-go`, but its +effective source is the reviewed `github.com/cppla/quic-go` replacement; the +original `require` version alone does not identify the code being tested. +Native transport and the standard H3 control still use official QUIC. +See [dependency maintenance](DEPENDENCY-MAINTENANCE.md) for the current pins. The baseline runs the pinned standard profile with Chrome QUIC parroting enabled, its `Gecko` mode disabled, and a real reverse-proxy masquerade. Both proxies fetch the same private deterministic HTTP origin. The H3 control fetches the equivalent @@ -55,8 +58,25 @@ The baseline stays frozen; it no longer shares AutoCAR's exact QUIC revision. New AutoCAR variant labels and effective descriptors record the current profile. Do not rewrite old preregistrations or result labels: earlier captures remain evidence only for their recorded source, dependency and profile. This update -does not enable TLS resumption or 0-RTT for the Chrome H3 client and does not -establish a passive-identification advantage. +does not opt this pilot into TLS resumption or 0-RTT and does not establish a +passive-identification advantage. Each workload starts a fresh client process; +this pilot does not exercise the separate `chrome-2026-10-resume` reconnect +mode or a real browser's warm connection. + +Before any server starts, the runner reads the two compiled Go binaries with +the offline `scripts/stealth-build-info` helper; it never executes a binary to +discover its dependencies. The retained `build-provenance.json` records their +SHA-256 hashes, compiler/target, command package, and requested versus effective +sources/checksums for the selected QUIC and uTLS modules. It omits arbitrary +build settings, linker arguments, local paths and unrelated dependencies. +The configuration generator validates this report against both binary hashes +and embeds it in the effective AutoCAR descriptor, whose checksum is frozen by +the campaign driver. Missing metadata, local replacements for those selected +modules, mismatched targets, unexpected fields or missing required modules fail +closed. The report describes embedded build metadata, not a signed build +attestation or a vulnerability scan. Hashes identify the binaries; metadata +alone does not prove source contents or describe build tags and experiments. +Historical descriptors and frozen results are not rewritten. ## Running diff --git a/scripts/check-dependency-boundary.sh b/scripts/check-dependency-boundary.sh index f202c0a..3c73461 100755 --- a/scripts/check-dependency-boundary.sh +++ b/scripts/check-dependency-boundary.sh @@ -138,10 +138,6 @@ if [[ -n ${local_replacements} ]]; then fi while IFS= read -r -d '' source_file; do - if grep -qE '["`]github\.com/cppla/(utls|quic-go)(/[^"`[:space:]]*)?["`]' "${source_file}"; then - echo "error: ${source_file#./} imports the replacement path directly; retain the original module import path" >&2 - status=1 - fi if imports=$(grep -nE "${FORBIDDEN_HYSTERIA_GO_PATTERN}" "${source_file}"); then echo "error: Go source references the prohibited Hysteria application module:" >&2 while IFS= read -r match; do @@ -159,12 +155,16 @@ done < <(find . -type f -name '*.go' ! -path './.git/*' -print0) # Go permits escaped interpreted-string import paths. Require their canonical # spelling too, so literal path checks cannot miss a second module identity. -# Tokenize only enough to distinguish import declarations from comments, -# ordinary strings and rune literals. This stays offline and does not run Go. -escaped_imports=$( - python3 - <<'PY' +# Check replacement identities only inside actual import declarations: metadata +# strings may name a source without importing it. The separate application-module +# whole-source prohibition above remains unchanged. This stays offline. +invalid_imports=$( + python3 - "${ALLOWED_UTLS_REPLACEMENT}" "${ALLOWED_WEB_QUIC_REPLACEMENT}" <<'PY' from pathlib import Path import re +import sys + +replacement_paths = sys.argv[1:] tokens = re.compile( r'//[^\n]*|/\*[\s\S]*?\*/|"(?:\\[\s\S]|[^"\\])*"|' @@ -175,7 +175,10 @@ tokens = re.compile( for path in Path(".").rglob("*.go"): if ".git" in path.parts or not path.is_file(): continue - source = path.read_text(encoding="utf-8") + # Preserve CR bytes so raw-string interpretation matches Go, not Python's + # universal-newline conversion to LF. + with path.open(encoding="utf-8", newline="") as handle: + source = handle.read() importing = grouped = False for match in tokens.finditer(source): token = match.group() @@ -191,14 +194,20 @@ for path in Path(".").rglob("*.go"): if token.startswith('"') and "\\" in token: line = source.count("\n", 0, match.start()) + 1 print(f"{path}:{line}: escaped import path") + else: + # Go discards carriage returns inside raw string literals. + imported = token[1:-1].replace("\r", "") if token.startswith(chr(96)) else token[1:-1] + if any(imported == name or imported.startswith(name + "/") for name in replacement_paths): + line = source.count("\n", 0, match.start()) + 1 + print(f"{path}:{line}: imports the replacement path directly; retain the original module import path") importing = grouped elif token == ")" or (token == ";" and not grouped): importing = False PY ) -if [[ -n ${escaped_imports} ]]; then - echo "error: Go import paths must use unescaped canonical spelling:" >&2 - printf '%s\n' "${escaped_imports}" >&2 +if [[ -n ${invalid_imports} ]]; then + echo "error: Go imports must retain original module identities and unescaped canonical spelling:" >&2 + printf '%s\n' "${invalid_imports}" >&2 status=1 fi diff --git a/scripts/stealth-build-info/main.go b/scripts/stealth-build-info/main.go new file mode 100644 index 0000000..47c4c00 --- /dev/null +++ b/scripts/stealth-build-info/main.go @@ -0,0 +1,266 @@ +// Command stealth-build-info reads, but never runs, two pilot binaries and +// emits a deliberately small allowlist of their Go build provenance. +package main + +import ( + "bytes" + "crypto/sha256" + "debug/buildinfo" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "regexp" + "runtime/debug" + "strings" +) + +const ( + maxBinaryBytes = 128 << 20 + officialQUIC = "github.com/quic-go/quic-go" + adapterQUIC = "github.com/apernet/quic-go" + uTLS = "github.com/refraction-networking/utls" +) + +var ( + goVersionPattern = regexp.MustCompile(`^go[0-9]+\.[0-9]+(?:\.[0-9]+)?(?:(?:rc|beta)[0-9]+)?$`) + targetPattern = regexp.MustCompile(`^[a-z0-9]+$`) + versionPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`) + selectedModules = []string{officialQUIC, adapterQUIC, uTLS} +) + +type moduleProvenance struct { + ModulePath string `json:"module_path"` + RequestedVersion string `json:"requested_version"` + SourcePath string `json:"source_path"` + SourceVersion string `json:"source_version"` + SourceSum string `json:"source_sum"` + Replaced bool `json:"replaced"` +} + +type binaryProvenance struct { + BinarySHA256 string `json:"binary_sha256"` + GoVersion string `json:"go_version"` + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + Package string `json:"package"` + Modules []moduleProvenance `json:"modules"` +} + +type provenance struct { + SchemaVersion int `json:"schema_version"` + Kind string `json:"kind"` + Autocar binaryProvenance `json:"autocar"` + Control binaryProvenance `json:"control"` +} + +type binarySpec struct { + path string + modules []string +} + +var ( + autocarSpec = binarySpec{"github.com/cppla/autocar/cmd/autocar", selectedModules} + controlSpec = binarySpec{"github.com/cppla/autocar/scripts/stealth-pilot", []string{officialQUIC}} +) + +// A repeat is rejected rather than silently selecting the last path supplied. +type uniquePathFlag struct { + value string + set bool +} + +func (f *uniquePathFlag) String() string { return "" } + +func (f *uniquePathFlag) Set(value string) error { + if f.set { + return errors.New("duplicate flag") + } + f.value, f.set = value, true + return nil +} + +func main() { + if err := run(os.Args[1:], os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "stealth-build-info:", err) + os.Exit(1) + } +} + +func run(args []string, stdout io.Writer) error { + var autocarPath, controlPath uniquePathFlag + flags := flag.NewFlagSet("stealth-build-info", flag.ContinueOnError) + // flag's default errors can echo argv, including sensitive paths or values. + flags.SetOutput(io.Discard) + flags.Var(&autocarPath, "autocar", "AutoCAR binary") + flags.Var(&controlPath, "control", "control binary") + if err := flags.Parse(args); err != nil { + return errors.New("invalid arguments; require --autocar --control ") + } + if flags.NArg() != 0 || autocarPath.value == "" || controlPath.value == "" { + return errors.New("require --autocar --control , without positional arguments") + } + autocar, err := inspectBinary(autocarPath.value, autocarSpec) + if err != nil { + return fmt.Errorf("autocar: %w", err) + } + control, err := inspectBinary(controlPath.value, controlSpec) + if err != nil { + return fmt.Errorf("control: %w", err) + } + report, err := makeProvenance(autocar, control) + if err != nil { + return err + } + if err := json.NewEncoder(stdout).Encode(report); err != nil { + return errors.New("cannot write provenance JSON") + } + return nil +} + +func makeProvenance(autocar, control binaryProvenance) (provenance, error) { + if autocar.GOOS != control.GOOS || autocar.GOARCH != control.GOARCH { + return provenance{}, errors.New("binary targets do not match") + } + if autocar.GoVersion != control.GoVersion { + return provenance{}, errors.New("binary Go versions do not match") + } + return provenance{SchemaVersion: 1, Kind: "autocar-pilot-build-provenance", Autocar: autocar, Control: control}, nil +} + +func inspectBinary(path string, spec binarySpec) (binaryProvenance, error) { + info, hash, err := readBinary(path) + if err != nil { + return binaryProvenance{}, err + } + result, err := inspectBuildInfo(info, spec) + if err != nil { + return binaryProvenance{}, err + } + result.BinarySHA256 = hash + return result, nil +} + +func readBinary(path string) (*debug.BuildInfo, string, error) { + // Check before opening, so ordinary FIFO/device/directory inputs cannot block + // the reader. Symlinks are rejected too: callers must provide a regular file. + before, err := os.Lstat(path) + if err != nil { + return nil, "", errors.New("cannot inspect binary") + } + if !before.Mode().IsRegular() || before.Size() <= 0 || before.Size() > maxBinaryBytes { + return nil, "", errors.New("binary must be a nonempty regular file of at most 128 MiB") + } + file, err := os.Open(path) + if err != nil { + return nil, "", errors.New("cannot open binary") + } + defer file.Close() + opened, err := file.Stat() + if err != nil || !os.SameFile(before, opened) || !opened.Mode().IsRegular() || opened.Size() != before.Size() { + return nil, "", errors.New("binary changed while opening") + } + data, err := io.ReadAll(io.LimitReader(file, maxBinaryBytes+1)) + if err != nil || int64(len(data)) != opened.Size() || len(data) > maxBinaryBytes { + return nil, "", errors.New("cannot read stable bounded binary") + } + info, err := buildinfo.Read(bytes.NewReader(data)) + if err != nil { + return nil, "", errors.New("binary has no readable Go build information") + } + hash := sha256.Sum256(data) + return info, hex.EncodeToString(hash[:]), nil +} + +func inspectBuildInfo(info *debug.BuildInfo, spec binarySpec) (binaryProvenance, error) { + if info == nil || info.Path != spec.path { + return binaryProvenance{}, errors.New("unexpected command package") + } + if !goVersionPattern.MatchString(info.GoVersion) { + return binaryProvenance{}, errors.New("invalid or development Go version") + } + result := binaryProvenance{GoVersion: info.GoVersion, Package: info.Path} + target := make(map[string]string, 2) + for _, setting := range info.Settings { + if setting.Key != "GOOS" && setting.Key != "GOARCH" { + continue + } + if _, exists := target[setting.Key]; exists || !targetPattern.MatchString(setting.Value) { + return binaryProvenance{}, errors.New("invalid or duplicate target build setting") + } + target[setting.Key] = setting.Value + } + result.GOOS, result.GOARCH = target["GOOS"], target["GOARCH"] + if result.GOOS == "" || result.GOARCH == "" { + return binaryProvenance{}, errors.New("missing target build setting") + } + + selected := make(map[string]*debug.Module, len(spec.modules)) + for _, dep := range info.Deps { + if dep == nil { + return binaryProvenance{}, errors.New("invalid dependency build information") + } + for _, path := range selectedModules { + if dep.Path != path { + continue + } + if _, exists := selected[path]; exists { + return binaryProvenance{}, errors.New("duplicate selected dependency") + } + selected[path] = dep + } + } + if len(selected) != len(spec.modules) { + return binaryProvenance{}, errors.New("missing or unexpected selected dependency") + } + for _, path := range spec.modules { + dep, exists := selected[path] + if !exists { + return binaryProvenance{}, errors.New("missing selected dependency") + } + module, err := inspectModule(dep) + if err != nil { + return binaryProvenance{}, err + } + result.Modules = append(result.Modules, module) + } + return result, nil +} + +func inspectModule(dep *debug.Module) (moduleProvenance, error) { + if !versionPattern.MatchString(dep.Version) { + return moduleProvenance{}, errors.New("selected dependency has no valid requested version") + } + source := dep + if dep.Replace != nil { + if dep.Path == officialQUIC { + return moduleProvenance{}, errors.New("official QUIC dependency must not be replaced") + } + source = dep.Replace + if source.Replace != nil { + return moduleProvenance{}, errors.New("nested dependency replacement is unsupported") + } + } + allowedSource := source.Path == dep.Path || + (dep.Path == adapterQUIC && source.Path == "github.com/cppla/quic-go") || + (dep.Path == uTLS && source.Path == "github.com/cppla/utls") + if !allowedSource || !versionPattern.MatchString(source.Version) { + return moduleProvenance{}, errors.New("selected dependency has an unsupported or unversioned source") + } + if !strings.HasPrefix(source.Sum, "h1:") { + return moduleProvenance{}, errors.New("selected dependency has no valid source sum") + } + sum, err := base64.StdEncoding.Strict().DecodeString(strings.TrimPrefix(source.Sum, "h1:")) + if err != nil || len(sum) != sha256.Size || "h1:"+base64.StdEncoding.EncodeToString(sum) != source.Sum { + return moduleProvenance{}, errors.New("selected dependency has no valid source sum") + } + return moduleProvenance{ + ModulePath: dep.Path, RequestedVersion: dep.Version, + SourcePath: source.Path, SourceVersion: source.Version, SourceSum: source.Sum, + Replaced: dep.Replace != nil, + }, nil +} diff --git a/scripts/stealth-build-info/main_test.go b/scripts/stealth-build-info/main_test.go new file mode 100644 index 0000000..74e215b --- /dev/null +++ b/scripts/stealth-build-info/main_test.go @@ -0,0 +1,376 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "runtime/debug" + "slices" + "strings" + "testing" + "time" +) + +func fixtureBuildInfo(spec binarySpec) *debug.BuildInfo { + info := &debug.BuildInfo{ + GoVersion: "go1.27.2", + Path: spec.path, + Main: debug.Module{Path: "github.com/cppla/autocar", Version: "(devel)"}, + Settings: []debug.BuildSetting{ + {Key: "GOOS", Value: "linux"}, + {Key: "GOARCH", Value: "arm64"}, + }, + } + for _, path := range spec.modules { + info.Deps = append(info.Deps, &debug.Module{Path: path, Version: "v0.63.0", Sum: fixtureSum(path)}) + } + return info +} + +func fixtureSum(value string) string { + hash := sha256.Sum256([]byte(value)) + return "h1:" + base64.StdEncoding.EncodeToString(hash[:]) +} + +func TestInspectBuildInfoReplacement(t *testing.T) { + info := fixtureBuildInfo(autocarSpec) + info.Deps[1].Replace = &debug.Module{ + Path: "github.com/cppla/quic-go", Version: "v0.63.1-0.20261009040133-c1cae948af15", Sum: fixtureSum("fork-quic"), + } + info.Deps[2].Replace = &debug.Module{ + Path: "github.com/cppla/utls", Version: "v0.0.0-20261009031926-14c2a4cb1403", Sum: fixtureSum("fork-utls"), + } + // The original module checksum can be absent when Go records a replacement. + info.Deps[1].Sum, info.Deps[2].Sum = "", "" + // Output order is fixed, not dependent on the build-info dependency ordering. + slices.Reverse(info.Deps) + got, err := inspectBuildInfo(info, autocarSpec) + if err != nil { + t.Fatal(err) + } + if len(got.Modules) != 3 || got.Package != autocarSpec.path || got.GoVersion != "go1.27.2" || got.GOOS != "linux" || got.GOARCH != "arm64" { + t.Fatalf("unexpected binary provenance: %+v", got) + } + for i, path := range selectedModules { + if got.Modules[i].ModulePath != path || got.Modules[i].RequestedVersion != "v0.63.0" { + t.Fatalf("unexpected requested module: %+v", got.Modules[i]) + } + } + if got.Modules[0].Replaced || got.Modules[0].SourcePath != officialQUIC || got.Modules[0].SourceVersion != "v0.63.0" { + t.Fatalf("native module must be unreplaced: %+v", got.Modules[0]) + } + for i, original := range []*debug.Module{info.Deps[1], info.Deps[0]} { + module := got.Modules[i+1] + if !module.Replaced || module.SourcePath != original.Replace.Path || module.SourceVersion != original.Replace.Version || module.SourceSum != original.Replace.Sum { + t.Fatalf("replacement provenance lost: %+v", module) + } + } +} + +func TestInspectBuildInfoAllowedSourceForms(t *testing.T) { + for _, path := range []string{adapterQUIC, uTLS} { + t.Run(path, func(t *testing.T) { + dep := &debug.Module{Path: path, Version: "v1.2.3", Sum: fixtureSum("original")} + original, err := inspectModule(dep) + if err != nil || original.Replaced || original.SourcePath != path || original.SourceSum != dep.Sum { + t.Fatalf("original source: %+v, %v", original, err) + } + dep.Replace = &debug.Module{Path: path, Version: "v1.2.4", Sum: fixtureSum("new-version")} + replaced, err := inspectModule(dep) + if err != nil || !replaced.Replaced || replaced.SourcePath != path || replaced.SourceVersion != "v1.2.4" { + t.Fatalf("same-path version replacement: %+v, %v", replaced, err) + } + }) + } +} + +func TestInspectBuildInfoRejectsInvalidMetadata(t *testing.T) { + tests := []struct { + name string + edit func(*debug.BuildInfo) + }{ + {"wrong package", func(b *debug.BuildInfo) { b.Path = "private-secret/path" }}, + {"missing Go version", func(b *debug.BuildInfo) { b.GoVersion = "" }}, + {"development Go version", func(b *debug.BuildInfo) { b.GoVersion = "devel go1.28-secret" }}, + {"missing GOOS", func(b *debug.BuildInfo) { b.Settings = b.Settings[1:] }}, + {"missing GOARCH", func(b *debug.BuildInfo) { b.Settings = b.Settings[:1] }}, + {"duplicate GOOS", func(b *debug.BuildInfo) { b.Settings = append(b.Settings, b.Settings[0]) }}, + {"duplicate GOARCH", func(b *debug.BuildInfo) { b.Settings = append(b.Settings, b.Settings[1]) }}, + {"empty target", func(b *debug.BuildInfo) { b.Settings[0].Value = "" }}, + {"unsafe target", func(b *debug.BuildInfo) { b.Settings[1].Value = "secret/../../path" }}, + {"missing dependency", func(b *debug.BuildInfo) { b.Deps = b.Deps[:2] }}, + {"duplicate dependency", func(b *debug.BuildInfo) { b.Deps = append(b.Deps, b.Deps[0]) }}, + {"nil dependency", func(b *debug.BuildInfo) { b.Deps = append(b.Deps, nil) }}, + {"unversioned request", func(b *debug.BuildInfo) { b.Deps[1].Version = "" }}, + {"development request", func(b *debug.BuildInfo) { b.Deps[1].Version = "(devel)" }}, + {"invalid requested version", func(b *debug.BuildInfo) { b.Deps[1].Version = "v1.2-secret" }}, + {"missing source sum", func(b *debug.BuildInfo) { b.Deps[0].Sum = "" }}, + {"malformed source sum", func(b *debug.BuildInfo) { b.Deps[0].Sum = "h1:secret" }}, + {"wrong-size source sum", func(b *debug.BuildInfo) { b.Deps[0].Sum = "h1:c2VjcmV0" }}, + {"sum with newline", func(b *debug.BuildInfo) { b.Deps[0].Sum += "\n" }}, + {"native replacement", func(b *debug.BuildInfo) { + b.Deps[0].Replace = &debug.Module{Path: officialQUIC, Version: "v0.63.1", Sum: fixtureSum("native")} + }}, + {"local replacement", func(b *debug.BuildInfo) { b.Deps[1].Replace = &debug.Module{Path: "/private/secret/quic-go"} }}, + {"relative replacement", func(b *debug.BuildInfo) { b.Deps[1].Replace = &debug.Module{Path: "../secret"} }}, + {"unversioned replacement", func(b *debug.BuildInfo) { + b.Deps[1].Replace = &debug.Module{Path: "github.com/cppla/quic-go", Sum: fixtureSum("fork")} + }}, + {"development replacement", func(b *debug.BuildInfo) { + b.Deps[1].Replace = &debug.Module{Path: "github.com/cppla/quic-go", Version: "(devel)", Sum: fixtureSum("fork")} + }}, + {"replacement missing sum", func(b *debug.BuildInfo) { + b.Deps[1].Replace = &debug.Module{Path: "github.com/cppla/quic-go", Version: "v0.63.1"} + }}, + {"unexpected replacement source", func(b *debug.BuildInfo) { + b.Deps[1].Replace = &debug.Module{Path: "github.com/private-secret/quic-go", Version: "v0.63.1", Sum: fixtureSum("fork")} + }}, + {"nested replacement", func(b *debug.BuildInfo) { + b.Deps[1].Replace = &debug.Module{Path: adapterQUIC, Version: "v0.63.1", Sum: fixtureSum("fork"), Replace: &debug.Module{Path: "private-secret"}} + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + info := fixtureBuildInfo(autocarSpec) + test.edit(info) + got, err := inspectBuildInfo(info, autocarSpec) + if err == nil { + t.Fatalf("accepted invalid metadata: %+v", got) + } + if strings.Contains(err.Error(), "secret") { + t.Fatalf("error exposed metadata: %v", err) + } + }) + } + if _, err := inspectBuildInfo(nil, autocarSpec); err == nil { + t.Fatal("accepted nil build info") + } +} + +func TestControlRequiresOnlyOfficialSelectedDependency(t *testing.T) { + info := fixtureBuildInfo(controlSpec) + got, err := inspectBuildInfo(info, controlSpec) + if err != nil || len(got.Modules) != 1 || got.Modules[0].ModulePath != officialQUIC { + t.Fatalf("invalid control result: %+v, %v", got, err) + } + info.Deps = append(info.Deps, &debug.Module{Path: adapterQUIC, Version: "v0.63.1", Sum: fixtureSum("unexpected")}) + if _, err := inspectBuildInfo(info, controlSpec); err == nil { + t.Fatal("accepted adapter-linked control binary") + } +} + +func TestProvenanceRedactsEverythingOutsideAllowlist(t *testing.T) { + info := fixtureBuildInfo(autocarSpec) + info.Main = debug.Module{Path: "/private/secret/main", Version: "secret-main-version", Sum: "secret-main-sum"} + info.Deps = append(info.Deps, &debug.Module{Path: "secret-unrelated-module", Version: "secret-version", Replace: &debug.Module{Path: "/secret/local/path"}}) + info.Settings = append(info.Settings, + debug.BuildSetting{Key: "-ldflags", Value: "-X secret-token=secret-value"}, + debug.BuildSetting{Key: "vcs.revision", Value: "secret-revision"}, + debug.BuildSetting{Key: "vcs.modified", Value: "true"}, + debug.BuildSetting{Key: "secret-setting", Value: "secret-setting-value"}, + ) + autocar, err := inspectBuildInfo(info, autocarSpec) + if err != nil { + t.Fatal(err) + } + control, err := inspectBuildInfo(fixtureBuildInfo(controlSpec), controlSpec) + if err != nil { + t.Fatal(err) + } + autocar.BinarySHA256, control.BinarySHA256 = strings.Repeat("a", 64), strings.Repeat("b", 64) + report, err := makeProvenance(autocar, control) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + for _, excluded := range []string{"secret", "argv", "ldflags", "vcs", "Main", "Settings"} { + if bytes.Contains(data, []byte(excluded)) { + t.Fatalf("output exposed %q: %s", excluded, data) + } + } + var decoded map[string]json.RawMessage + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + assertKeys(t, decoded, "schema_version", "kind", "autocar", "control") + if string(decoded["schema_version"]) != "1" || string(decoded["kind"]) != `"autocar-pilot-build-provenance"` { + t.Fatalf("wrong schema: %s", data) + } + for _, name := range []string{"autocar", "control"} { + var binary map[string]json.RawMessage + if err := json.Unmarshal(decoded[name], &binary); err != nil { + t.Fatal(err) + } + assertKeys(t, binary, "binary_sha256", "go_version", "goos", "goarch", "package", "modules") + var modules []map[string]json.RawMessage + if err := json.Unmarshal(binary["modules"], &modules); err != nil { + t.Fatal(err) + } + for _, module := range modules { + assertKeys(t, module, "module_path", "requested_version", "source_path", "source_version", "source_sum", "replaced") + } + } +} + +func assertKeys(t *testing.T, value map[string]json.RawMessage, want ...string) { + t.Helper() + got := make([]string, 0, len(value)) + for key := range value { + got = append(got, key) + } + slices.Sort(got) + slices.Sort(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected keys: got %v, want %v", got, want) + } +} + +func TestProvenanceRequiresMatchingCompilerAndTarget(t *testing.T) { + autocar, err := inspectBuildInfo(fixtureBuildInfo(autocarSpec), autocarSpec) + if err != nil { + t.Fatal(err) + } + control, err := inspectBuildInfo(fixtureBuildInfo(controlSpec), controlSpec) + if err != nil { + t.Fatal(err) + } + for _, edit := range []func(*binaryProvenance){ + func(b *binaryProvenance) { b.GOOS = "darwin" }, + func(b *binaryProvenance) { b.GOARCH = "amd64" }, + func(b *binaryProvenance) { b.GoVersion = "go1.27.3" }, + } { + changed := control + edit(&changed) + if _, err := makeProvenance(autocar, changed); err == nil { + t.Fatal("accepted mismatched binaries") + } + } +} + +func TestRunRejectsArgumentsWithoutLeakingValues(t *testing.T) { + for _, args := range [][]string{ + nil, + {"--secret-flag=secret-value"}, + {"--autocar", "secret-first", "--autocar", "secret-second", "--control", "secret-control"}, + {"--autocar", "secret-first", "--control", "secret-control", "secret-positional"}, + {"--autocar", "secret-first"}, + {"--autocar", "", "--control", "secret-control"}, + {"--autocar", "/nonexistent/secret-binary", "--control", "secret-control"}, + } { + var output bytes.Buffer + err := run(args, &output) + if err == nil || output.Len() != 0 { + t.Fatalf("invalid CLI emitted output or succeeded: %q, %v", output.String(), err) + } + if strings.Contains(err.Error(), "secret") { + t.Fatalf("error exposed arguments: %v", err) + } + } +} + +func TestReadBinaryRejectsInvalidFiles(t *testing.T) { + dir := t.TempDir() + for _, test := range []struct { + name string + data []byte + }{{"empty-secret", nil}, {"invalid-secret", []byte("not a Go binary")}} { + path := filepath.Join(dir, test.name) + if err := os.WriteFile(path, test.data, 0600); err != nil { + t.Fatal(err) + } + if _, _, err := readBinary(path); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("invalid file accepted or path leaked: %v", err) + } + } + if _, _, err := readBinary(dir); err == nil { + t.Fatal("accepted directory") + } + large := filepath.Join(dir, "oversized-secret") + file, err := os.Create(large) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(maxBinaryBytes + 1); err != nil { + file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + if _, _, err := readBinary(large); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("oversized file accepted or path leaked: %v", err) + } + t.Run("symlink", func(t *testing.T) { + link := filepath.Join(dir, "linked-secret") + if err := os.Symlink(filepath.Join(dir, "invalid-secret"), link); err != nil { + t.Skip("platform does not allow creating test symlink") + } + if _, _, err := readBinary(link); err == nil { + t.Fatal("accepted symlink") + } + }) +} + +func TestReadRealCompiledBinaryOfflineWithoutExecuting(t *testing.T) { + dir := t.TempDir() + commandDir := filepath.Join(dir, "cmd", "autocar") + if err := os.MkdirAll(commandDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module github.com/cppla/autocar\n\ngo 1.27.2\n"), 0600); err != nil { + t.Fatal(err) + } + marker := filepath.Join(dir, "must-not-run") + source := fmt.Sprintf("package main\nimport \"os\"\nfunc main() { _ = os.WriteFile(%q, []byte(\"executed\"), 0600); panic(\"must not execute\") }\n", marker) + if err := os.WriteFile(filepath.Join(commandDir, "main.go"), []byte(source), 0600); err != nil { + t.Fatal(err) + } + binary := filepath.Join(dir, "fixture-binary") + goBinary := filepath.Join(runtime.GOROOT(), "bin", "go") + if runtime.GOOS == "windows" { + goBinary += ".exe" + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + command := exec.CommandContext(ctx, goBinary, "build", "-trimpath", "-ldflags=-s -w", "-o", binary, "./cmd/autocar") + command.Dir = dir + command.Env = append(os.Environ(), "GOPROXY=off", "GOSUMDB=off", "GOTOOLCHAIN=local", "GOWORK=off", "GOFLAGS=", "GOENV=off", "CGO_ENABLED=0", "GOOS="+runtime.GOOS, "GOARCH="+runtime.GOARCH) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("offline fixture build failed: %v\n%s", err, output) + } + info, hash, err := readBinary(binary) + if err != nil { + t.Fatal(err) + } + if info.Path != autocarSpec.path || !goVersionPattern.MatchString(info.GoVersion) || info.Main.Version != "(devel)" { + t.Fatalf("wrong real build metadata: %+v", info) + } + data, err := os.ReadFile(binary) + if err != nil { + t.Fatal(err) + } + wantHash := sha256.Sum256(data) + if hash != hex.EncodeToString(wantHash[:]) { + t.Fatal("binary hash does not cover the actual file") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("fixture was executed or marker inspection failed: %v", err) + } + // This offline standard-library-only fixture must not masquerade as a pilot + // binary: a correct command path alone cannot satisfy dependency validation. + if _, err := inspectBinary(binary, autocarSpec); err == nil { + t.Fatal("accepted binary missing required dependencies") + } +} diff --git a/scripts/stealth-pilot-config.py b/scripts/stealth-pilot-config.py index 9403a57..2d5f90c 100755 --- a/scripts/stealth-pilot-config.py +++ b/scripts/stealth-pilot-config.py @@ -9,6 +9,9 @@ from __future__ import annotations import argparse +import base64 +import binascii +import copy import csv import hashlib import ipaddress @@ -24,6 +27,28 @@ WORKLOADS = ("idle", "download_1k", "parallel_20") NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}") USER_RE = re.compile(r"[1-9][0-9]*(?::[1-9][0-9]*)?") +BUILD_INFO_LIMIT = 32 << 10 +BUILD_INFO_KIND = "autocar-pilot-build-provenance" +SHA256_RE = re.compile(r"[0-9a-f]{64}") +GO_VERSION_RE = re.compile(r"go[0-9]+\.[0-9]+(?:\.[0-9]+)?(?:(?:rc|beta)[0-9]+)?") +MODULE_VERSION_RE = re.compile( + r"v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" + r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" +) +NATIVE_QUIC = "github.com/quic-go/quic-go" +WEB_QUIC = "github.com/apernet/quic-go" +UTLS = "github.com/refraction-networking/utls" +MODULE_SOURCES = { + NATIVE_QUIC: (NATIVE_QUIC,), + WEB_QUIC: (WEB_QUIC, "github.com/cppla/quic-go"), + UTLS: (UTLS, "github.com/cppla/utls"), +} +BUILD_MODULES = {"autocar": (NATIVE_QUIC, WEB_QUIC, UTLS), "control": (NATIVE_QUIC,)} +BUILD_PACKAGES = { + "autocar": "github.com/cppla/autocar/cmd/autocar", + "control": "github.com/cppla/autocar/scripts/stealth-pilot", +} def parser() -> argparse.ArgumentParser: @@ -37,7 +62,7 @@ def parser() -> argparse.ArgumentParser: "autocar-container", "autocar-ip", "hysteria-container", "hysteria-ip", "origin-ip", "cert", "key", "token", "autocar-binary", "hysteria-binary", "hysteria-client-config", "hysteria-server-config", "autocar-version", - "hysteria-version", "hysteria-expected-sha256", + "hysteria-version", "hysteria-expected-sha256", "build-info", "control-binary", ): prepare.add_argument("--" + name, required=True) @@ -83,7 +108,7 @@ def prepare(args: argparse.Namespace) -> None: name: under(root, Path(getattr(args, name)).resolve(strict=True), name) for name in ( "cert", "key", "token", "autocar_binary", "hysteria_binary", - "hysteria_client_config", "hysteria_server_config", + "hysteria_client_config", "hysteria_server_config", "build_info", "control_binary", ) } for path in files.values(): @@ -101,6 +126,10 @@ def prepare(args: argparse.Namespace) -> None: if hysteria_hash != args.hysteria_expected_sha256: fail("Hysteria binary does not match the pinned official checksum") + build_provenance = read_build_provenance( + files["build_info"], files["autocar_binary"], files["control_binary"] + ) + control_version = build_provenance["control"]["modules"][0]["source_version"] auth_hash = sha256(files["token"]) autocar_effective = { "schema_version": 1, @@ -108,6 +137,7 @@ def prepare(args: argparse.Namespace) -> None: "product": "autocar", "implementation_version": args.autocar_version, "binary_sha256": sha256(files["autocar_binary"]), + "build_provenance": build_provenance, "transport": "h3", "h3_fingerprint": "chrome-2026-10", "relay": f"{endpoints['autocar']}:8443", @@ -172,9 +202,9 @@ def prepare(args: argparse.Namespace) -> None: "products": { "cover": {"variants": [{ "name": "quic-go-standard-h3-control", - "client_implementation": "quic-go v0.63.0 standard H3 control (not a browser)", - "server_implementation": "quic-go v0.63.0 HTTP/3 fixture", - "implementation_version": "v0.63.0-calibration-control", + "client_implementation": f"quic-go {control_version} standard H3 control (not a browser)", + "server_implementation": f"quic-go {control_version} HTTP/3 fixture", + "implementation_version": f"{control_version}-calibration-control", "real_browser": False, "runner_image": args.runner_image, "server_container": args.control_container, @@ -430,13 +460,109 @@ def read_json(path: Path) -> Dict[str, Any]: return value +def exact_build_keys(value: Any, keys: Iterable[str]) -> None: + # Never echo unrecognized keys or values: this report is retained publicly. + if not isinstance(value, dict) or set(value) != set(keys): + fail("build provenance contains an invalid object or unexpected fields") + + +def unique_build_object(pairs: Iterable[tuple[str, Any]]) -> Dict[str, Any]: + value: Dict[str, Any] = {} + for key, item in pairs: + if key in value: + fail("build provenance contains duplicate JSON fields") + value[key] = item + return value + + +def require_build_string(value: Any, pattern: re.Pattern[str]) -> None: + if not isinstance(value, str) or len(value) > 256 or not pattern.fullmatch(value): + fail("build provenance contains an invalid version or digest") + + +def require_module_sum(value: Any) -> None: + if not isinstance(value, str) or not value.startswith("h1:") or len(value) != 47: + fail("build provenance contains an invalid module checksum") + try: + digest = base64.b64decode(value[3:], validate=True) + except (binascii.Error, ValueError): + fail("build provenance contains an invalid module checksum") + if len(digest) != 32 or base64.b64encode(digest).decode("ascii") != value[3:]: + fail("build provenance contains a non-canonical module checksum") + + +def read_build_provenance(path: Path, autocar_binary: Path, control_binary: Path) -> Dict[str, Any]: + with path.open("rb") as handle: + encoded = handle.read(BUILD_INFO_LIMIT + 1) + if len(encoded) > BUILD_INFO_LIMIT: + fail("build provenance exceeds the 32 KiB input limit") + try: + report = json.loads(encoded.decode("utf-8"), object_pairs_hook=unique_build_object) + except (UnicodeDecodeError, ValueError, RecursionError): + fail("build provenance is not valid bounded JSON") + exact_build_keys(report, ("schema_version", "kind", "autocar", "control")) + if type(report["schema_version"]) is not int or report["schema_version"] != 1 or report["kind"] != BUILD_INFO_KIND: + fail("build provenance schema or kind is unsupported") + sanitized: Dict[str, Any] = {"schema_version": 1, "kind": BUILD_INFO_KIND} + for role, binary in (("autocar", autocar_binary), ("control", control_binary)): + record = report[role] + exact_build_keys(record, ( + "binary_sha256", "go_version", "goos", "goarch", "package", "modules", + )) + require_build_string(record["binary_sha256"], SHA256_RE) + require_build_string(record["go_version"], GO_VERSION_RE) + if record["binary_sha256"] != sha256(binary): + fail("build provenance binary checksum does not match the supplied binary") + if record["goos"] != "linux" or record["goarch"] not in ("amd64", "arm64"): + fail("build provenance must describe a supported Linux pilot binary") + if record["package"] != BUILD_PACKAGES[role]: + fail("build provenance describes an unexpected command package") + modules = record["modules"] + if not isinstance(modules, list) or len(modules) != len(BUILD_MODULES[role]): + fail("build provenance has an incomplete module set") + validated = {} + for module in modules: + exact_build_keys(module, ( + "module_path", "requested_version", "source_path", "source_version", "source_sum", "replaced", + )) + identity = module["module_path"] + if not isinstance(identity, str) or identity not in BUILD_MODULES[role] or identity in validated: + fail("build provenance has duplicate or unexpected module identities") + if module["source_path"] not in MODULE_SOURCES[identity]: + fail("build provenance module source is not an approved remote path") + require_build_string(module["requested_version"], MODULE_VERSION_RE) + require_build_string(module["source_version"], MODULE_VERSION_RE) + require_module_sum(module["source_sum"]) + if type(module["replaced"]) is not bool: + fail("build provenance replacement flag must be a boolean") + if identity == NATIVE_QUIC and module["replaced"]: + fail("build provenance may not replace native official QUIC") + if not module["replaced"] and ( + module["source_path"] != identity or module["source_version"] != module["requested_version"] + ): + fail("build provenance has inconsistent non-replaced module metadata") + validated[identity] = dict(module) + if set(validated) != set(BUILD_MODULES[role]): + fail("build provenance has an incomplete module set") + sanitized[role] = { + "binary_sha256": record["binary_sha256"], "go_version": record["go_version"], + "goos": record["goos"], "goarch": record["goarch"], "package": record["package"], + "modules": [validated[identity] for identity in BUILD_MODULES[role]], + } + if any(sanitized["autocar"][key] != sanitized["control"][key] for key in ("go_version", "goos", "goarch")): + fail("build provenance binaries must use the same Go version and target") + if sanitized["autocar"]["modules"][0] != sanitized["control"]["modules"][0]: + fail("build provenance binaries must use the same official QUIC dependency") + return sanitized + + def self_test() -> None: with tempfile.TemporaryDirectory(prefix="stealth-pilot-config-test.") as directory: root = Path(directory) secret = "this-value-must-not-appear" for name, content in { "cert.pem": "certificate", "key.pem": "private-key", "token": secret, - "autocar": "autocar-binary", "hysteria": "hysteria-binary", + "autocar": "autocar-binary", "hysteria": "hysteria-binary", "stealth-pilot": "control-binary", "hysteria-client.yaml": f"auth: {secret}", "hysteria-server.yaml": f"password: {secret}", }.items(): @@ -452,11 +578,14 @@ def self_test() -> None: hysteria_container="hysteria", hysteria_ip="10.242.1.30", origin_ip="10.242.1.40", cert=str(root / "cert.pem"), key=str(root / "key.pem"), token=str(root / "token"), autocar_binary=str(root / "autocar"), hysteria_binary=str(root / "hysteria"), + control_binary=str(root / "stealth-pilot"), build_info=str(root / "build-info.json"), hysteria_client_config=str(root / "hysteria-client.yaml"), hysteria_server_config=str(root / "hysteria-server.yaml"), autocar_version="v1.0.1-self-test", hysteria_version="v2.12.2-619a6f8", hysteria_expected_sha256=sha256(root / "hysteria"), ) + provenance = self_test_build_provenance(root) + write_json(Path(args.build_info), provenance) prepare(args) retained = "".join( (root / name).read_text(encoding="utf-8") @@ -468,6 +597,8 @@ def self_test() -> None: effective = read_json(root / "autocar-effective.json") if effective["h3_fingerprint"] != "chrome-2026-10": fail("self-test found an incorrect AutoCAR H3 profile") + if effective["build_provenance"] != provenance: + fail("self-test did not retain validated replacement-aware provenance") autocar = generated["products"]["autocar"]["variants"][0] if ( autocar["name"] != "autocar-chrome-2026-10-h3" @@ -484,9 +615,159 @@ def self_test() -> None: networks.write_text(json.dumps([{"IPAM": {"Config": [{"Subnet": "10.242.64.0/24"}]}}]), encoding="utf-8") if select_subnet(str(networks)) != "10.242.65.0/24": fail("self-test subnet selection was not deterministic") + self_test_build_rejections(root, args, provenance) print(json.dumps({"schema_version": 1, "status": "pass", "self_test": True})) +def self_test_build_provenance(root: Path) -> Dict[str, Any]: + report: Dict[str, Any] = {"schema_version": 1, "kind": BUILD_INFO_KIND} + for role, filename in (("autocar", "autocar"), ("control", "stealth-pilot")): + report[role] = { + "binary_sha256": sha256(root / filename), "go_version": "go1.27.2", + "goos": "linux", "goarch": "arm64", "package": BUILD_PACKAGES[role], + "modules": [{ + "module_path": identity, "requested_version": "v0.63.0" if identity == NATIVE_QUIC else "v1.2.3", + "source_path": MODULE_SOURCES[identity][-1], + "source_version": "v0.63.0" if identity == NATIVE_QUIC else "v0.0.0-20261009040133-c1cae948af15", + "source_sum": "h1:" + base64.b64encode(hashlib.sha256(identity.encode()).digest()).decode("ascii"), + "replaced": identity != NATIVE_QUIC, + } for identity in BUILD_MODULES[role]], + } + return report + + +def self_test_build_rejections(root: Path, args: argparse.Namespace, valid: Dict[str, Any]) -> None: + report_path = Path(args.build_info) + + def reject(report: Any) -> None: + write_json(report_path, report) + outputs = [Path(args.output), Path(args.autocar_effective_output), Path(args.hysteria_effective_output)] + before = [path.read_bytes() for path in outputs] + try: + prepare(args) + except SystemExit: + if before != [path.read_bytes() for path in outputs]: + raise AssertionError("invalid build provenance changed retained outputs") + return + raise AssertionError("invalid build provenance was accepted") + + def changed(path: tuple[Any, ...], value: Any) -> Dict[str, Any]: + report = copy.deepcopy(valid) + target = report + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + return report + + for path, value in ( + (("schema_version",), True), (("schema_version",), 1.0), (("schema_version",), 2), (("kind",), "other"), + (("secret",), "PRIVATE_SECRET"), (("autocar", "secret"), "PRIVATE_SECRET"), + (("autocar", "modules", 1, "secret"), "PRIVATE_SECRET"), + (("autocar", "binary_sha256"), "0" * 64), (("control", "binary_sha256"), "0" * 64), + (("autocar", "binary_sha256"), "A" * 64), (("autocar", "binary_sha256"), 12), + (("autocar", "go_version"), "devel"), (("control", "go_version"), "go1.26.1"), + (("autocar", "goos"), "darwin"), (("autocar", "goarch"), "386"), + (("control", "goarch"), "amd64"), (("autocar", "package"), BUILD_PACKAGES["control"]), + (("autocar", "modules"), {}), (("autocar", "modules"), valid["autocar"]["modules"][:-1]), + (("autocar", "modules", 1), valid["autocar"]["modules"][0]), + (("autocar", "modules", 1, "module_path"), "github.com/cppla/quic-go"), + (("autocar", "modules", 1, "module_path"), []), + (("autocar", "modules", 1, "source_path"), "/tmp/local-quic"), + (("autocar", "modules", 1, "source_path"), "github.com/unapproved/quic-go"), + (("autocar", "modules", 1, "source_path"), "github.com/cppla/utls"), + (("autocar", "modules", 1, "source_version"), ""), + (("autocar", "modules", 1, "source_version"), "main"), + (("autocar", "modules", 1, "requested_version"), "(devel)"), + (("autocar", "modules", 1, "replaced"), 1), + (("autocar", "modules", 1, "replaced"), False), + (("autocar", "modules", 0, "replaced"), True), + (("autocar", "modules", 0, "source_version"), "v0.62.0"), + (("autocar", "modules", 1, "source_sum"), "h1:" + "A" * 43 + "!"), + (("autocar", "modules", 1, "source_sum"), "h1:" + "A" * 42 + "B="), + (("autocar", "modules", 1, "source_sum"), "PRIVATE_SECRET"), + (("control", "modules", 0, "source_sum"), "h1:" + "A" * 43 + "="), + ): + reject(changed(path, value)) + missing = copy.deepcopy(valid) + del missing["autocar"]["go_version"] + reject(missing) + missing = copy.deepcopy(valid) + del missing["control"] + reject(missing) + different_native = copy.deepcopy(valid) + different_native["control"]["modules"][0].update(requested_version="v0.64.0", source_version="v0.64.0") + reject(different_native) + encoded = json.dumps(valid).encode("utf-8") + for malformed in ( + b" " * (BUILD_INFO_LIMIT + 1), b"\xff", b"{", b"[]", + b'{"schema_version":1,"schema_version":1}', + encoded.replace(b'"modules":', b'"modules":[],"modules":', 1), + ): + report_path.write_bytes(malformed) + try: + read_build_provenance(report_path, root / "autocar", root / "stealth-pilot") + except SystemExit: + pass + else: + raise AssertionError("malformed or oversized build provenance was accepted") + # A valid report is bound to both supplied files, not merely well-formed hashes. + write_json(report_path, valid) + for filename in ("autocar", "stealth-pilot"): + binary = root / filename + original = binary.read_bytes() + binary.write_bytes(original + b" changed") + try: + prepare(args) + except SystemExit: + pass + else: + raise AssertionError("changed pilot binary was accepted") + finally: + binary.write_bytes(original) + # Original upstream sources and same-path versioned replacements are valid, + # whereas native official QUIC can never be replaced. + for replaced in (False, True): + upstream = copy.deepcopy(valid) + for module in upstream["autocar"]["modules"][1:]: + module.update(source_path=module["module_path"], source_version=module["requested_version"], replaced=replaced) + write_json(report_path, upstream) + read_build_provenance(report_path, root / "autocar", root / "stealth-pilot") + # The limit is inclusive, and module order is normalized rather than trusted. + report_path.write_bytes(encoded + b" " * (BUILD_INFO_LIMIT - len(encoded))) + if read_build_provenance(report_path, root / "autocar", root / "stealth-pilot") != valid: + raise AssertionError("valid report at the input-size limit was rejected") + reordered = copy.deepcopy(valid) + reordered["autocar"]["modules"].reverse() + write_json(report_path, reordered) + if read_build_provenance(report_path, root / "autocar", root / "stealth-pilot") != valid: + raise AssertionError("valid module order was not normalized") + for field in ("build_info", "control_binary"): + original = getattr(args, field) + setattr(args, field, str(Path(__file__).resolve())) + try: + prepare(args) + except SystemExit: + pass + else: + raise AssertionError("provenance input outside inputs-root was accepted") + finally: + setattr(args, field, original) + future_native = copy.deepcopy(valid) + for role in ("autocar", "control"): + future_native[role]["modules"][0].update(requested_version="v0.64.0", source_version="v0.64.0") + write_json(report_path, future_native) + prepare(args) + cover = read_json(Path(args.output))["products"]["cover"]["variants"][0] + if ( + cover["name"] != "quic-go-standard-h3-control" + or cover["client_implementation"] != "quic-go v0.64.0 standard H3 control (not a browser)" + or cover["server_implementation"] != "quic-go v0.64.0 HTTP/3 fixture" + or cover["implementation_version"] != "v0.64.0-calibration-control" + ): + raise AssertionError("control labels did not follow the validated effective version") + write_json(report_path, valid) + + def fail(message: str) -> None: raise SystemExit(message) diff --git a/scripts/stealth-pilot.sh b/scripts/stealth-pilot.sh index e0d2716..dd3757c 100755 --- a/scripts/stealth-pilot.sh +++ b/scripts/stealth-pilot.sh @@ -32,7 +32,7 @@ EOF if [ "${1:-}" = --self-test ]; then [ "$#" -eq 1 ] || { usage; exit 2; } python3 scripts/stealth-pilot-config.py self-test - GOPROXY=off go test ./scripts/stealth-pilot + GOPROXY=off go test ./scripts/stealth-pilot ./scripts/stealth-build-info exit 0 fi [ "$#" -eq 0 ] || { usage; exit 2; } @@ -221,7 +221,7 @@ if [ -n "$($docker_bin image ls -q "$runner_image" 2>/dev/null)" ]; then exit 2 fi -# Compile from the current v1.0.1 worktree with dependency fetching disabled. +# Compile from the current worktree with dependency fetching disabled. # The resulting Linux binaries are frozen before any capture begins. git_head=$(git rev-parse --verify HEAD) build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ) @@ -232,6 +232,15 @@ GOPROXY=off CGO_ENABLED=0 GOOS=linux GOARCH="$goarch" go build -trimpath \ -o "$inputs/stealth-pilot" ./scripts/stealth-pilot chmod 0755 "$inputs/autocar" "$inputs/stealth-pilot" +# Read the exact target binaries without executing them. Record the effective +# replacement sources, not just the original module requirements or this +# checkout's go.mod. Keep only the helper's allowlisted, secret-free fields. +GOPROXY=off GOOS="$(go env GOHOSTOS)" GOARCH="$(go env GOHOSTARCH)" \ + go run ./scripts/stealth-build-info \ + --autocar "$inputs/autocar" --control "$inputs/stealth-pilot" \ + >"$artifact_dir/build-provenance.json" +cp "$artifact_dir/build-provenance.json" "$inputs/build-provenance.json" + # Stage an already-local official binary and verify both its metadata and # preregistered release checksum. No curl, registry pull, or public endpoint is # used by the pilot. @@ -416,6 +425,7 @@ python3 scripts/stealth-pilot-config.py prepare \ --hysteria-container "$hysteria_server" --hysteria-ip "$hysteria_ip" \ --origin-ip "$origin_ip" --cert "$inputs/server.crt" --key "$inputs/server.key" \ --token "$inputs/token" --autocar-binary "$inputs/autocar" \ + --control-binary "$inputs/stealth-pilot" --build-info "$inputs/build-provenance.json" \ --hysteria-binary "$inputs/hysteria" \ --hysteria-client-config "$inputs/hysteria-client.yaml" \ --hysteria-server-config "$inputs/hysteria-server.yaml" \ diff --git a/scripts/test_dependency_boundary.py b/scripts/test_dependency_boundary.py index 07b59c0..bf33758 100644 --- a/scripts/test_dependency_boundary.py +++ b/scripts/test_dependency_boundary.py @@ -177,6 +177,71 @@ def test_import_like_comments_and_literals_are_not_imports(self): result = self.check() self.assertEqual(result.returncode, 0, result.stdout) + def test_replacement_metadata_strings_and_comments_are_not_imports(self): + for path in (self.fork, self.quic_fork, self.fork + "/internal/fixture", self.quic_fork + "/http3"): + with self.subTest(path=path): + (self.root / "metadata.go").write_text( + 'package fixture\nimport "fmt"\n' + f'const source = "{path}"\n' + f'const rawSource = `{path}`\n' + f'// import "{path}"\n' + f'/* import (alias `{path}`) */\n' + f'const example = `import _ "{path}"`\n' + f'var sources = map[string]string{{"{path}": `{path}`}}\n' + 'var quotedImport = "import \\\"github.com/cppla/utls\\\""\n', + encoding="utf-8", + ) + result = self.check() + self.assertEqual(result.returncode, 0, result.stdout) + + def test_literal_replacement_import_forms_and_subpackages_fail(self): + forms = ( + 'import "{path}"', + 'import alias "{path}"', + 'import _ "{path}"', + 'import . "{path}"', + 'import (\n "fmt"\n alias "{path}"\n)', + 'import ("fmt"; _ "{path}")', + 'import (\n . /* alias comment */ "{path}"\n)', + 'import `{path}`', + 'import alias `{path}`', + 'import (\n _ `{path}`\n)', + 'import (\n . `{path}`\n)', + 'import /* declaration comment */ "{path}"', + ) + for path in (self.fork, self.quic_fork, self.fork + "/internal/fixture", self.quic_fork + "/http3"): + for form in forms: + with self.subTest(path=path, form=form): + (self.root / "main.go").write_text( + "package fixture\n" + form.format(path=path) + "\n", + encoding="utf-8", + ) + result = self.check() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("imports the replacement path directly", result.stdout) + + def test_raw_import_carriage_returns_cannot_hide_replacement_identity(self): + for path in (self.fork, self.quic_fork + "/http3"): + with self.subTest(path=path): + (self.root / "main.go").write_bytes( + ("package fixture\nimport `" + path.replace("/cppla/", "/cppla/\r") + "`\n").encode() + ) + result = self.check() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("imports the replacement path directly", result.stdout) + + def test_prohibited_application_module_whole_source_policy_is_unchanged(self): + path = "github.com/apernet/hysteria/v2" + for source in ( + f'import _ "{path}"', f'const source = "{path}"', + f'const raw = `{path}`', f'// import "{path}"', + ): + with self.subTest(source=source): + (self.root / "main.go").write_text("package fixture\n" + source + "\n", encoding="utf-8") + result = self.check() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("Go source references the prohibited", result.stdout) + def test_fork_cannot_be_required_or_imported_directly(self): result = self.check(self.module + f"\nrequire {self.fork} {FORK_VERSION}\n") self.assertNotEqual(result.returncode, 0, result.stdout)