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
+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{
Kind: 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
// canonical marker name (the one our own rules would emit for the same kind,
// suitable for summarizeSeatbeltViolation). Only verbs that map to kinds
// scoreViolation and suggestOverride already understand are recognized; all
// others fall back to (generic_deny, "", false) so the caller keeps the
// original classification.
// scoreViolation already understands are recognized; all others fall back to
// (generic_deny, "", false) so the caller keeps the original classification.
func inferSeatbeltKindFromRawLog(raw string) (sandbox.ViolationKind, string, bool) {
m := seatbeltDenyVerbPattern.FindStringSubmatch(raw)
if len(m) != 2 {
@@ -189,12 +194,29 @@ func inferSeatbeltKindFromRawLog(raw string) (sandbox.ViolationKind, string, boo
return sandbox.ViolationKindFSRead, "file-read", true
case strings.HasPrefix(verb, "process-exec"):
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
}
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 {
return ""
}
@@ -216,13 +238,7 @@ func extractSeatbeltDeniedPath(raw string, payload *seatbeltLogPayload) string {
}
last := strings.TrimSpace(fields[len(fields)-1])
last = strings.Trim(last, "\"',;:()[]{}")
if last == "" || !looksLikeConcretePath(last) {
return ""
}
return last
return strings.Trim(last, "\"',;:()[]{}")
}
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)
case "process-exec":
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:
if target == "" {
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, "write access denied: /Users/dev/project/.astro/types.d.ts", violations[0].RuleLabel)
// Unrecognized verb (network-outbound) stays generic — no over-claiming.
assert.Equal(t, sandbox.ViolationKindGenericDeny, violations[1].Kind)
// Network denial: kind recovered from the verb, and the operand is an
// 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, "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) {
@@ -162,10 +185,18 @@ func TestInferSeatbeltKindFromRawLog(t *testing.T) {
wantOK: false,
},
{
name: "network verb is not recognized",
raw: `Sandbox: node(1) deny(1) network-outbound 1.2.3.4:443`,
wantKind: sandbox.ViolationKindGenericDeny,
wantOK: false,
name: "network-outbound maps to network_connect",
raw: `Sandbox: node(1) deny(1) network-outbound 1.2.3.4:443`,
wantKind: sandbox.ViolationKindNetworkConnect,
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",
+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).
### 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
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",
"POETRY_PYPI_TOKEN_PYPI=x",
"AWS_SECRET_ACCESS_KEY=x",
"GOOGLE_APPLICATION_CREDENTIALS=x",
"GITHUB_TOKEN=x",
"OP_SERVICE_ACCOUNT_TOKEN=x",
"CLOUDFLARE_API_TOKEN=x",
@@ -51,6 +52,7 @@ func TestProfileEnvContract(t *testing.T) {
{profile: "uv", wantKept: []string{"UV_PUBLISH_TOKEN"}},
{profile: "uvx", wantKept: []string{"UV_PUBLISH_TOKEN"}},
{profile: "poetry", wantKept: []string{"POETRY_PYPI_TOKEN_PYPI"}},
{profile: "go", wantKept: []string{}},
}
for _, tt := range tests {
+2
View File
@@ -92,6 +92,8 @@ func scoreViolation(driver DriverName, v Violation, cwd string) int {
score += 120
case ViolationKindExec:
score += 110
case ViolationKindNetworkBind, ViolationKindNetworkConnect:
score += 105
case ViolationKindFSDeleteOrRename:
score += 100
case ViolationKindGenericDeny:
+28
View File
@@ -97,6 +97,34 @@ func TestPrimaryViolationPrefersConcreteProjectPathOverDefaultNoise(t *testing.T
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) {
report := &ViolationReport{
SandboxName: DriverSeatbelt,