From 484ab02d57f945ef6ba0dfa1ecabe48094ddacb4 Mon Sep 17 00:00:00 2001 From: Vijay Date: Sun, 11 Oct 2026 17:12:06 -0400 Subject: [PATCH 1/2] test: guard production code against networking Signed-off-by: Vijay --- SECURITY.md | 2 +- internal/cli/network_test.go | 325 +++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 internal/cli/network_test.go diff --git a/SECURITY.md b/SECURITY.md index 5aa4ed7..186a22b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -Security fixes target the latest patch in the `0.6.x` release line and `main`. +Security fixes target the latest patch in the `0.7.x` release line and `main`. Use the newest patch and record its version and revision when running trials. Older patches and development snapshots do not receive routine backports. Dependencies and the Go toolchain are reviewed on an ongoing basis. diff --git a/internal/cli/network_test.go b/internal/cli/network_test.go new file mode 100644 index 0000000..dbe1f95 --- /dev/null +++ b/internal/cli/network_test.go @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: Apache-2.0 +// Code authors: Vijay and Codex + +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "go/ast" + "go/importer" + "go/parser" + "go/token" + "go/types" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "testing" + "time" +) + +// This is a source-level capability guard, not a sandbox or a reachability +// analysis. Check the current GOOS/GOARCH and tags supplied through GOFLAGS, +// including dependency code and uncalled functions, but not tests or unrelated +// examples. CI must run it for each supported build configuration; go test's +// command-line-only tags are not inherited by go list. Standard-library code is +// trusted: scanning net itself would reject the permitted SplitHostPort helper. +// Arbitrary subprocesses, assembly, reflection and dynamic loading still require +// review and the separate offline binary smoke test. +func TestProductionHasNoNetwork(t *testing.T) { + packages := listNetworkPackages(t, "../..") + for _, finding := range networkFindings(t, packages) { + t.Error(finding) + } +} + +type networkPackage struct { + ImportPath string + Dir string + Export string + Standard bool + GoFiles []string + CgoFiles []string +} + +func listNetworkPackages(t *testing.T, dir string) []networkPackage { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + // No -test: test-only clients must not contaminate the production graph. + cmd := exec.CommandContext(ctx, "go", "list", "-deps", "-export", "-json", "./cmd/...", "./internal/...") + cmd.Dir = dir + var stderr bytes.Buffer + cmd.Stderr = &stderr + output, err := cmd.Output() + if err != nil { + t.Fatalf("load production graph: %v\n%s", err, &stderr) + } + var packages []networkPackage + decoder := json.NewDecoder(bytes.NewReader(output)) + for { + var p networkPackage + if err := decoder.Decode(&p); err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + packages = append(packages, p) + } + if len(packages) == 0 { + t.Fatal("empty production graph") + } + return packages +} + +func networkFindings(t *testing.T, packages []networkPackage) []string { + t.Helper() + fset := token.NewFileSet() + exports := make(map[string]string) + for _, p := range packages { + exports[p.ImportPath] = p.Export + } + imp := importer.ForCompiler(fset, "gc", func(path string) (io.ReadCloser, error) { + return os.Open(exports[path]) + }) + var findings []string + for _, p := range packages { + if networkImport(p.ImportPath) { + findings = append(findings, p.ImportPath+": forbidden dependency in production graph") + } + if p.Standard { + continue + } + // Cgo can open sockets without a Go API reference; fail closed rather + // than silently claiming to have checked its C implementation. + if len(p.CgoFiles) != 0 { + findings = append(findings, p.ImportPath+": cgo requires an explicit offline review") + continue + } + var files []*ast.File + for _, name := range p.GoFiles { + f, err := parser.ParseFile(fset, filepath.Join(p.Dir, name), nil, 0) + if err != nil { + t.Fatal(err) + } + files = append(files, f) + for _, spec := range f.Imports { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil { + t.Fatal(err) + } + if networkImport(path) { + findings = append(findings, fmt.Sprintf("%s: %s: forbidden import %s", p.ImportPath, fset.Position(spec.Pos()), path)) + } + } + } + info := &types.Info{Uses: make(map[*ast.Ident]types.Object)} + config := types.Config{Importer: imp, Sizes: types.SizesFor("gc", runtime.GOARCH)} + if _, err := config.Check(p.ImportPath, fset, files, info); err != nil { + t.Fatalf("type-check %s for network guard: %v", p.ImportPath, err) + } + // Resolve symbols, not selector spelling. This catches import aliases, + // dot imports, method expressions, and saved function values while + // leaving shadowed names and unrelated Dial/Listen methods alone. + for id, obj := range info.Uses { + if obj.Pkg() == nil { + continue + } + if _, ok := obj.(*types.Func); !ok { + continue + } + if networkFunction(obj.Pkg().Path(), obj.Name()) { + findings = append(findings, fmt.Sprintf("%s: %s: forbidden network API %s.%s", p.ImportPath, fset.Position(id.Pos()), obj.Pkg().Path(), obj.Name())) + } + } + } + slices.Sort(findings) + return findings +} + +func networkImport(path string) bool { + // Keep parsing/value packages (net/url, net/netip, net/mail) available. + return path == "net/http" || strings.HasPrefix(path, "net/http/") || + path == "net/rpc" || strings.HasPrefix(path, "net/rpc/") || path == "net/smtp" || path == "log/syslog" +} + +func networkFunction(path, name string) bool { + switch path { + case "net", "crypto/tls", "net/textproto": + for _, prefix := range []string{"Dial", "Listen", "Accept", "Lookup", "Resolve"} { + if strings.HasPrefix(name, prefix) && name != "ResolveUnixAddr" { + return true + } + } + return name == "FileConn" || name == "FileListener" || name == "FilePacketConn" + case "syscall", "golang.org/x/sys/unix", "golang.org/x/sys/windows": + // Raw syscalls can hide socket operations behind numeric constants. + for _, prefix := range []string{"Socket", "WSA", "Accept", "Bind", "Connect", "Listen", "Sendto", "Sendmsg", "Recvfrom", "Recvmsg", "Getsock", "Setsock", "Shutdown", "Syscall", "RawSyscall"} { + if strings.HasPrefix(name, prefix) { + return true + } + } + } + return false +} + +func TestNetworkGuardFixtures(t *testing.T) { + fixtures := []struct { + name string + source string + want string + }{ + {"http", `import "net/http"; var _ = http.MethodGet`, "forbidden import net/http"}, + {"http_alias", `import client "net/http"; var _ = client.Get`, "forbidden import net/http"}, + {"http_blank", `import _ "net/http"`, "forbidden import net/http"}, + {"http_dot", `import . "net/http"; var _ = MethodGet`, "forbidden import net/http"}, + {"http_subpackage", `import _ "net/http/pprof"`, "forbidden import net/http/pprof"}, + {"syslog", `import "log/syslog"; var _ = syslog.New`, "forbidden import log/syslog"}, + {"smtp", `import "net/smtp"; var _ = smtp.SendMail`, "forbidden import net/smtp"}, + {"rpc", `import "net/rpc"; var _ = rpc.Dial`, "forbidden import net/rpc"}, + {"dial", `import "net"; func unused() { _, _ = net.Dial("tcp", "localhost:1") }`, "net.Dial"}, + {"dial_alias", `import socket "net"; var saved = socket.DialTimeout`, "net.DialTimeout"}, + {"dial_dot", `import . "net"; var saved = Dial`, "net.Dial"}, + {"dialer", `import "net"; var d net.Dialer; var saved = d.DialContext`, "net.DialContext"}, + {"dialer_alias", `import "net"; type local = net.Dialer; var saved = (*local).Dial`, "net.Dial"}, + {"dial_tcp", `import "net"; var _ = net.DialTCP`, "net.DialTCP"}, + {"dial_udp", `import "net"; var _ = net.DialUDP`, "net.DialUDP"}, + {"dial_unix", `import "net"; var _ = net.DialUnix`, "net.DialUnix"}, + {"listen", `import "net"; var _ = net.Listen`, "net.Listen"}, + {"listen_packet", `import "net"; var _ = net.ListenPacket`, "net.ListenPacket"}, + {"listen_udp", `import "net"; var _ = net.ListenUDP`, "net.ListenUDP"}, + {"listen_config", `import "net"; var _ = (*net.ListenConfig).Listen`, "net.Listen"}, + {"accept", `import "net"; var _ = (*net.TCPListener).AcceptTCP`, "net.AcceptTCP"}, + {"dns", `import "net"; var _ = net.LookupHost`, "net.LookupHost"}, + {"resolver", `import "net"; var _ = net.DefaultResolver.LookupIPAddr`, "net.LookupIPAddr"}, + {"resolve", `import "net"; var _ = net.ResolveTCPAddr`, "net.ResolveTCPAddr"}, + {"file_conn", `import "net"; var _ = net.FileConn`, "net.FileConn"}, + {"file_listener", `import "net"; var _ = net.FileListener`, "net.FileListener"}, + {"file_packet", `import "net"; var _ = net.FilePacketConn`, "net.FilePacketConn"}, + {"listener_interface", `import "net"; var _ = net.Listener.Accept`, "net.Accept"}, + {"tls_dial", `import "crypto/tls"; var _ = tls.Dial`, "crypto/tls.Dial"}, + {"tls_listen", `import "crypto/tls"; var _ = tls.Listen`, "crypto/tls.Listen"}, + {"tls_dialer", `import "crypto/tls"; var _ = (*tls.Dialer).DialContext`, "crypto/tls.DialContext"}, + {"textproto", `import "net/textproto"; var _ = textproto.Dial`, "net/textproto.Dial"}, + {"socket", `import s "syscall"; var _ = s.Socket`, "syscall.Socket"}, + {"connect", `import "syscall"; var _ = syscall.Connect`, "syscall.Connect"}, + {"bind", `import "syscall"; var _ = syscall.Bind`, "syscall.Bind"}, + {"raw_syscall", `import "syscall"; var _ = syscall.RawSyscall`, "syscall.RawSyscall"}, + {"syscall", `import "syscall"; var _ = syscall.Syscall`, "syscall.Syscall"}, + {"safe_url", `import "net/url"; var _ = url.Parse`, ""}, + {"safe_split", `import n "net"; var _ = n.SplitHostPort; var _ = n.JoinHostPort`, ""}, + {"safe_dot", `import . "net"; var _ = SplitHostPort`, ""}, + {"safe_ip", `import "net"; import "net/netip"; var _ = net.ParseIP; var _ = netip.ParseAddr`, ""}, + {"safe_mail", `import "net/mail"; var _ = mail.ParseAddress`, ""}, + {"safe_textproto", `import "net/textproto"; var _ = textproto.CanonicalMIMEHeaderKey`, ""}, + {"safe_syscall", `import "syscall"; var _ = syscall.O_NONBLOCK; var _ = syscall.Getpid`, ""}, + {"safe_pipe", `import "net"; var _ = net.Pipe`, ""}, + {"safe_shadow", `import "net"; var _ = net.SplitHostPort; func f() { net := struct { Dial func() }{}; net.Dial() }`, ""}, + {"safe_method", `type local struct{}; func (local) Dial() {}; var _ = local{}.Dial`, ""}, + {"safe_text", "// net.Dial and import net/http are only text.\nconst text = `net.Listen net/http syscall.Socket`", ""}, + } + root := t.TempDir() + writeNetworkFixture(t, root, "go.mod", "module guardfixture\n\ngo 1.26.0\n\nrequire example.com/client v0.0.0\nreplace example.com/client => ./dependency\n") + for _, fixture := range fixtures { + writeNetworkFixture(t, root, "internal/"+fixture.name+"/source.go", "package fixture\n"+fixture.source+"\n") + } + writeNetworkFixture(t, root, "cmd/tool/main.go", "package main\nimport _ \"example.com/client\"\nfunc main() {}\n") + writeNetworkFixture(t, root, "dependency/go.mod", "module example.com/client\n\ngo 1.26.0\n") + writeNetworkFixture(t, root, "dependency/client.go", "package client\nimport _ \"example.com/client/deeper\"\n") + writeNetworkFixture(t, root, "dependency/deeper/client.go", "package deeper\nimport \"net\"\nvar saved = net.Dial\n") + writeNetworkFixture(t, root, "dependency/deeper/http.go", "package deeper\nimport _ \"net/http\"\n") + writeNetworkFixture(t, root, "examples/live/main.go", "package main\nimport _ \"net/http\"\nfunc main() {}\n") + writeNetworkFixture(t, root, "internal/safe_url/client_test.go", "package fixture\nimport _ \"net/http\"\n") + packages := listNetworkPackages(t, root) + findings := networkFindings(t, packages) + for _, fixture := range fixtures { + t.Run(fixture.name, func(t *testing.T) { + var got []string + for _, finding := range findings { + if strings.HasPrefix(finding, "guardfixture/internal/"+fixture.name+":") { + got = append(got, finding) + } + } + if fixture.want == "" && len(got) != 0 || fixture.want != "" && !strings.Contains(strings.Join(got, "\n"), fixture.want) { + t.Fatalf("want %q; findings: %v", fixture.want, got) + } + }) + } + for _, want := range []string{"net.Dial", "forbidden import net/http"} { + found := false + for _, finding := range findings { + if strings.HasPrefix(finding, "example.com/client/deeper:") && strings.Contains(finding, want) { + found = true + } + } + if !found { + t.Fatalf("missed %s in transitive dependency", want) + } + } + for _, p := range packages { + if strings.Contains(p.ImportPath, "examples/live") || strings.HasSuffix(p.ImportPath, ".test") || len(p.GoFiles) == 0 && !p.Standard { + t.Fatalf("unexpected package in production graph: %+v", p) + } + for _, name := range p.GoFiles { + if strings.HasSuffix(name, "_test.go") { + t.Fatalf("test file included in production graph: %s", name) + } + } + } +} + +func TestNetworkGuardProductionGraph(t *testing.T) { + root := t.TempDir() + writeNetworkFixture(t, root, "go.mod", "module guardfixture\n\ngo 1.26.0\n") + writeNetworkFixture(t, root, "cmd/tool/main.go", "package main\nfunc main() {}\n") + writeNetworkFixture(t, root, "internal/helper/helper.go", "package helper\nimport \"net\"\nvar _ = net.SplitHostPort\n") + writeNetworkFixture(t, root, "internal/helper/helper_test.go", "package helper\nimport _ \"net/http\"\n") + writeNetworkFixture(t, root, "examples/live/main.go", "package main\nimport _ \"net/http\"\nfunc main() {}\n") + if got := networkFindings(t, listNetworkPackages(t, root)); len(got) != 0 { + t.Fatalf("test-only/example networking contaminated production graph: %v", got) + } + // expvar brings in net/http through the standard library, whose bodies + // are not type-checked by this guard. The dependency graph must catch it. + writeNetworkFixture(t, root, "cmd/tool/main.go", "package main\nimport _ \"expvar\"\nfunc main() {}\n") + got := networkFindings(t, listNetworkPackages(t, root)) + if !slices.Contains(got, "net/http: forbidden dependency in production graph") { + t.Fatalf("missed indirect standard-library HTTP dependency: %v", got) + } +} + +func TestNetworkGuardLowLevelPolicy(t *testing.T) { + for _, path := range []string{"syscall", "golang.org/x/sys/unix", "golang.org/x/sys/windows"} { + for _, name := range []string{"Socket", "Socketpair", "Accept4", "Bind", "Connect", "ConnectEx", "Listen", "Sendto", "SendmsgN", "Recvfrom", "Recvmsg", "WSASocket", "Syscall6", "RawSyscall6"} { + if !networkFunction(path, name) { + t.Errorf("missed %s.%s", path, name) + } + } + for _, name := range []string{"Open", "Close", "Read", "Fstat", "Getpid"} { + if networkFunction(path, name) { + t.Errorf("incorrectly rejected %s.%s", path, name) + } + } + } + got := networkFindings(t, []networkPackage{{ImportPath: "fixture/cgo", CgoFiles: []string{"socket.go"}}}) + if len(got) != 1 || !strings.Contains(got[0], "cgo requires an explicit offline review") { + t.Fatalf("cgo was not rejected: %v", got) + } +} + +func writeNetworkFixture(t *testing.T, root, name, source string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(source), 0600); err != nil { + t.Fatal(err) + } +} From 24f65e751e0fbfaa87a0b1844edc880b1943ffdb Mon Sep 17 00:00:00 2001 From: Vijay Date: Sun, 11 Oct 2026 17:33:35 -0400 Subject: [PATCH 2/2] Verify built CLI outputs without network access Signed-off-by: Vijay --- .github/workflows/ci.yml | 8 ++ CONTRIBUTING.md | 12 +++ internal/cli/network_test.go | 32 ++++-- scripts/restricted-smoke.sh | 132 ++++++++++++++++++------- scripts/testdata/network-probe/main.go | 37 +++++++ 5 files changed, 177 insertions(+), 44 deletions(-) create mode 100644 scripts/testdata/network-probe/main.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a16484a..b856153 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,14 @@ jobs: go-version: "1.26.9" - run: go test -race ./... - run: go vet ./... + - name: Built Linux CLI with and without networking + if: runner.os == 'Linux' + timeout-minutes: 5 + run: | + mkdir -p "$RUNNER_TEMP/offline-build" + CGO_ENABLED=0 GOOS=linux go build -o "$RUNNER_TEMP/offline-build/fleetdiff" ./cmd/fleetdiff + tar -czf "$RUNNER_TEMP/fleetdiff-linux.tar.gz" -C "$RUNNER_TEMP/offline-build" fleetdiff + sh scripts/restricted-smoke.sh "$RUNNER_TEMP/fleetdiff-linux.tar.gz" "linux/$(go env GOARCH)" - name: LiteLLM spend files and privacy checks run: | go build -o bin/fleetdiff ./cmd/fleetdiff diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44473a3..62e21f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,18 @@ go test -race ./... go vet ./... ``` +Linux CI also compares the built CLI's exit status, stdout and stderr with Docker +bridge networking and `--network none`. This uses the existing synthetic fixtures +and a locally built `FROM scratch` image, run by its immutable image ID. With +Go 1.26.9, Docker and jq available, reproduce it from the repository root: + +```sh +mkdir -p bin/offline +CGO_ENABLED=0 GOOS=linux go build -o bin/offline/fleetdiff ./cmd/fleetdiff +tar -czf bin/offline/fleetdiff.tar.gz -C bin/offline fleetdiff +sh scripts/restricted-smoke.sh bin/offline/fleetdiff.tar.gz "linux/$(go env GOARCH)" +``` + ## Signed and signed-off commits Every pull-request commit needs both: diff --git a/internal/cli/network_test.go b/internal/cli/network_test.go index dbe1f95..b914958 100644 --- a/internal/cli/network_test.go +++ b/internal/cli/network_test.go @@ -31,8 +31,9 @@ import ( // examples. CI must run it for each supported build configuration; go test's // command-line-only tags are not inherited by go list. Standard-library code is // trusted: scanning net itself would reject the permitted SplitHostPort helper. -// Arbitrary subprocesses, assembly, reflection and dynamic loading still require -// review and the separate offline binary smoke test. +// scripts/restricted-smoke.sh also compares built Linux CLI outputs with Docker +// bridge networking versus --network none in CI. Assembly, reflection and +// dynamic loading still require review; neither test proves absence of all I/O. func TestProductionHasNoNetwork(t *testing.T) { packages := listNetworkPackages(t, "../..") for _, finding := range networkFindings(t, packages) { @@ -136,7 +137,7 @@ func networkFindings(t *testing.T, packages []networkPackage) []string { continue } if networkFunction(obj.Pkg().Path(), obj.Name()) { - findings = append(findings, fmt.Sprintf("%s: %s: forbidden network API %s.%s", p.ImportPath, fset.Position(id.Pos()), obj.Pkg().Path(), obj.Name())) + findings = append(findings, fmt.Sprintf("%s: %s: forbidden network/process API %s.%s", p.ImportPath, fset.Position(id.Pos()), obj.Pkg().Path(), obj.Name())) } } } @@ -147,11 +148,13 @@ func networkFindings(t *testing.T, packages []networkPackage) []string { func networkImport(path string) bool { // Keep parsing/value packages (net/url, net/netip, net/mail) available. return path == "net/http" || strings.HasPrefix(path, "net/http/") || - path == "net/rpc" || strings.HasPrefix(path, "net/rpc/") || path == "net/smtp" || path == "log/syslog" + path == "net/rpc" || strings.HasPrefix(path, "net/rpc/") || path == "net/smtp" || path == "log/syslog" || path == "os/exec" } func networkFunction(path, name string) bool { switch path { + case "os": + return name == "StartProcess" case "net", "crypto/tls", "net/textproto": for _, prefix := range []string{"Dial", "Listen", "Accept", "Lookup", "Resolve"} { if strings.HasPrefix(name, prefix) && name != "ResolveUnixAddr" { @@ -160,6 +163,9 @@ func networkFunction(path, name string) bool { } return name == "FileConn" || name == "FileListener" || name == "FilePacketConn" case "syscall", "golang.org/x/sys/unix", "golang.org/x/sys/windows": + if name == "Exec" || name == "ForkExec" || name == "StartProcess" || strings.HasPrefix(name, "CreateProcess") { + return true + } // Raw syscalls can hide socket operations behind numeric constants. for _, prefix := range []string{"Socket", "WSA", "Accept", "Bind", "Connect", "Listen", "Sendto", "Sendmsg", "Recvfrom", "Recvmsg", "Getsock", "Setsock", "Shutdown", "Syscall", "RawSyscall"} { if strings.HasPrefix(name, prefix) { @@ -184,6 +190,15 @@ func TestNetworkGuardFixtures(t *testing.T) { {"syslog", `import "log/syslog"; var _ = syslog.New`, "forbidden import log/syslog"}, {"smtp", `import "net/smtp"; var _ = smtp.SendMail`, "forbidden import net/smtp"}, {"rpc", `import "net/rpc"; var _ = rpc.Dial`, "forbidden import net/rpc"}, + {"exec", `import "os/exec"; var _ = exec.Command`, "forbidden import os/exec"}, + {"exec_alias", `import process "os/exec"; var _ = process.CommandContext`, "forbidden import os/exec"}, + {"exec_blank", `import _ "os/exec"`, "forbidden import os/exec"}, + {"exec_dot", `import . "os/exec"; var _ = Command`, "forbidden import os/exec"}, + {"start_process", `import "os"; var _ = os.StartProcess`, "os.StartProcess"}, + {"start_process_alias", `import process "os"; var _ = process.StartProcess`, "os.StartProcess"}, + {"start_process_dot", `import . "os"; var _ = StartProcess`, "os.StartProcess"}, + {"syscall_exec", `import "syscall"; var _ = syscall.Exec`, "syscall.Exec"}, + {"syscall_fork_exec", `import "syscall"; var _ = syscall.ForkExec`, "syscall.ForkExec"}, {"dial", `import "net"; func unused() { _, _ = net.Dial("tcp", "localhost:1") }`, "net.Dial"}, {"dial_alias", `import socket "net"; var saved = socket.DialTimeout`, "net.DialTimeout"}, {"dial_dot", `import . "net"; var saved = Dial`, "net.Dial"}, @@ -220,6 +235,8 @@ func TestNetworkGuardFixtures(t *testing.T) { {"safe_mail", `import "net/mail"; var _ = mail.ParseAddress`, ""}, {"safe_textproto", `import "net/textproto"; var _ = textproto.CanonicalMIMEHeaderKey`, ""}, {"safe_syscall", `import "syscall"; var _ = syscall.O_NONBLOCK; var _ = syscall.Getpid`, ""}, + {"safe_os", `import "os"; var _ = os.Open; var _ = os.Getpid`, ""}, + {"safe_process_shadow", `import "os"; var _ = os.Open; func f() { os := struct { StartProcess func() }{}; os.StartProcess() }`, ""}, {"safe_pipe", `import "net"; var _ = net.Pipe`, ""}, {"safe_shadow", `import "net"; var _ = net.SplitHostPort; func f() { net := struct { Dial func() }{}; net.Dial() }`, ""}, {"safe_method", `type local struct{}; func (local) Dial() {}; var _ = local{}.Dial`, ""}, @@ -235,6 +252,7 @@ func TestNetworkGuardFixtures(t *testing.T) { writeNetworkFixture(t, root, "dependency/client.go", "package client\nimport _ \"example.com/client/deeper\"\n") writeNetworkFixture(t, root, "dependency/deeper/client.go", "package deeper\nimport \"net\"\nvar saved = net.Dial\n") writeNetworkFixture(t, root, "dependency/deeper/http.go", "package deeper\nimport _ \"net/http\"\n") + writeNetworkFixture(t, root, "dependency/deeper/process.go", "package deeper\nimport \"os\"\nimport _ \"os/exec\"\nvar _ = os.StartProcess\n") writeNetworkFixture(t, root, "examples/live/main.go", "package main\nimport _ \"net/http\"\nfunc main() {}\n") writeNetworkFixture(t, root, "internal/safe_url/client_test.go", "package fixture\nimport _ \"net/http\"\n") packages := listNetworkPackages(t, root) @@ -252,7 +270,7 @@ func TestNetworkGuardFixtures(t *testing.T) { } }) } - for _, want := range []string{"net.Dial", "forbidden import net/http"} { + for _, want := range []string{"net.Dial", "forbidden import net/http", "forbidden import os/exec", "os.StartProcess"} { found := false for _, finding := range findings { if strings.HasPrefix(finding, "example.com/client/deeper:") && strings.Contains(finding, want) { @@ -280,7 +298,7 @@ func TestNetworkGuardProductionGraph(t *testing.T) { writeNetworkFixture(t, root, "go.mod", "module guardfixture\n\ngo 1.26.0\n") writeNetworkFixture(t, root, "cmd/tool/main.go", "package main\nfunc main() {}\n") writeNetworkFixture(t, root, "internal/helper/helper.go", "package helper\nimport \"net\"\nvar _ = net.SplitHostPort\n") - writeNetworkFixture(t, root, "internal/helper/helper_test.go", "package helper\nimport _ \"net/http\"\n") + writeNetworkFixture(t, root, "internal/helper/helper_test.go", "package helper\nimport _ \"net/http\"\nimport _ \"os/exec\"\n") writeNetworkFixture(t, root, "examples/live/main.go", "package main\nimport _ \"net/http\"\nfunc main() {}\n") if got := networkFindings(t, listNetworkPackages(t, root)); len(got) != 0 { t.Fatalf("test-only/example networking contaminated production graph: %v", got) @@ -296,7 +314,7 @@ func TestNetworkGuardProductionGraph(t *testing.T) { func TestNetworkGuardLowLevelPolicy(t *testing.T) { for _, path := range []string{"syscall", "golang.org/x/sys/unix", "golang.org/x/sys/windows"} { - for _, name := range []string{"Socket", "Socketpair", "Accept4", "Bind", "Connect", "ConnectEx", "Listen", "Sendto", "SendmsgN", "Recvfrom", "Recvmsg", "WSASocket", "Syscall6", "RawSyscall6"} { + for _, name := range []string{"Socket", "Socketpair", "Accept4", "Bind", "Connect", "ConnectEx", "Listen", "Sendto", "SendmsgN", "Recvfrom", "Recvmsg", "WSASocket", "Syscall6", "RawSyscall6", "Exec", "ForkExec", "StartProcess", "CreateProcessAsUser"} { if !networkFunction(path, name) { t.Errorf("missed %s.%s", path, name) } diff --git a/scripts/restricted-smoke.sh b/scripts/restricted-smoke.sh index 18dad3e..b64fcd0 100644 --- a/scripts/restricted-smoke.sh +++ b/scripts/restricted-smoke.sh @@ -7,44 +7,102 @@ archive=${1:?supply a Linux release archive} platform=${2:?supply linux/amd64 or linux/arm64} case "$platform" in linux/amd64|linux/arm64) ;; *) exit 2 ;; esac work=$(mktemp -d) -image="fleetdiff-restricted-test-$$" -cleanup() { docker image rm "$image" >/dev/null 2>&1 || true; rm -rf "$work"; } -trap cleanup EXIT HUP INT TERM +tag="fleetdiff-restricted-test-$$" +container="fleetdiff-restricted-test-$$" +cleanup() { + docker rm -f "$container" >/dev/null 2>&1 || true + docker image rm "$tag" >/dev/null 2>&1 || true + rm -rf "$work" +} +trap cleanup EXIT +trap 'exit 130' HUP INT TERM tar -xzf "$archive" -C "$work" fleetdiff chmod 755 "$work/fleetdiff" -cp scripts/restricted.Dockerfile "$work/Dockerfile" -docker build --network none --platform "$platform" -t "$image" "$work" >/dev/null -docker run --rm --platform "$platform" --network none --read-only --user 65532:65532 \ - --cap-drop ALL --security-opt no-new-privileges --memory 256m --cpus 1 --pids-limit 64 \ - --mount "type=bind,src=$(pwd)/examples/two-systems/data,dst=/data,readonly" \ - "$image" compare --before /data/before --after /data/after --expected owned,partner --format json > "$work/report.json" -test -s "$work/report.json" -jq -e '.version == 1 and .complete_observation_intervals == true and - ([.counters[] | select(.name == "requests") | .after] == [10])' "$work/report.json" >/dev/null -printf 'Restricted runtime passed: %s\n' "$platform" -docker run --rm --platform "$platform" --network none --read-only --user 65532:65532 \ - --cap-drop ALL --security-opt no-new-privileges --memory 256m --cpus 1 --pids-limit 64 \ - --mount "type=bind,src=$(pwd)/testdata/inspect-contract/v1,dst=/data,readonly" \ - "$image" inspect --format json /data/litellm-normal-no-usage-provenance.json > "$work/inspect.json" -jq -e '.schema == "fleetdiff-inspect/v1" and - .metrics.gen_ai_sketch_requests_total == 1 and - .metrics.gen_ai_sketch_missing_token_usage_total == 1' "$work/inspect.json" >/dev/null -printf 'Restricted offline inspection passed: %s\n' "$platform" -docker run --rm --platform "$platform" --network none --read-only --user 65532:65532 \ - --cap-drop ALL --security-opt no-new-privileges --memory 256m --cpus 1 --pids-limit 64 \ - --mount "type=bind,src=$(pwd)/examples/diagnose,dst=/data,readonly" \ - "$image" diagnose --format json /data/safe.yaml > "$work/diagnose.json" -jq -e '.status == "supported_safe" and .configuration_parsed == true' "$work/diagnose.json" >/dev/null -printf 'Restricted offline diagnosis passed: %s\n' "$platform" +CGO_ENABLED=0 GOOS=linux GOARCH=${platform#linux/} go build -o "$work/network-probe" ./scripts/testdata/network-probe go run ./examples/scan --out "$work/windows" -# These are generated synthetic fixtures, made readable for the non-root container. -chmod 755 "$work/windows" +# Only synthetic fixtures and a test-only executable are exposed to the container. +chmod 755 "$work/network-probe" "$work/windows" chmod 644 "$work/windows/"*.json -status=0 -docker run --rm --platform "$platform" --network none --read-only --user 65532:65532 \ - --cap-drop ALL --security-opt no-new-privileges --memory 256m --cpus 1 --pids-limit 64 \ - --mount "type=bind,src=$work/windows,dst=/data,readonly" \ - "$image" scan /data --expected app --recent 2 --as-of 2026-10-04T00:30:00Z --format json > "$work/scan.json" || status=$? -test "$status" -eq 3 -jq -e '.schema == "fleetdiff-scan/v1" and .unusual_windows == 2' "$work/scan.json" >/dev/null -printf 'Restricted offline scan passed: %s\n' "$platform" +cp scripts/restricted.Dockerfile "$work/Dockerfile" +# FROM scratch has no external base to download or mutable tag to resolve. +# Pin both runtime modes to exactly the same locally built image ID. +docker build --network none --platform "$platform" --iidfile "$work/image-id" -t "$tag" "$work" >/dev/null +image=$(cat "$work/image-id") + +run_container() { + network=$1 + entrypoint=$2 + shift 2 + docker run --rm --name "$container" --platform "$platform" --network "$network" \ + --read-only --user 65532:65532 --cap-drop ALL --security-opt no-new-privileges \ + --memory 256m --cpus 1 --pids-limit 64 \ + --env FLEETDIFF_SMOKE_SECRET=synthetic-offline-smoke-only-not-a-production-secret \ + --mount "type=bind,src=$(pwd)/examples,dst=/examples,readonly" \ + --mount "type=bind,src=$(pwd)/testdata,dst=/testdata,readonly" \ + --mount "type=bind,src=$work/windows,dst=/windows,readonly" \ + --mount "type=bind,src=$work/network-probe,dst=/network-probe,readonly" \ + --entrypoint "$entrypoint" "$image" "$@" +} + +# A UDP route probe sends no packets and needs no external server. It must +# succeed with bridge routing and fail with ENETUNREACH under none. +run_container bridge /network-probe bridge +run_container none /network-probe none + +check_case() { + name=$1 + expected=$2 + shift 2 + for mode in bridge none; do + status=0 + run_container "$mode" /fleetdiff "$@" > "$work/$name.$mode.stdout" 2> "$work/$name.$mode.stderr" || status=$? + if [ "$status" -ne "$expected" ]; then + printf '%s (%s): expected exit %s, got %s\n' "$name" "$mode" "$expected" "$status" >&2 + cat "$work/$name.$mode.stdout" "$work/$name.$mode.stderr" >&2 + return 1 + fi + test -s "$work/$name.$mode.stdout" + done + cmp "$work/$name.bridge.stdout" "$work/$name.none.stdout" + cmp "$work/$name.bridge.stderr" "$work/$name.none.stderr" + test ! -s "$work/$name.none.stderr" + printf 'PASS offline equivalence: %s; exit=%s; stdout/stderr identical (%s)\n' "$name" "$expected" "$platform" +} + +check_report() { + if ! jq -e "$2" "$work/$1.none.stdout" >/dev/null; then + printf '%s: unexpected report values\n' "$1" >&2 + cat "$work/$1.none.stdout" >&2 + return 1 + fi +} + +check_case compare 0 compare --before /examples/two-systems/data/before \ + --after /examples/two-systems/data/after --expected owned,partner --format json +check_report compare '.version == 1 and .complete_observation_intervals == true and + ([.counters[] | select(.name == "requests") | .after] == [10])' + +check_case investigate-summaries 0 investigate --before /examples/sessions/data/before \ + --after /examples/sessions/data/after --expected app --format json +check_report investigate-summaries '.version == 1 and ([.questions[] | select(.id == "volume") | + .volume.before_reported_tokens == 400 and .volume.after_reported_tokens == 3300] == [true])' + +# Fixed test-only keys and explicit periods remove per-run randomness and clocks. +check_case investigate-spend 0 investigate --litellm-spend /examples/litellm-spend/synthetic.csv \ + --before-period 2026-10-07 --after-period 2026-10-08 --group-by key \ + --hash-secret-env FLEETDIFF_SMOKE_SECRET --format json +check_report investigate-spend '.before.recorded_tokens == 8000 and .after.recorded_tokens == 16000 and + .concentration_of_increase.tracked_contributors == 2 and + .concentration_of_increase.share_of_net_recorded_increase.lower == 1' + +check_case inspect 0 inspect --format json --secret-env FLEETDIFF_SMOKE_SECRET \ + /testdata/inspect-contract/v1/litellm-normal-no-usage-provenance.json +check_report inspect '.schema == "fleetdiff-inspect/v1" and .metrics.gen_ai_sketch_requests_total == 1 and + .metrics.gen_ai_sketch_missing_token_usage_total == 1' + +check_case diagnose 0 diagnose --format json /examples/diagnose/safe.yaml +check_report diagnose '.status == "supported_safe" and .configuration_parsed == true' + +check_case scan 3 scan /windows --expected app --recent 2 --as-of 2026-10-04T00:30:00Z --format json +check_report scan '.schema == "fleetdiff-scan/v1" and .unusual_windows == 2' +printf 'PASS restricted offline binary smoke: 6 cases (%s)\n' "$platform" diff --git a/scripts/testdata/network-probe/main.go b/scripts/testdata/network-probe/main.go new file mode 100644 index 0000000..d4b79aa --- /dev/null +++ b/scripts/testdata/network-probe/main.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// Code authors: Vijay and Codex + +// Test-only route check for restricted-smoke.sh; no application code imports it. +package main + +import ( + "errors" + "fmt" + "net" + "os" + "syscall" + "time" +) + +func main() { + if len(os.Args) != 2 || (os.Args[1] != "bridge" && os.Args[1] != "none") { + fmt.Fprintln(os.Stderr, "supply bridge or none") + os.Exit(2) + } + // UDP connect checks the kernel route without sending a packet. TEST-NET-1 + // is a documentation address, so this does not depend on a live service. + conn, err := net.DialTimeout("udp4", "192.0.2.1:9", 2*time.Second) + if conn != nil { + conn.Close() + } + if os.Args[1] == "bridge" && err == nil { + fmt.Println("PASS network probe: bridge has an outbound route (no packets sent)") + return + } + if os.Args[1] == "none" && errors.Is(err, syscall.ENETUNREACH) { + fmt.Println("PASS network probe: none rejects outbound routing with ENETUNREACH") + return + } + fmt.Fprintf(os.Stderr, "unexpected %s route result: %v\n", os.Args[1], err) + os.Exit(1) +}