Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions internal/nodeprofile/filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package nodeprofile

import "strings"

// This is the security boundary of the profiler, not a data-reduction step: a
// stack trace is a map of the customer's code structure, and this filter is what
// makes shipping one defensible (ADR 0011 §4, security.md §8). Its posture is the
// opposite of the scanner's infra deny-list (nodescan.ModuleFilter, which keeps
// by default): here we KEEP only what we can positively classify as safe and
// redact the rest, so a new or misclassified frame errs toward not leaving.
//
// Policy (decided in the slice-4 plan):
// - client frames (module path on the allow-list) -> keep
// - standard library / runtime / the workload's own
// main package (no domain in the package path) -> keep
// - kernel frames -> keep (N1: not the
// customer's code, and they make a profile readable)
// - third-party module frames (a domain, not allowed) -> redact by default,
// keep only if ThirdPartyKeep is configured
// - unsymbolized / other native frames -> redact
//
// Redaction (R2): a redacted frame is replaced by a single neutral placeholder,
// and consecutive redacted frames collapse into one, so the flame-graph shape is
// preserved but no identity — not even which dependency, nor its depth — leaves
// the node. Only kept frames and aggregate drop counts ever leave.

// RedactedFrame is the neutral placeholder that replaces a redacted frame. It
// carries no identity.
const RedactedFrame = "[filtered]"

// ThirdPartyPolicy controls whether third-party dependency frames are kept.
type ThirdPartyPolicy string

const (
// ThirdPartyDrop redacts third-party frames. It is the default (the zero
// value "" is treated as drop).
ThirdPartyDrop ThirdPartyPolicy = "drop"
// ThirdPartyKeep keeps third-party frames.
ThirdPartyKeep ThirdPartyPolicy = "keep"
)

// SymbolFilter reduces captured samples to allowed frames. It is stateless;
// counting is returned per call. The allow-list holds the customer's own Go
// module-path prefixes; it arrives from the node ConfigMap in a later slice.
type SymbolFilter struct {
allowed []string
thirdParty ThirdPartyPolicy
}

// NewSymbolFilter builds a filter. allowedModulePrefixes are the customer module
// paths to keep (e.g. "github.com/acme/app"); an empty list still keeps stdlib,
// main, and kernel frames, and redacts every third-party module.
func NewSymbolFilter(allowedModulePrefixes []string, thirdParty ThirdPartyPolicy) *SymbolFilter {
return &SymbolFilter{allowed: allowedModulePrefixes, thirdParty: thirdParty}
}

// FilterCounters are aggregate-only drop counts. They are safe to log and ship;
// they never carry the identity of a redacted frame (CLAUDE.md invariant 6).
type FilterCounters struct {
ThirdPartyDropped uint64 // third-party frames redacted
UnsymbolizedDropped uint64 // unsymbolized / other native frames redacted
SamplesFiltered uint64 // samples that had at least one frame redacted
}

// Filter returns a copy of samples with disallowed frames redacted, plus the
// aggregate counts. Input samples are not mutated.
func (f *SymbolFilter) Filter(samples []Sample) ([]Sample, FilterCounters) {
var c FilterCounters
out := make([]Sample, 0, len(samples))
for _, s := range samples {
fs, redacted := f.filterFrames(s.Frames, &c)
if redacted {
c.SamplesFiltered++
}
s.Frames = fs
out = append(out, s)
}
return out, c
}

// filterFrames applies the policy to one stack, collapsing consecutive redacted
// frames into a single placeholder (R2). It reports whether anything was
// redacted.
func (f *SymbolFilter) filterFrames(frames []Frame, c *FilterCounters) ([]Frame, bool) {
out := make([]Frame, 0, len(frames))
redactedAny := false
prevRedacted := false
for _, fr := range frames {
if f.keep(fr, c) {
out = append(out, fr)
prevRedacted = false
continue
}
redactedAny = true
if prevRedacted {
continue // collapse consecutive redacted frames
}
out = append(out, Frame{Function: RedactedFrame, Kind: "filtered"})
prevRedacted = true
}
return out, redactedAny
}

// keep classifies one frame and, when it redacts, increments the matching
// counter. It never records the frame's identity.
func (f *SymbolFilter) keep(fr Frame, c *FilterCounters) bool {
if fr.Kind == "kernel" {
return true // N1: kernel frames are not the customer's code
}
if fr.Function == "" {
c.UnsymbolizedDropped++
return false
}
pkg := packagePath(fr.Function)
if !hasDomain(pkg) {
// stdlib, runtime, internal/*, the workload's own main package, or a
// bare native/kernel symbol — none reveal the customer's Go structure.
return true
}
if f.isAllowed(pkg) {
return true
}
// A domain-bearing package that is not the customer's: third-party.
if f.thirdParty == ThirdPartyKeep {
return true
}
c.ThirdPartyDropped++
return false
}

func (f *SymbolFilter) isAllowed(pkg string) bool {
for _, prefix := range f.allowed {
if pkg == prefix || strings.HasPrefix(pkg, prefix+"/") {
return true
}
}
return false
}

// packagePath extracts the Go package import path from a symbolized function
// name such as "github.com/acme/app/svc.(*S).Do" -> "github.com/acme/app/svc",
// "runtime.mallocgc" -> "runtime", "main.process" -> "main". A name with neither
// "/" nor "." (a bare native/kernel symbol) is returned unchanged.
func packagePath(fn string) string {
slash := strings.LastIndex(fn, "/")
// The package name is the segment after the last "/", up to its first ".".
tail := fn[slash+1:]
if dot := strings.IndexByte(tail, '.'); dot >= 0 {
tail = tail[:dot]
}
if slash < 0 {
return tail
}
return fn[:slash+1] + tail
}

// hasDomain reports whether a package path's first segment looks like a domain
// (contains a "."), which is how Go distinguishes an external module path
// ("github.com/...") from a standard-library path ("net/http", "runtime").
func hasDomain(pkg string) bool {
first := pkg
if i := strings.IndexByte(pkg, '/'); i >= 0 {
first = pkg[:i]
}
return strings.Contains(first, ".")
}
128 changes: 128 additions & 0 deletions internal/nodeprofile/filter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package nodeprofile

import (
"strings"
"testing"
)

func goFrame(fn string) Frame { return Frame{Function: fn, Kind: "go"} }
func kernelFrame(fn string) Frame { return Frame{Function: fn, Kind: "kernel"} }

func fnsOf(frames []Frame) []string {
out := make([]string, len(frames))
for i, f := range frames {
out[i] = f.Function
}
return out
}

func TestPackagePath(t *testing.T) {
cases := map[string]string{
"github.com/acme/app/svc.(*S).Do": "github.com/acme/app/svc",
"runtime.mallocgc": "runtime",
"net/http.(*conn).serve": "net/http",
"main.process": "main",
"entry_SYSCALL_64": "entry_SYSCALL_64",
"golang.org/x/sync/errgroup.(*Group).Go": "golang.org/x/sync/errgroup",
}
for in, want := range cases {
if got := packagePath(in); got != want {
t.Errorf("packagePath(%q) = %q, want %q", in, got, want)
}
}
}

func TestHasDomain(t *testing.T) {
for pkg, want := range map[string]bool{
"github.com/acme/app": true,
"golang.org/x/sync": true,
"runtime": false,
"net/http": false,
"main": false,
"internal/abi": false,
"entry_SYSCALL_64": false,
} {
if got := hasDomain(pkg); got != want {
t.Errorf("hasDomain(%q) = %v, want %v", pkg, got, want)
}
}
}

func TestSymbolFilterClassifiesAndRedacts(t *testing.T) {
f := NewSymbolFilter([]string{"github.com/acme/app"}, ThirdPartyDrop)
in := []Sample{{
Value: 5,
Frames: []Frame{
goFrame("github.com/acme/app/svc.(*S).Do"), // client -> keep
goFrame("github.com/acme/app/internal/util.Mix"), // client -> keep
goFrame("github.com/thirdparty/lib/foo.Bar"), // third-party -> redact
goFrame("golang.org/x/sync/errgroup.(*Group).Go"), // third-party -> redact (collapses)
goFrame("runtime.mallocgc"), // stdlib -> keep
goFrame("main.process"), // workload main -> keep
kernelFrame("entry_SYSCALL_64"), // kernel -> keep
},
}}

out, c := f.Filter(in)
got := fnsOf(out[0].Frames)
want := []string{
"github.com/acme/app/svc.(*S).Do",
"github.com/acme/app/internal/util.Mix",
RedactedFrame, // the two consecutive third-party frames collapse to one
"runtime.mallocgc",
"main.process",
"entry_SYSCALL_64",
}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("frames = %v\n want %v", got, want)
}
if c.ThirdPartyDropped != 2 || c.SamplesFiltered != 1 {
t.Errorf("counters = %+v, want ThirdPartyDropped=2 SamplesFiltered=1", c)
}
for _, fn := range got {
if strings.Contains(fn, "thirdparty") || strings.Contains(fn, "golang.org") {
t.Errorf("redacted identity leaked into output: %q", fn)
}
}
}

func TestSymbolFilterThirdPartyKeep(t *testing.T) {
f := NewSymbolFilter([]string{"github.com/acme/app"}, ThirdPartyKeep)
out, c := f.Filter([]Sample{{Frames: []Frame{goFrame("github.com/thirdparty/lib.Bar")}}})
if out[0].Frames[0].Function != "github.com/thirdparty/lib.Bar" || c.ThirdPartyDropped != 0 {
t.Errorf("ThirdPartyKeep should keep third-party: %v, counters %+v", fnsOf(out[0].Frames), c)
}
}

func TestSymbolFilterUnsymbolizedRedacted(t *testing.T) {
f := NewSymbolFilter(nil, ThirdPartyDrop)
out, c := f.Filter([]Sample{{Frames: []Frame{
{Function: "", Kind: "native"},
{Function: "", Kind: "native"},
goFrame("main.run"),
}}})
got := fnsOf(out[0].Frames)
if strings.Join(got, "|") != RedactedFrame+"|main.run" {
t.Errorf("frames = %v, want [%s main.run]", got, RedactedFrame)
}
if c.UnsymbolizedDropped != 2 {
t.Errorf("UnsymbolizedDropped = %d, want 2", c.UnsymbolizedDropped)
}
}

// TestSymbolFilterEmptyAllowListStillKeepsStdlibAndMain guards the deny-by-default
// posture: with no allow-list, client third-party is gone but stdlib/main/kernel
// remain so the profile is still readable.
func TestSymbolFilterEmptyAllowListStillKeepsStdlibAndMain(t *testing.T) {
f := NewSymbolFilter(nil, ThirdPartyDrop)
out, _ := f.Filter([]Sample{{Frames: []Frame{
goFrame("main.process"),
goFrame("runtime.mallocgc"),
goFrame("github.com/acme/app/svc.Do"), // no allow-list -> third-party -> redact
}}})
got := fnsOf(out[0].Frames)
want := []string{"main.process", "runtime.mallocgc", RedactedFrame}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Errorf("frames = %v, want %v", got, want)
}
}