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
2 changes: 1 addition & 1 deletion charts/runtime-agent/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ produce its own output should not start.
{{- end -}}
{{- if eq .Values.profile "ebpf" -}}
{{- if not .Values.profiling.allowedModulePrefixes -}}
{{- fail "profile \"ebpf\" requires profiling.allowedModulePrefixes to name at least one Go module prefix. An empty allow-list admits no dependency frame, so the profiles would be near-empty — and not entirely: your own main package is kept regardless of the allow-list, so an empty one ships the least useful profile and not the safest (ADR 0011 §4, ADR 0039)." -}}
{{- fail "profile \"ebpf\" requires profiling.allowedModulePrefixes to name at least one Go module prefix. The allow-list admits your own domain-bearing modules; without it a profile keeps your main package and the standard library and drops your entire service layer, which is a worse profile with no warning attached (ADR 0011 §4, ADR 0041)." -}}
{{- end -}}
{{- end -}}
{{- end -}}
2 changes: 1 addition & 1 deletion docs/adr/0011-node-ebpf-cpu-profiling.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Date: 2026-08-11

Status: Accepted
Amends: 0009, 0010
Amended by: 0022, 0023, 0025, 0039
Amended by: 0022, 0023, 0025, 0039, 0041

Extends the node role of 0009 and the node→controller channel of 0010; scopes a
narrow, config-bounded exception to 0010 §1's "one level down" reply discipline
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0039-stated-limits-are-measured-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Date: 2026-08-26

Status: Accepted
Amended by: 0040
Amended by: 0040, 0041

Amends: 0009 §3, 0010 §Consequences, 0011 §Consequences, 0015 §1, 0025 §5

Expand Down
149 changes: 149 additions & 0 deletions docs/adr/0041-the-allow-list-bounds-dependencies-not-your-own-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# 0041. The symbol allow-list bounds dependencies, not the customer's own code; source paths do not ship

Date: 2026-08-26

Status: Accepted

Amends: 0011 §4, 0039 §5

Cuts the compiler-recorded source path down to a base name before it enters the
agent, and corrects what the symbol allow-list was ever meant to bound. No
change to RBAC, to the chart's privileges, or to which workloads are profiled.

## Context

ADR 0039 recorded two ways a frame gets past the symbol allow-list and deferred
closing both to a slice of its own. Building that slice showed the two are not
the same kind of thing, and only one of them is a defect.

**The exemption for the customer's own code is not a defect.** `SymbolFilter.keep`
returns early for any package whose path has no dot in its first segment, which
covers the standard library, the workload's own `main`, and any module declared
without a domain (`module acmecorp`). ADR 0011 §4 and `docs/security.md` §8 both
describe the policy as "kept only if the module path is on the allow-list", so
the implementation looked like it had drifted.

Closing that gap means requiring a customer to enumerate their own module paths
before they can see their own hot functions. Two things are wrong with it.

The first is that ADR 0025 already removed exactly this shape. It abolished the
profiling eligible set on the grounds that profiling scope *is* collection
scope, and that a second list to keep in step with the first is a trap rather
than a control. A customer excludes workloads with namespace filters and opt-out
annotations (ADR 0015, ADR 0028); that is the mechanism for "what do you
collect". Gating the customer's own `main` behind a second list reintroduces the
trap in the one place it is most visible: install the agent, allow the
namespace, and half the stack reads `[filtered]`.

The second is that the heuristic is sounder than its critics. A public Go module
path must begin with a domain or it cannot be fetched, so "no domain in the
first segment" means, in practice, *the standard library or code the customer
did not publish* — and both are what they installed the profiler to see.

Nor does keeping it widen anything. Profiling targets come from the controller's
admitted-pod index, so a controller — buggy or hostile — can only aim at
workloads the customer's own filters already admitted. Always keeping `main`
therefore exposes nothing beyond the function names of admitted workloads, which
is the product.

**The source path is a defect, and a plain one.** `traceReporter` copied
`f.SourceFile` verbatim, `keep` never looked at the field, and `Serialize` wrote
it into the shipped pprof as `Function.Filename`. A Go binary built without
`-trimpath` — the default — records the absolute path from the machine that
compiled it, so a shipped profile carried strings like

/home/jenkins/workspace/acme-payments/src/git.acme-internal.example/payments/api/server.go

That is the CI workspace, an internal VCS hostname, the customer's project name
and the layout of their source tree. It is not their code structure, which is
what a profile is for and what they agreed to ship. It is our sampling carrying
their build machine out of the cluster — the same class of string ADR 0019
excludes `-ldflags`, `-gcflags` and `-tags` from the scanner's build-settings
allow-list to avoid, one package away.

## Decision

**1. The allow-list bounds third-party and infrastructure frames.** It is not,
and was never usefully, a gate on the customer's own code. ADR 0011 §4's "kept
only if its Go module path is on the client allow-list" is corrected to: frames
of the customer's own code are kept — the standard library, the workload's
`main`, and modules declared without a domain — while frames of domain-bearing
modules that are not on the allow-list are redacted, third-party by default.

The load-bearing claim of ADR 0025 §5 survives unchanged, and is worth restating
because it is easy to read this as a weakening: the allow-list is still what
stops a hostile controller from extracting the structure of anything beyond the
customer's own admitted workloads. What changes is only the description of which
frames it was ever bounding.

**2. The source file is reduced to its base name, at the seam.** `server.go`,
never a directory. Cut in `traceReporter.ReportTraceEvent`, where the profiler
hands the frame over, so the full path never enters the buffer and there is no
stage at which the agent holds it — the filter-early rule of CLAUDE.md
invariant 4, applied to the one field that had escaped it.

Both separators are cut, not the host's: the path was recorded by whatever
machine compiled the binary, which need not be the one running it.

**3. The line number stays.** It is a bare integer that says nothing about the
machine that compiled the binary, and with the base name it is what a flame
graph shows. Dropping it would cost the customer something and buy nothing.

**4. The property is asserted on the shipped bytes, not only at the seam.** A
test parses a serialized profile and requires that no `Function.Filename`
contains a path separator. The reporter is what cuts the path and the serializer
is a package away; an assertion only at the seam would miss a second producer of
frames, and this one would still catch it.

**5. `docs/security.md` §7.2 and §8 say both of these.** They currently name
both as open gaps, per ADR 0039. One is closed and the other was never a gap,
and the document should not carry a warning about a design decision as though it
were an outstanding defect.

**6. The chart still refuses an empty allow-list.** It is tempting to conclude
that an empty list is now harmless, since the customer's own code is kept
regardless. It is not: a customer whose modules are `github.com/acme/…` would
get `main` and the standard library and lose their entire service layer, which
is a worse profile with no warning. The refusal stays; only its wording is
corrected, since it currently claims the profiler would "ship nothing".

## Consequences

**Easier.** A customer installs the agent, allows a namespace, and sees their
own functions — with no second list to maintain and no support ticket asking
why half the stack is redacted. The allow-list now does one job and the document
describes that job.

**Harder / given up.** Less than it first appears, and the reason is worth
recording because "three of my files are called `server.go`" is the obvious
objection.

For Go frames nothing is lost. A symbolized Go function name carries its full
package path, and a Go package is a directory, so two files of the same name
cannot share one. `github.com/acme/app/api.(*Server).Serve` plus `server.go`
identifies the file exactly, and three `server.go` in three packages stay three
distinct `Function` entries in the shipped profile — measured, not assumed. What
the base name removes is only the prefix *before* the package path, which is
precisely the build machine's part of it.

What is genuinely lost is for frames whose name carries no package path — bare
native symbols from libc or the kernel. There the base name is the only file
hint and can now collide with an unrelated one. Those frames are not the
customer's code: unsymbolized frames are redacted anyway, and kernel frames are
kept for readability rather than for their filenames.

Anyone who wanted to map a profile back onto a source tree by absolute path
loses that. It was never something to offer — it worked only because the
customer's build machine was leaking into their telemetry.

The base name is a reduction, not a redaction: a filename is still a string the
customer wrote, and `payroll_export.go` says something. That is the same
category as the function names beside it, which is the thing the profiler exists
to ship, so no further reduction is proposed.

**Not changed.** No RBAC, no privilege, no chart template beyond one refusal
message, and no change to which workloads are profiled or which frames are
redacted. Only the path is shorter.

This ADR records decisions implemented in the same pull request, per the process
in [`README.md`](README.md).
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,4 @@ line to paste.
- [0038. Vulnerabilities are gated on reachability, not on the module graph](0038-reachability-is-the-gate.md)
- [0039. A stated limit is a measured limit, or it is marked](0039-stated-limits-are-measured-limits.md)
- [0040. The channel authenticates a node, not just the node role](0040-the-channel-authenticates-a-node.md)
- [0041. The symbol allow-list bounds dependencies, not the customer's own code; source paths do not ship](0041-the-allow-list-bounds-dependencies-not-your-own-code.md)
83 changes: 46 additions & 37 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,26 +497,33 @@ controller cannot reach past them, however hostile — and the symbol allow-list
is the load-bearing one: it is the reason a stack trace is defensible to ship at
all, and it is enforced on the node from a file your Helm release owns.

**Two things get past the allow-list today, and they are gaps rather than
design** (ADR 0039; the fix is a separate change and will carry its own
decision record):

- **The workload's own `main` package is kept unconditionally**, before the
allow-list is consulted, along with any module whose path has no dot in its
first segment — `module acmecorp` rather than `module github.com/acme/…`. The
code treats "no domain" as "standard library or runtime, carries no structure
of your code"; for `main` that is wrong, since a Go service's handlers, jobs
and business functions live there. So `main.(*Server).handleCharge` ships even
with an empty allow-list.
- **Source file paths and line numbers ship alongside function names.** The
filter classifies a frame on its function and never inspects its file, and the
file is written into the pprof as `Function.Filename`. In a binary built
without `-trimpath` — the Go default — that path carries the build machine's
directory layout and often an internal VCS hostname.

Until both are closed, treat the allow-list as bounding *dependency and
third-party* frames, which it does exactly, and not as bounding your own
application's identifiers.
**What the allow-list bounds is dependencies, not your own code** (ADR 0041).
Your `main` package is kept without consulting it, and so is any module whose
path has no dot in its first segment — `module acmecorp` rather than
`module github.com/acme/…`. That is deliberate: a public Go module path must
begin with a domain or it could not be fetched, so "no domain" means the
standard library or code you did not publish, and you installed the profiler to
see the latter.

The alternative was to make you list your own module paths before your own hot
functions appeared, which is the second list ADR 0025 removed — profiling scope
is collection scope, and what you exclude, you exclude with namespace filters
and opt-out annotations
([§11](#11-your-controls-and-what-the-agent-says-about-itself)), not here.

This does not widen what a hostile controller could extract. Profiling targets
come from the controller's already-filtered pod index, so it can only aim at
workloads your filters admitted; keeping `main` exposes the function names of
those workloads and nothing else, which is what the profile is.

**Source paths do not leave the node.** A frame carries the file's *base name*
only — `server.go`, never a directory — and the base name is taken at the point
the profiler hands the frame over, so the full path is never held anywhere in
the agent. This matters because a Go binary built without `-trimpath`, which is
the default, records the absolute path of the machine that compiled it: without
this, a profile would carry your CI workspace, your internal VCS hostname and
the layout of your source tree. Line numbers are kept; they say nothing about
that machine.

---

Expand Down Expand Up @@ -764,23 +771,25 @@ ServiceAccount, stays in the agent's log and is never part of a payload.
### Profile filtering (applies on the node, before egress — ADR 0011)

- Stack frames are filtered by Go module path on the node that captured them.
The policy has three parts: frames whose module path is on **your client
allow-list** are kept; **standard-library and `runtime` frames are always
kept** (they carry no structure of your code and make a profile readable);
**third-party dependency frames are governed by config and dropped by
default.** Everything else (Kubernetes components, service meshes,
observability stacks, this agent itself) is dropped. Only the kept frames and
an aggregate count of what was dropped leave the node — never the identities
of dropped functions.
- **Two exemptions to that policy, stated because they are not obvious from it**
(ADR 0039, and see the discussion in
[§7.2](#72-the-ebpf-cpu-profiler-opt-in-ebpf-profile-adr-0011)): the always-kept
class is implemented as "module path with no dot in its first segment", which
admits **your own `main` package** and any module named without a domain; and
each kept frame carries its **source file path and line number**, which the
filter does not examine. Both are being closed; until then, a shipped profile
can name your application's own functions and the file paths they were
compiled from, regardless of the allow-list.
The policy has three parts: **your own code is kept** — the standard library
and `runtime`, your `main` package, and any module declared without a domain
in its path, since a public module path must begin with one; frames whose
module path is on **your client allow-list** are kept; **third-party
dependency frames are governed by config and dropped by default.** Everything
else (Kubernetes components, service meshes, observability stacks, this agent
itself) is dropped. Only the kept frames and an aggregate count of what was
dropped leave the node — never the identities of dropped functions.
- So the allow-list is what bounds **dependencies**, not your own identifiers
(ADR 0041). What you exclude from collection entirely, you exclude with
namespace filters and opt-out annotations, and profiling scope follows
collection scope — there is no second list here
([§7.2](#72-the-ebpf-cpu-profiler-opt-in-ebpf-profile-adr-0011), ADR 0025).
- A kept frame carries its **source file's base name and line number** —
`server.go`, never a directory. The base name is taken where the profiler
hands the frame over, so the compiler-recorded path never enters the agent at
all. Without that, a binary built without `-trimpath` (the Go default) would
ship the absolute path of the machine that compiled it: CI workspace,
internal VCS hostname, the layout of your source tree.
- eBPF profiles are **validated on the node before they are queued to leave**: a
profile is shipped only if it parses, carries a `cpu`/`nanoseconds` sample
type, and has a service (non-`runtime.*`) function in its top. A profile that
Expand Down
36 changes: 23 additions & 13 deletions internal/nodeprofile/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ import "strings"
// 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):
// What it bounds is dependencies, not the customer's own code (ADR 0041): a
// module the customer did not publish has no domain in its path, and is kept for
// the same reason the standard library is — they installed the profiler to see
// it. The allow-list decides which *domain-bearing* modules may leave.
//
// Policy:
// - 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
// main package / a module declared without a domain
// (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,
Expand Down Expand Up @@ -113,18 +119,22 @@ func (f *SymbolFilter) keep(fr Frame, c *FilterCounters) bool {
}
pkg := packagePath(fr.Function)
if !hasDomain(pkg) {
// stdlib, runtime, internal/*, the workload's own main package, or a
// bare native/kernel symbol.
// stdlib, runtime, internal/*, the workload's own main package, a
// module declared without a domain, or a bare native/kernel symbol.
//
// This branch is wider than "the standard library", and that is the
// decision rather than an oversight (ADR 0041). A public Go module path
// has to begin with a domain or it cannot be fetched, so "no domain"
// means the standard library or code the customer did not publish —
// their `main`, their private modules. Both are what they installed the
// profiler to see.
//
// This exemption is wider than it should be, and saying so is the point
// of the note (ADR 0039). It is written as "no dot in the first path
// segment", which is true of the standard library — those frames carry
// no structure of the customer's code — and also true of `main.*` and
// of any module declared without a domain, which carry a great deal of
// it: handlers, jobs, business functions. Those reach a shipped profile
// regardless of the allow-list, including when the allow-list is empty.
// Narrowing this is a behaviour change and belongs to its own decision
// record; until then docs/security.md §7.2 and §8 name the gap.
// Gating those behind the allow-list would make a customer enumerate
// their own module paths before their own hot functions appeared, which
// is the second list ADR 0025 abolished: profiling scope is collection
// scope, and workloads are excluded with namespace filters and opt-out
// annotations, not here. What the allow-list bounds is the frames of
// domain-bearing modules that are not theirs.
return true
}
if f.isAllowed(pkg) {
Expand Down
Loading