mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
264 lines
8.9 KiB
Go
264 lines
8.9 KiB
Go
//go:build darwin
|
|
// +build darwin
|
|
|
|
package platform
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/safedep/pmg/sandbox"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestParseSeatbeltLogPayload(t *testing.T) {
|
|
msg := `Sandbox: node(123) deny(1) file-write-data /Users/dev/project/.env ` +
|
|
seatbeltLogMessage("run-1", "file-write", "/Users/dev/project/.env")
|
|
|
|
payload, ok := parseSeatbeltLogPayload(msg)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "run-1", payload.RunID)
|
|
assert.Equal(t, "file-write", payload.Kind)
|
|
assert.Equal(t, "/Users/dev/project/.env", payload.Target)
|
|
}
|
|
|
|
func TestParseSeatbeltLogPayloadEmptyTarget(t *testing.T) {
|
|
msg := `Sandbox: node(123) deny(1) default ` + seatbeltLogMessage("run-1", "default", "")
|
|
|
|
payload, ok := parseSeatbeltLogPayload(msg)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "run-1", payload.RunID)
|
|
assert.Equal(t, "default", payload.Kind)
|
|
assert.Empty(t, payload.Target)
|
|
}
|
|
|
|
func TestExtractSeatbeltDeniedPath(t *testing.T) {
|
|
payload := &seatbeltLogPayload{
|
|
RunID: "run-1",
|
|
Kind: "file-read",
|
|
Target: "**/.env",
|
|
}
|
|
|
|
raw := `Sandbox: node(123) deny(1) file-read-data ./.env ` + seatbeltLogMessage("run-1", "file-read", "**/.env")
|
|
assert.Equal(t, "./.env", extractSeatbeltDeniedPath(raw, payload))
|
|
}
|
|
|
|
func TestDecodeSeatbeltLogEntries(t *testing.T) {
|
|
data := []byte(`[
|
|
{"eventMessage":"entry-1","process":"node"},
|
|
{"eventMessage":"entry-2","process":"npm"}
|
|
]`)
|
|
|
|
entries, err := decodeSeatbeltLogEntries(data)
|
|
require.NoError(t, err)
|
|
require.Len(t, entries, 2)
|
|
assert.Equal(t, "entry-1", entries[0].EventMessage)
|
|
assert.Equal(t, "npm", entries[1].Process)
|
|
}
|
|
|
|
func TestExtractSeatbeltViolations(t *testing.T) {
|
|
entries := []seatbeltLogEntry{
|
|
{
|
|
EventMessage: "Sandbox deny " + seatbeltLogMessage("run-1", "file-read", "/tmp/.env"),
|
|
Process: "node",
|
|
},
|
|
{
|
|
EventMessage: "Sandbox deny " + seatbeltLogMessage("run-2", "file-write", "/tmp/out"),
|
|
Process: "npm",
|
|
},
|
|
}
|
|
|
|
violations := extractSeatbeltViolations(entries, "run-1")
|
|
require.Len(t, violations, 1)
|
|
assert.Equal(t, sandbox.ViolationKindFSRead, violations[0].Kind)
|
|
assert.Equal(t, "file-read", violations[0].RawKind)
|
|
assert.Equal(t, "/tmp/.env", violations[0].Target)
|
|
assert.Equal(t, "/tmp/.env", violations[0].RuleTarget)
|
|
assert.Equal(t, "node", violations[0].Process)
|
|
}
|
|
|
|
// Regression: a denial that hits the catch-all `(deny default ...)` rule
|
|
// must still be classified as the typed kind (and get a typed label) by
|
|
// recovering the verb from the sandbox-exec preamble. Without this, the
|
|
// actual failure-causing operation (e.g. a write) gets buried below
|
|
// incidental typed reads in primaryViolation ranking.
|
|
func TestExtractSeatbeltViolationsRecoversTypedKindFromDefaultDeny(t *testing.T) {
|
|
entries := []seatbeltLogEntry{
|
|
{
|
|
EventMessage: `Sandbox: node(59437) deny(1) file-write-data /Users/dev/project/.astro/types.d.ts ` +
|
|
seatbeltLogMessage("run-1", "default", ""),
|
|
Process: "node",
|
|
},
|
|
{
|
|
EventMessage: `Sandbox: node(59438) deny(1) network-outbound 1.2.3.4:443 ` +
|
|
seatbeltLogMessage("run-1", "default", ""),
|
|
Process: "node",
|
|
},
|
|
}
|
|
|
|
violations := extractSeatbeltViolations(entries, "run-1")
|
|
require.Len(t, violations, 2)
|
|
|
|
// File write hit the catch-all deny — Kind is recovered, RawKind stays
|
|
// "default" to preserve the "catch-all match" signal, and the label
|
|
// reflects the recovered kind so users see "write access denied".
|
|
assert.Equal(t, sandbox.ViolationKindFSWrite, violations[0].Kind)
|
|
assert.Equal(t, "default", violations[0].RawKind)
|
|
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)
|
|
|
|
// 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) {
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
wantKind sandbox.ViolationKind
|
|
wantLabel string
|
|
wantOK bool
|
|
}{
|
|
{
|
|
name: "file-write-data maps to fs_write",
|
|
raw: `Sandbox: node(1) deny(1) file-write-data /tmp/x`,
|
|
wantKind: sandbox.ViolationKindFSWrite,
|
|
wantLabel: "file-write",
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "file-write-unlink maps to fs_delete_or_rename",
|
|
raw: `Sandbox: node(1) deny(1) file-write-unlink /tmp/x`,
|
|
wantKind: sandbox.ViolationKindFSDeleteOrRename,
|
|
wantLabel: "file-write-unlink",
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "file-rename maps to fs_delete_or_rename",
|
|
raw: `Sandbox: node(1) deny(1) file-rename /tmp/a`,
|
|
wantKind: sandbox.ViolationKindFSDeleteOrRename,
|
|
wantLabel: "file-write-unlink",
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "file-read-metadata maps to fs_read",
|
|
raw: `Sandbox: node(1) deny(1) file-read-metadata /tmp/x`,
|
|
wantKind: sandbox.ViolationKindFSRead,
|
|
wantLabel: "file-read",
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "process-exec maps to exec",
|
|
raw: `Sandbox: node(1) deny(1) process-exec /usr/bin/curl`,
|
|
wantKind: sandbox.ViolationKindExec,
|
|
wantLabel: "process-exec",
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "process-fork is not exec",
|
|
raw: `Sandbox: node(1) deny(1) process-fork`,
|
|
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",
|
|
raw: `something unrelated`,
|
|
wantKind: sandbox.ViolationKindGenericDeny,
|
|
wantOK: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
kind, label, ok := inferSeatbeltKindFromRawLog(tc.raw)
|
|
assert.Equal(t, tc.wantOK, ok)
|
|
assert.Equal(t, tc.wantKind, kind)
|
|
if tc.wantOK {
|
|
assert.Equal(t, tc.wantLabel, label)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Normative copy, quoted by docs (M0.6); changes here are breaking.
|
|
const lockdownDirectDenialMessage = "direct network access blocked by network_via_proxy_only — traffic must flow through the PMG proxy (a tool may have ignored HTTP_PROXY/HTTPS_PROXY)"
|
|
|
|
func TestExtractSeatbeltViolationsLockdownDirectDenial(t *testing.T) {
|
|
entries := []seatbeltLogEntry{
|
|
{
|
|
EventMessage: `Sandbox: curl(123) deny(1) network-outbound ` +
|
|
seatbeltLogMessage("run-1", "network-outbound", "direct"),
|
|
Process: "curl",
|
|
},
|
|
{
|
|
EventMessage: `Sandbox: curl(124) deny(1) network-outbound ` +
|
|
seatbeltLogMessage("run-1", "network-outbound", "1.2.3.4:443"),
|
|
Process: "curl",
|
|
},
|
|
}
|
|
|
|
violations := extractSeatbeltViolations(entries, "run-1")
|
|
require.Len(t, violations, 2)
|
|
|
|
assert.Equal(t, sandbox.ViolationKindNetworkConnect, violations[0].Kind)
|
|
assert.Equal(t, lockdownDirectDenialMessage, violations[0].RuleLabel)
|
|
|
|
assert.Equal(t, sandbox.ViolationKindNetworkConnect, violations[1].Kind)
|
|
assert.Equal(t, "network connect denied: 1.2.3.4:443", violations[1].RuleLabel)
|
|
}
|
|
|
|
func TestExtractSeatbeltViolationsLockdownDenialWithPathOperand(t *testing.T) {
|
|
entries := []seatbeltLogEntry{
|
|
{
|
|
EventMessage: `Sandbox: node(125) deny(1) network-outbound /private/var/run/mDNSResponder ` +
|
|
seatbeltLogMessage("run-1", "network-outbound", "direct"),
|
|
Process: "node",
|
|
},
|
|
}
|
|
|
|
violations := extractSeatbeltViolations(entries, "run-1")
|
|
require.Len(t, violations, 1)
|
|
|
|
assert.Equal(t, sandbox.ViolationKindNetworkConnect, violations[0].Kind)
|
|
assert.Equal(t, "/private/var/run/mDNSResponder", violations[0].Target)
|
|
assert.Equal(t, lockdownDirectDenialMessage, violations[0].RuleLabel)
|
|
}
|