fix: Sandbox profile for Go ecosystem (#361)

* fix: Sandbox profile for Go ecosystem

* fix: Sandbox violations for network bind
This commit is contained in:
Abhisek Datta
2026-07-06 23:38:38 +05:30
committed by GitHub
parent 02965143d0
commit c601e17cdc
10 changed files with 290 additions and 16 deletions
+42
View File
@@ -243,6 +243,48 @@ jobs:
cd - && rm -rf "$HTTPX_TESTDIR" cd - && rm -rf "$HTTPX_TESTDIR"
- name: Test Go Modules - Proxy Mode (Experimental)
run: |
echo "Testing experimental Go module support via proxy mode..."
GO_TESTDIR=$(mktemp -d) && cd "$GO_TESTDIR"
go mod init example.com/pmg-go-e2e
cat > main.go <<'EOF'
package main
import (
"fmt"
"github.com/google/uuid"
)
func main() { fmt.Println(uuid.NewString()) }
EOF
echo "Testing pmg go get with a pinned version..."
pmg go get github.com/google/uuid@v1.6.0
# Verification: module resolved and checksummed through the proxy
grep -q 'github.com/google/uuid v1.6.0' go.mod
grep -q 'github.com/google/uuid v1.6.0' go.sum
echo "Testing pmg go mod tidy..."
rm go.sum
pmg go mod tidy
# Verification: go.sum regenerated via proxied module fetches
grep -q 'github.com/google/uuid v1.6.0' go.sum
echo "Testing pmg go run..."
pmg go run . | grep -Eq '^[0-9a-f-]{36}$'
cd - && rm -rf "$GO_TESTDIR"
# Regression: PMG injects HTTP(S)_PROXY into its child, and `go test`
# inherits it. The hermetic proxye2e harness must not route its
# in-process proxy traffic through the outer PMG proxy.
- name: Test Go - Hermetic Proxy Tests Under PMG
run: pmg go test -count=1 -run 'TestProxyFlow_Go' ./test/proxye2e/
- name: Test PNPM - Single Package & Manifest - name: Test PNPM - Single Package & Manifest
run: | run: |
echo "Testing PNPM single package installation..." echo "Testing PNPM single package installation..."
+6
View File
@@ -178,6 +178,12 @@ sandbox:
enabled: true enabled: true
profile: uvx profile: uvx
# Go ecosystem (experimental). The go command is opt-in: it runs only when
# invoked explicitly as `pmg go ...`.
go:
enabled: true
profile: go
# Dependency cooldown blocks installation of package versions published within # Dependency cooldown blocks installation of package versions published within
# a configurable time window. # a configurable time window.
dependency_cooldown: dependency_cooldown:
+36 -10
View File
@@ -137,6 +137,12 @@ func extractSeatbeltViolations(entries []seatbeltLogEntry, runID string) []sandb
} }
} }
// Network denial operands are addresses, not paths, so the
// path-shaped extraction above rejected them; recover the raw token.
if target == "" && (kind == sandbox.ViolationKindNetworkBind || kind == sandbox.ViolationKindNetworkConnect) {
target = extractSeatbeltDeniedToken(entry.EventMessage, payload)
}
violations = append(violations, sandbox.Violation{ violations = append(violations, sandbox.Violation{
Kind: kind, Kind: kind,
RawKind: payload.Kind, RawKind: payload.Kind,
@@ -170,9 +176,8 @@ func normalizeSeatbeltViolationKind(kind string) sandbox.ViolationKind {
// sandbox-exec denial verb embedded in raw. It returns the typed kind plus a // sandbox-exec denial verb embedded in raw. It returns the typed kind plus a
// canonical marker name (the one our own rules would emit for the same kind, // canonical marker name (the one our own rules would emit for the same kind,
// suitable for summarizeSeatbeltViolation). Only verbs that map to kinds // suitable for summarizeSeatbeltViolation). Only verbs that map to kinds
// scoreViolation and suggestOverride already understand are recognized; all // scoreViolation already understands are recognized; all others fall back to
// others fall back to (generic_deny, "", false) so the caller keeps the // (generic_deny, "", false) so the caller keeps the original classification.
// original classification.
func inferSeatbeltKindFromRawLog(raw string) (sandbox.ViolationKind, string, bool) { func inferSeatbeltKindFromRawLog(raw string) (sandbox.ViolationKind, string, bool) {
m := seatbeltDenyVerbPattern.FindStringSubmatch(raw) m := seatbeltDenyVerbPattern.FindStringSubmatch(raw)
if len(m) != 2 { if len(m) != 2 {
@@ -189,12 +194,29 @@ func inferSeatbeltKindFromRawLog(raw string) (sandbox.ViolationKind, string, boo
return sandbox.ViolationKindFSRead, "file-read", true return sandbox.ViolationKindFSRead, "file-read", true
case strings.HasPrefix(verb, "process-exec"): case strings.HasPrefix(verb, "process-exec"):
return sandbox.ViolationKindExec, "process-exec", true return sandbox.ViolationKindExec, "process-exec", true
case verb == "network-bind":
return sandbox.ViolationKindNetworkBind, "network-bind", true
case verb == "network-outbound":
return sandbox.ViolationKindNetworkConnect, "network-outbound", true
} }
return sandbox.ViolationKindGenericDeny, "", false return sandbox.ViolationKindGenericDeny, "", false
} }
func extractSeatbeltDeniedPath(raw string, payload *seatbeltLogPayload) string { func extractSeatbeltDeniedPath(raw string, payload *seatbeltLogPayload) string {
last := extractSeatbeltDeniedToken(raw, payload)
if last == "" || !looksLikeConcretePath(last) {
return ""
}
return last
}
// extractSeatbeltDeniedToken returns the last token of the sandbox-exec
// preamble before our marker — the denial operand. Unlike
// extractSeatbeltDeniedPath it does not require a path shape, so it also
// recovers network addresses such as "local:*:0" or "1.2.3.4:443".
func extractSeatbeltDeniedToken(raw string, payload *seatbeltLogPayload) string {
if payload == nil { if payload == nil {
return "" return ""
} }
@@ -216,13 +238,7 @@ func extractSeatbeltDeniedPath(raw string, payload *seatbeltLogPayload) string {
} }
last := strings.TrimSpace(fields[len(fields)-1]) last := strings.TrimSpace(fields[len(fields)-1])
last = strings.Trim(last, "\"',;:()[]{}") return strings.Trim(last, "\"',;:()[]{}")
if last == "" || !looksLikeConcretePath(last) {
return ""
}
return last
} }
func (s *seatbeltSandbox) queryLogs(start, end time.Time) ([]seatbeltLogEntry, error) { func (s *seatbeltSandbox) queryLogs(start, end time.Time) ([]seatbeltLogEntry, error) {
@@ -302,6 +318,16 @@ func summarizeSeatbeltViolation(kind, target string) string {
return fmt.Sprintf("rename or unlink denied: %s", target) return fmt.Sprintf("rename or unlink denied: %s", target)
case "process-exec": case "process-exec":
return fmt.Sprintf("process execution denied: %s", target) return fmt.Sprintf("process execution denied: %s", target)
case "network-bind":
if target == "" {
return "network bind denied"
}
return fmt.Sprintf("network bind denied: %s", target)
case "network-outbound":
if target == "" {
return "network connect denied"
}
return fmt.Sprintf("network connect denied: %s", target)
default: default:
if target == "" { if target == "" {
return "sandbox denied an operation" return "sandbox denied an operation"
@@ -107,9 +107,32 @@ func TestExtractSeatbeltViolationsRecoversTypedKindFromDefaultDeny(t *testing.T)
assert.Equal(t, "/Users/dev/project/.astro/types.d.ts", violations[0].Target) assert.Equal(t, "/Users/dev/project/.astro/types.d.ts", violations[0].Target)
assert.Equal(t, "write access denied: /Users/dev/project/.astro/types.d.ts", violations[0].RuleLabel) assert.Equal(t, "write access denied: /Users/dev/project/.astro/types.d.ts", violations[0].RuleLabel)
// Unrecognized verb (network-outbound) stays generic — no over-claiming. // Network denial: kind recovered from the verb, and the operand is an
assert.Equal(t, sandbox.ViolationKindGenericDeny, violations[1].Kind) // address rather than a path, so it comes from the raw-token fallback.
assert.Equal(t, sandbox.ViolationKindNetworkConnect, violations[1].Kind)
assert.Equal(t, "default", violations[1].RawKind) assert.Equal(t, "default", violations[1].RawKind)
assert.Equal(t, "1.2.3.4:443", violations[1].Target)
assert.Equal(t, "network connect denied: 1.2.3.4:443", violations[1].RuleLabel)
}
// Regression: a network-bind denial (e.g. httptest listeners under go test)
// must surface as a typed network violation with the denied address, not as a
// bare generic deny that loses primary ranking to incidental fs noise.
func TestExtractSeatbeltViolationsRecoversNetworkBind(t *testing.T) {
entries := []seatbeltLogEntry{
{
EventMessage: `Sandbox: scratch(36119) deny(1) network-bind local:*:0 ` +
seatbeltLogMessage("run-1", "default", ""),
Process: "scratch",
},
}
violations := extractSeatbeltViolations(entries, "run-1")
require.Len(t, violations, 1)
assert.Equal(t, sandbox.ViolationKindNetworkBind, violations[0].Kind)
assert.Equal(t, "default", violations[0].RawKind)
assert.Equal(t, "local:*:0", violations[0].Target)
assert.Equal(t, "network bind denied: local:*:0", violations[0].RuleLabel)
} }
func TestInferSeatbeltKindFromRawLog(t *testing.T) { func TestInferSeatbeltKindFromRawLog(t *testing.T) {
@@ -162,10 +185,18 @@ func TestInferSeatbeltKindFromRawLog(t *testing.T) {
wantOK: false, wantOK: false,
}, },
{ {
name: "network verb is not recognized", name: "network-outbound maps to network_connect",
raw: `Sandbox: node(1) deny(1) network-outbound 1.2.3.4:443`, raw: `Sandbox: node(1) deny(1) network-outbound 1.2.3.4:443`,
wantKind: sandbox.ViolationKindGenericDeny, wantKind: sandbox.ViolationKindNetworkConnect,
wantOK: false, wantLabel: "network-outbound",
wantOK: true,
},
{
name: "network-bind maps to network_bind",
raw: `Sandbox: scratch(1) deny(1) network-bind local:*:0`,
wantKind: sandbox.ViolationKindNetworkBind,
wantLabel: "network-bind",
wantOK: true,
}, },
{ {
name: "log without deny prefix is not recognized", name: "log without deny prefix is not recognized",
+5
View File
@@ -12,6 +12,11 @@ Restrictive policy for the npm ecosystem (npm, pnpm, yarn, bun).
Restrictive policy for the PyPI ecosystem (pip, pip3, pipx, poetry, uv, uvx). Restrictive policy for the PyPI ecosystem (pip, pip3, pipx, poetry, uv, uvx).
### go
Policy for the Go module ecosystem (go). Standalone profile: Go has a single
package manager, so there is no shared base to inherit from.
## Custom Policies ## Custom Policies
You can create custom sandbox policies by: You can create custom sandbox policies by:
+117
View File
@@ -0,0 +1,117 @@
name: go
description: Sandbox policy for the Go module ecosystem (go command)
package_managers:
- go
# go run executes the built program in the foreground and may need a TTY.
allow_pty: true
# Localhost-only listening (same as npx/uvx/pipx): go test commonly binds
# httptest servers, and go run workloads often serve locally. Non-localhost
# binds and outbound traffic are unaffected.
allow_network_bind: true
# .git/config stays blocked (default). go build embeds VCS info by default
# (-buildvcs=auto) which invokes git; if git fails on the blocked config, build
# with -buildvcs=false or set allow_git_config: true in a custom profile.
filesystem:
allow_read:
# Root read is required for toolchain discovery: GOROOT can live anywhere
# (/usr/local/go, Homebrew, distro packages, asdf, golang.org/dl SDKs).
# This is safe because deny rules have higher precedence and dangerous
# files (.env, .ssh, .aws, .gnupg, etc.) are blocked by mandatory deny
# patterns.
- /
- ${CWD}/**
- ${HOME}/go/**
- ${HOME}/.config/go/**
- ${HOME}/Library/Caches/go-build/**
- ${HOME}/.cache/go-build/**
allow_write:
# Note: ${TMPDIR} is automatically allowed when write restrictions are enabled (macOS)
# Note: On macOS, /tmp is a symlink to /private/tmp, so we need both
- /tmp/**
- /private/tmp/**
- /var/tmp/**
# go build/test write output binaries and generated files anywhere in the
# project (e.g. go build -o bin/app). Unlike npm/PyPI, the Go ecosystem has
# no install-time script execution: module code only runs via go run /
# go test / go generate, and credential files inside the project stay
# blocked by the mandatory deny patterns.
- ${CWD}/**
# Default GOPATH: module cache and sumdb cache (pkg/mod), go install
# target (bin).
- ${HOME}/go/**
# Default GOCACHE build cache (macOS, Linux)
- ${HOME}/Library/Caches/go-build/**
- ${HOME}/.cache/go-build/**
# Local telemetry counters written by the go tool on every invocation
- ${HOME}/Library/Application Support/go/telemetry/**
- ${HOME}/.config/go/telemetry/**
deny_read: []
deny_write:
# Additional system directories to protect
- /etc/**
- /usr/**
network:
# Per-host rules are NOT enforced on any platform. macOS Seatbelt and Linux
# Bubblewrap only make a binary decision: any allow_outbound entry enables
# ALL outbound traffic, and "*:*" in deny_outbound disables the network
# entirely only when allow_outbound is empty. The hosts below document the
# default Go module endpoints and keep the network enabled; actual module
# traffic control comes from the PMG proxy's fail-closed GOPROXY rewrite.
allow_outbound:
- proxy.golang.org:443
- sum.golang.org:443
- index.golang.org:443
deny_outbound:
- "*:*"
environment:
# Go toolchain variables (GOPROXY, GOFLAGS, CGO_*, CC, CXX) and the
# PMG-injected proxy/cert variables are not on the DANGEROUS_ENV_VARS scrub
# list, so they pass through without an allow entry. Deliberately no GO*
# re-allow here: that glob would also re-allow GOOGLE_* cloud credentials,
# which must stay scrubbed.
allow: []
process:
allow_exec:
# Go toolchain: official installer, distro packages, Homebrew
- /usr/local/go/**
- /usr/local/bin/go
- /usr/bin/go
- /usr/lib/go*/**
- /opt/homebrew/bin/go
- /opt/homebrew/Cellar/go/**
# golang.org/dl SDKs and version manager shims
- ${HOME}/sdk/go*/**
- ${HOME}/.asdf/shims/go
# Toolchains downloaded via GOTOOLCHAIN are exec'd from the module cache
- ${HOME}/go/pkg/mod/golang.org/toolchain@*/**
# go run / go test execute freshly built binaries from the build cache and
# the per-build temp work directory
- ${HOME}/Library/Caches/go-build/**
- ${HOME}/.cache/go-build/**
- /tmp/**
- ${TMPDIR}/**
# cgo toolchain and VCS stamping (-buildvcs)
- /usr/bin/gcc
- /usr/bin/clang
- /usr/bin/cc
- /usr/bin/git
- /usr/local/bin/git
# Required for shims (e.g., asdf) that use #!/usr/bin/env bash
- /bin/bash
- /bin/sh
- /usr/bin/env
deny_exec:
- /usr/bin/curl
- /usr/bin/wget
+2
View File
@@ -30,6 +30,7 @@ func TestProfileEnvContract(t *testing.T) {
"UV_PUBLISH_TOKEN=x", "UV_PUBLISH_TOKEN=x",
"POETRY_PYPI_TOKEN_PYPI=x", "POETRY_PYPI_TOKEN_PYPI=x",
"AWS_SECRET_ACCESS_KEY=x", "AWS_SECRET_ACCESS_KEY=x",
"GOOGLE_APPLICATION_CREDENTIALS=x",
"GITHUB_TOKEN=x", "GITHUB_TOKEN=x",
"OP_SERVICE_ACCOUNT_TOKEN=x", "OP_SERVICE_ACCOUNT_TOKEN=x",
"CLOUDFLARE_API_TOKEN=x", "CLOUDFLARE_API_TOKEN=x",
@@ -51,6 +52,7 @@ func TestProfileEnvContract(t *testing.T) {
{profile: "uv", wantKept: []string{"UV_PUBLISH_TOKEN"}}, {profile: "uv", wantKept: []string{"UV_PUBLISH_TOKEN"}},
{profile: "uvx", wantKept: []string{"UV_PUBLISH_TOKEN"}}, {profile: "uvx", wantKept: []string{"UV_PUBLISH_TOKEN"}},
{profile: "poetry", wantKept: []string{"POETRY_PYPI_TOKEN_PYPI"}}, {profile: "poetry", wantKept: []string{"POETRY_PYPI_TOKEN_PYPI"}},
{profile: "go", wantKept: []string{}},
} }
for _, tt := range tests { for _, tt := range tests {
+2
View File
@@ -92,6 +92,8 @@ func scoreViolation(driver DriverName, v Violation, cwd string) int {
score += 120 score += 120
case ViolationKindExec: case ViolationKindExec:
score += 110 score += 110
case ViolationKindNetworkBind, ViolationKindNetworkConnect:
score += 105
case ViolationKindFSDeleteOrRename: case ViolationKindFSDeleteOrRename:
score += 100 score += 100
case ViolationKindGenericDeny: case ViolationKindGenericDeny:
+28
View File
@@ -97,6 +97,34 @@ func TestPrimaryViolationPrefersConcreteProjectPathOverDefaultNoise(t *testing.T
assert.Equal(t, filepath.Join(cwd, ".env"), primary.Target) assert.Equal(t, filepath.Join(cwd, ".env"), primary.Target)
} }
// Regression: a network-bind denial (e.g. httptest listeners under go test)
// must outrank incidental fs noise like /dev/dtracehelper so the violations
// list and failure hint name the network denial, not the noise.
func TestPrimaryViolationPrefersNetworkDenialOverNoise(t *testing.T) {
report := &ViolationReport{
SandboxName: DriverSeatbelt,
Violations: []Violation{
{
Kind: ViolationKindFSWrite,
RawKind: "file-write",
Target: "/dev/dtracehelper",
RuleLabel: "write access denied: /dev/dtracehelper",
},
{
Kind: ViolationKindNetworkBind,
RawKind: "default",
Target: "local:*:0",
RuleLabel: "network bind denied: local:*:0",
},
},
}
primary := primaryViolation(report)
require.NotNil(t, primary)
assert.Equal(t, ViolationKindNetworkBind, primary.Kind)
assert.Equal(t, "local:*:0", primary.Target)
}
func TestPrimaryViolationPrefersLaterViolationOnScoreTie(t *testing.T) { func TestPrimaryViolationPrefersLaterViolationOnScoreTie(t *testing.T) {
report := &ViolationReport{ report := &ViolationReport{
SandboxName: DriverSeatbelt, SandboxName: DriverSeatbelt,
+15
View File
@@ -55,6 +55,21 @@ func WithPinnedVersions(pinned map[string]string) Option {
func New(t *testing.T, opts ...Option) *Harness { func New(t *testing.T, opts ...Option) *Harness {
t.Helper() t.Helper()
// The proxy's upstream transport honors HTTP(S)_PROXY from the environment
// (corporate proxy chaining). Inherited proxy env — a corporate proxy, or
// an outer `pmg go test` injecting HTTPS_PROXY into its child — would
// route the in-process proxy's upstream traffic out of the harness, so it
// is cleared for hermeticity. This must run before the first upstream
// request: net/http caches proxy env process-wide on first use.
for _, name := range []string{
"HTTP_PROXY", "http_proxy",
"HTTPS_PROXY", "https_proxy",
"ALL_PROXY", "all_proxy",
"NO_PROXY", "no_proxy",
} {
t.Setenv(name, "")
}
var o options var o options
for _, opt := range opts { for _, opt := range opts {
opt(&o) opt(&o)