mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
fix: Sandbox profile for Go ecosystem (#361)
* fix: Sandbox profile for Go ecosystem * fix: Sandbox violations for network bind
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user