diff --git a/pkg/build/build.go b/pkg/build/build.go index e14f56703..139510ec9 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -1018,11 +1018,13 @@ func (b *Build) buildWorkspaceConfig(ctx context.Context) *container.Config { } } - // TODO(kaniini): Disable networking capability according to the pipeline requirements. - caps := container.Capabilities{ - Networking: true, + networking := true + if b.Configuration.Capabilities.Networking != nil { + networking = *b.Configuration.Capabilities.Networking } + caps := container.Capabilities{Networking: networking} + cfg := container.Config{ Arch: b.Arch, PackageName: b.Configuration.Package.Name, diff --git a/pkg/build/test.go b/pkg/build/test.go index 3c2ffeefa..431800c67 100644 --- a/pkg/build/test.go +++ b/pkg/build/test.go @@ -442,11 +442,13 @@ func (t *Test) buildWorkspaceConfig(ctx context.Context, imgRef, pkgName string, } } - // TODO(kaniini): Disable networking capability according to the pipeline requirements. - caps := container.Capabilities{ - Networking: true, + networking := true + if t.Configuration.Capabilities.Networking != nil { + networking = *t.Configuration.Capabilities.Networking } + caps := container.Capabilities{Networking: networking} + cfg := container.Config{ PackageName: pkgName, Mounts: mounts, diff --git a/pkg/build/test_test.go b/pkg/build/test_test.go index b96675be5..c6f22ba7a 100644 --- a/pkg/build/test_test.go +++ b/pkg/build/test_test.go @@ -85,6 +85,8 @@ func TestBuildWorkspaceConfig(t *testing.T) { }, } + networkingFalse := false + tests := []struct { name string env map[string]string @@ -127,6 +129,18 @@ func TestBuildWorkspaceConfig(t *testing.T) { want.CacheDir = tmpDirReal return &want }(), + }, { + name: "test - networking disabled", + t: func() *Test { + cacheT := baseTest + cacheT.Configuration.Capabilities.Networking = &networkingFalse + return &cacheT + }(), + want: func() *container.Config { + want := wantBase + want.Capabilities.Networking = false + return &want + }(), }, } for _, tt := range tests { diff --git a/pkg/config/config.go b/pkg/config/config.go index 2e09cc8f2..b8f6fce1c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -875,6 +875,14 @@ type Capabilities struct { Add []string `json:"add,omitempty" yaml:"add,omitempty"` // Linux process capabilities to drop from the pipeline container. Drop []string `json:"drop,omitempty" yaml:"drop,omitempty"` + + // Controls whether the build/test runner sandbox has access to the + // network. When omitted, melange keeps the existing behavior (network + // enabled). + // + // Note: network access may still be required for pipeline steps like + // fetch/git-checkout unless inputs are preloaded via cache/local sources. + Networking *bool `json:"networking,omitempty" yaml:"networking,omitempty"` } // Configuration is the root melange configuration. diff --git a/pkg/config/schema.cue b/pkg/config/schema.cue index cd131824e..b18299f51 100644 --- a/pkg/config/schema.cue +++ b/pkg/config/schema.cue @@ -40,6 +40,12 @@ // Linux process capabilities to drop from the pipeline container. drop?: [...string] + + // When set to false, melange will run the build/test runner sandbox + // without network access. + // + // When omitted, melange keeps the existing default (network enabled). + networking?: bool }) // Capability stores paths and an associated map of capabilities diff --git a/pkg/config/schema.json b/pkg/config/schema.json index 63f79baf6..f72a80140 100644 --- a/pkg/config/schema.json +++ b/pkg/config/schema.json @@ -96,6 +96,10 @@ }, "type": "array", "description": "Linux process capabilities to drop from the pipeline container." + }, + "networking": { + "type": "boolean", + "description": "When set to false, melange will run the build/test runner sandbox without network access. When omitted, melange keeps the existing default (network enabled)." } }, "additionalProperties": false, diff --git a/pkg/container/docker/docker_runner.go b/pkg/container/docker/docker_runner.go index b2caafd70..5b7cd22ba 100644 --- a/pkg/container/docker/docker_runner.go +++ b/pkg/container/docker/docker_runner.go @@ -80,6 +80,18 @@ func (dk *docker) Close() error { return dk.cli.Close() } +// dockerNetworkMode returns the Docker NetworkMode for the pod based on the +// networking capability. When networking is disabled it returns "none", which +// gives the container only a loopback interface with no route to the host or +// the outside world. When enabled it returns the empty NetworkMode, leaving +// Docker's default networking in place. +func dockerNetworkMode(networking bool) container.NetworkMode { + if !networking { + return "none" + } + return "" +} + // StartPod starts a pod for supporting a Docker task, if // necessary. func (dk *docker) StartPod(ctx context.Context, cfg *mcontainer.Config) error { @@ -113,6 +125,10 @@ func (dk *docker) StartPod(ctx context.Context, cfg *mcontainer.Config) error { if len(cfg.Capabilities.Drop) > 0 { hostConfig.CapDrop = cfg.Capabilities.Drop } + // Disable the container network when the networking capability is turned + // off. NetworkMode "none" gives the container only a loopback interface, + // with no route to the host or the outside world. + hostConfig.NetworkMode = dockerNetworkMode(cfg.Capabilities.Networking) platform := &image_spec.Platform{ Architecture: cfg.Arch.String(), diff --git a/pkg/container/docker/networking_test.go b/pkg/container/docker/networking_test.go new file mode 100644 index 000000000..100e80d41 --- /dev/null +++ b/pkg/container/docker/networking_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 Chainguard, Inc. +// +// 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. + +package docker + +import "testing" + +// TestDockerNetworkMode asserts the Docker runner selects NetworkMode "none" +// (loopback only, no route off-box) when the networking capability is +// disabled, and the default NetworkMode otherwise. +func TestDockerNetworkMode(t *testing.T) { + for _, tt := range []struct { + name string + networking bool + want string + }{ + {"networking enabled", true, ""}, + {"networking disabled", false, "none"}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := string(dockerNetworkMode(tt.networking)); got != tt.want { + t.Fatalf("networking=%v: NetworkMode=%q, want %q", tt.networking, got, tt.want) + } + }) + } +} diff --git a/pkg/container/networking_test.go b/pkg/container/networking_test.go new file mode 100644 index 000000000..db1b524dc --- /dev/null +++ b/pkg/container/networking_test.go @@ -0,0 +1,81 @@ +// Copyright 2026 Chainguard, Inc. +// +// 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. + +package container + +import ( + "strings" + "testing" + + "github.com/chainguard-dev/clog/slogtest" +) + +// TestBubblewrapNetworking asserts the bubblewrap runner unshares the network +// namespace (--unshare-net) when, and only when, the networking capability is +// disabled. +func TestBubblewrapNetworking(t *testing.T) { + for _, tt := range []struct { + name string + networking bool + wantUnshare bool + }{ + {"networking enabled", true, false}, + {"networking disabled", false, true}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx := slogtest.Context(t) + cfg := &Config{Capabilities: Capabilities{Networking: tt.networking}} + cmd := new(bubblewrap).cmd(ctx, cfg, false, nil) + got := strings.Contains(strings.Join(cmd.Args, " "), "--unshare-net") + if got != tt.wantUnshare { + t.Fatalf("networking=%v: --unshare-net present=%v, want=%v\nargs: %s", + tt.networking, got, tt.wantUnshare, strings.Join(cmd.Args, " ")) + } + }) + } +} + +// TestQemuNetdevArgs asserts the QEMU runner adds SLIRP restrict=on (guest +// network isolation) when, and only when, the networking capability is +// disabled, while always preserving the hostfwd control channel. +func TestQemuNetdevArgs(t *testing.T) { + const sshAddr = "127.0.0.1:2022" + const sshCtrlAddr = "127.0.0.1:2223" + + for _, tt := range []struct { + name string + networking bool + wantRestrict bool + }{ + {"networking enabled", true, false}, + {"networking disabled", false, true}, + } { + t.Run(tt.name, func(t *testing.T) { + got := qemuNetdevArgs(sshAddr, sshCtrlAddr, tt.networking) + + // hostfwd control channel must always be present so melange can + // drive the guest over SSH. + if !strings.Contains(got, "hostfwd=tcp:"+sshAddr+"-:22") || + !strings.Contains(got, "hostfwd=tcp:"+sshCtrlAddr+"-:2223") { + t.Fatalf("hostfwd rules missing from netdev args: %s", got) + } + + gotRestrict := strings.Contains(got, "restrict=on") + if gotRestrict != tt.wantRestrict { + t.Fatalf("networking=%v: restrict=on present=%v, want=%v\nargs: %s", + tt.networking, gotRestrict, tt.wantRestrict, got) + } + }) + } +} diff --git a/pkg/container/qemu_runner.go b/pkg/container/qemu_runner.go index 35c378e27..43d108ced 100644 --- a/pkg/container/qemu_runner.go +++ b/pkg/container/qemu_runner.go @@ -797,7 +797,7 @@ func createMicroVM(ctx context.Context, cfg *Config) error { baseargs = append(baseargs, "-nodefaults") baseargs = append(baseargs, serialArgs...) // use -netdev + -device instead of -nic, as this is better supported by microvm machine type - netdevArgs := "user,id=id1,hostfwd=tcp:" + cfg.SSHAddress + "-:22,hostfwd=tcp:" + cfg.SSHControlAddress + "-:2223" + netdevArgs := qemuNetdevArgs(cfg.SSHAddress, cfg.SSHControlAddress, cfg.Capabilities.Networking) // QEMU_DNS_SEARCH allows configuring DNS search domains inside the guest VM. // This is useful for builds that need to resolve short hostnames via search // domains, or when the build environment requires specific DNS resolution @@ -2543,6 +2543,22 @@ func parseDNSSearchDomains(input string) ([]string, error) { return domains, nil } +// qemuNetdevArgs builds the QEMU -netdev option string for the guest's +// user-mode (SLIRP) network. The hostfwd rules expose the guest SSH and +// control ports on the host, which is how melange drives the guest. +// +// When networking is disabled, SLIRP restrict=on isolates the guest: it +// blocks all guest-initiated traffic to the host and the outside world while +// leaving the explicit hostfwd rules intact, so the SSH control channel keeps +// working. DNS search domains are appended by the caller. +func qemuNetdevArgs(sshAddress, sshControlAddress string, networking bool) string { + args := "user,id=id1,hostfwd=tcp:" + sshAddress + "-:22,hostfwd=tcp:" + sshControlAddress + "-:2223" + if !networking { + args += ",restrict=on" + } + return args +} + // buildDNSSearchNetdevArgs constructs the QEMU netdev dnssearch options string. // Returns empty string if no domains provided. // Each domain produces a separate ",dnssearch=" option.