feat(networkstream): process attribution on the network stream (SUB-7786) - #879
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughChangesThe network stream now attributes network and DNS events to processes, isolates flush snapshots, and builds bounded wire payloads with deduplicated process trees. Tests cover attribution, cancellation, copying, serialization limits, deterministic selection, and UTF-8 handling. Documentation and the Network stream attribution
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Producer
participant NetworkStream
participant BuildWireStream
participant NotificationChannel
participant HTTP
Producer->>NetworkStream: submit process-aware events
NetworkStream->>NetworkStream: snapshot and clear live storage
NetworkStream->>BuildWireStream: build bounded wire payload
NetworkStream->>NotificationChannel: send intact snapshot or cancel on shutdown
BuildWireStream->>HTTP: send events and selected process trees
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
pkg/networkstream/v1/network_stream.go (1)
209-214: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winSkip the wire copy when the HTTP exporter is disabled.
buildWireStreamperforms every tree deep-copy, command-line cap and size estimate for the flush.sendNetworkEventthen returns immediately when!ns.cfg.KubernetesMode || ns.cfg.Exporters.HTTPExporterConfig == nil(Line 484). Outside Kubernetes mode the HTTP export is disabled entirely, so this work is discarded on every interval on host and ECS deployments.Gate the derivation on the same condition that
sendNetworkEventuses.♻️ Proposed change
Add a helper next to
sendNetworkEvent:func (ns *NetworkStream) httpExportEnabled() bool { return ns.cfg.KubernetesMode && ns.cfg.Exporters.HTTPExporterConfig != nil }Then derive the wire copy conditionally:
- wire := buildWireStream(snapshot) + var wire *armotypes.NetworkStream + if ns.httpExportEnabled() { + wire = buildWireStream(snapshot) + }and guard the send:
- if err := ns.sendNetworkEvent(wire); err != nil { + if wire != nil { + if err := ns.sendNetworkEvent(wire); err != nil { logger.L().Error("NetworkStream - failed to send network events", helpers.Error(err)) + } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@pkg/networkstream/v1/network_stream.go` around lines 209 - 214, Gate the expensive buildWireStream call in the flush path using a new NetworkStream.httpExportEnabled helper that returns true only when KubernetesMode is enabled and HTTPExporterConfig is non-nil. Update sendNetworkEvent to use the same helper, ensuring wire derivation and sending share identical enablement conditions.pkg/networkstream/v1/wire.go (1)
159-163: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winEqual-depth collisions still select a tree by map iteration order.
The comment at Lines 144-145 states that the payload must not depend on Go's randomised map iteration order. The strict
depth > candidate.depthcomparison keeps the first-visited tree when two distinct trees for one ref have equal depth. The retained tree, and thereforeestBytes, then varies between runs. BecauseestBytesfeeds the budget ranking inselectProcessTrees, the shipped set can also vary in that case.Add a deterministic secondary tie-break, for example the larger
estBytesor the wider node count.♻️ Proposed tie-break on estimated size
- if depth := chainDepth(&event.ProcessTree.ProcessTree); !seen || depth > candidate.depth { - candidate.tree = event.ProcessTree - candidate.depth = depth - candidate.estBytes = estimateTreeBytes(event.ProcessTree) - } + depth := chainDepth(&event.ProcessTree.ProcessTree) + if !seen || depth > candidate.depth { + candidate.tree = event.ProcessTree + candidate.depth = depth + candidate.estBytes = estimateTreeBytes(event.ProcessTree) + } else if depth == candidate.depth { + // Equal depth: keep the larger estimate so the choice is + // independent of map iteration order. + if est := estimateTreeBytes(event.ProcessTree); est > candidate.estBytes { + candidate.tree = event.ProcessTree + candidate.estBytes = est + } + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@pkg/networkstream/v1/wire.go` around lines 159 - 163, Update the candidate selection condition in the process-tree handling block to deterministically resolve equal-depth collisions, using a stable secondary criterion such as larger estimateTreeBytes(event.ProcessTree). Ensure both the selected candidate.tree and candidate.estBytes are updated when the secondary tie-break wins, so selectProcessTrees produces map-order-independent results.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@pkg/networkstream/v1/wire.go`:
- Around line 273-278: Suppress SA1019 at the intentional legacy Children reads
without changing behavior. In pkg/networkstream/v1/wire.go#L273-L278,
`#L356-L371`, and `#L411-L415`, add targeted nolint:staticcheck annotations to the
specified node.Children, dst.Children, and src.Children accesses, documenting
charging, normalization, or depth-counting intent. In
pkg/networkstream/v1/wire_test.go#L164-L188, annotate
shared.ProcessTree.Children to document that the assertion verifies the source
slice remains populated.
- Around line 256-280: Update estimateProcessBytes to include each child’s
serialized ChildrenMap key cost during the ChildrenMap recursion, accounting for
the child Comm and PID text representation as escaped output. Apply the
equivalent child Comm key cost in the legacy Children recursion so both paths
include key overhead before recursing.
---
Nitpick comments:
In `@pkg/networkstream/v1/network_stream.go`:
- Around line 209-214: Gate the expensive buildWireStream call in the flush path
using a new NetworkStream.httpExportEnabled helper that returns true only when
KubernetesMode is enabled and HTTPExporterConfig is non-nil. Update
sendNetworkEvent to use the same helper, ensuring wire derivation and sending
share identical enablement conditions.
In `@pkg/networkstream/v1/wire.go`:
- Around line 159-163: Update the candidate selection condition in the
process-tree handling block to deterministically resolve equal-depth collisions,
using a stable secondary criterion such as larger
estimateTreeBytes(event.ProcessTree). Ensure both the selected candidate.tree
and candidate.estBytes are updated when the secondary tie-break wins, so
selectProcessTrees produces map-order-independent results.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: aff823fb-17b6-4ec6-9e56-5b30dcfd3ec0
⛔ Files ignored due to path filters (1)
go.sumis excluded by!**/*.sum
📒 Files selected for processing (6)
docs/features/network-stream-process-attribution.mdgo.modpkg/networkstream/v1/network_stream.gopkg/networkstream/v1/network_stream_test.gopkg/networkstream/v1/wire.gopkg/networkstream/v1/wire_test.go
…(SUB-7786) v0.0.696 predates the process-attribution schema (ProcessRef, NetworkStream.Processes, ProcessAttributionVersion). v0.0.742 is the exact tag carrying it — v0.0.741 does not, so a newer tag is not evidence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
… key (SUB-7786) The per-batch connection key was address/port/protocol with first-writer-wins, so a second process connecting to the same endpoint was silently DISCARDED -- data loss, not merely misattribution. The same held for two processes resolving one domain. The key now carries the process ref, so distinct processes coexist while first-writer-wins is retained per process. The ref is appended, never reordered: an unattributed key stays byte-identical to the old format and can never collide with an attributed one. StartTimeNs is boot-relative nanoseconds emitted verbatim; zero is legal pid-only identity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
…ees, sleep removed (SUB-7786) The flush handed the notification channel the LIVE storage struct, then -- still holding eventsStorageMutex -- slept 100 ms and stripped every process tree from the maps the consumer was reading. private-node-agent's host network sensor reads outbound.ProcessTree off that channel, so the sleep was the only thing standing between it and having its data erased mid-read. A test proved worse: an event recorded 300 ms AFTER the flush appeared in the already-delivered snapshot, because the clear loop and the consumer shared the same maps. snapshotNetworkStream now allocates its own event maps, so the consumer's view is immune to everything the producer does next. Trees are shared by pointer, not walked: a tree is immutable once attached, which keeps the lock body to O(entities + connections) struct copies. Both sends move outside the lock, and the 100 ms sleep is deleted rather than shortened -- there is no shared state left to race on. removeProcessTreeFromEvents goes with it; the wire copy is where trees leave the payload from here. Adds docs/features/network-stream-process-attribution.md, covering the emitted schema, the batch key, the channel contract and the lock discipline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
…tribution marker (SUB-7786) buildWireStream derives the HTTP payload from the flush snapshot: per-event trees move into the message-scoped Processes map, one copy per distinct ProcessRef, and each event keeps only its ref. Trees dominate the payload, so shipping them once per process rather than once per connection is the size decision; the duplicated ref bytes are budgeted. ProcessAttributionVersion is stamped unconditionally -- an empty Processes map is not a capability signal, so a sensor that ran and found nothing must stay distinguishable from one that predates attribution. On collision the deeper chain wins: the tree cache TTL (1 min) is shorter than the flush interval (2 min), so two lookups for one process inside one interval can return chains with different ancestry resolved. First-wins would discard the richer chain and make the payload depend on map iteration order. Command lines are capped at 1024 bytes with a visible marker, never splitting a rune. cmdline is ~40% of a tree's bytes and unbounded, so this is what bounds the payload tail rather than an optimisation. The copy is read-only by hand rather than via armotypes.Process.DeepCopy, which mutates its receiver: it calls MigrateToMap -- allocating ChildrenMap and nilling Children -- on itself and on every child it recurses into. Those nodes are shared with the process-tree manager's LRU cache, the legacy alert paths and the notification-channel consumer, so writing to them from the flush goroutine would be a data race that also strips the uncapped values those consumers rely on. copyCappedProcess normalises the deprecated Children slice on read instead, and every walk is depth-bounded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
…e channel (SUB-7786) Moving the notification-channel send out of eventsStorageMutex removed the stall on event recording but left the send itself blocking. A consumer that stops reading would pin the flush goroutine past ctx cancellation, which the previous under-the-lock send could not select against. The send stays blocking, so a slow consumer still applies backpressure rather than silently losing traffic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
Derive the wire copy BEFORE handing the snapshot to the notification channel. Both were already outside the lock and buildWireStream is read-only, so this was not live -- but the ordering made the producer re-read maps the consumer already owned, resting on an out-of-repo guarantee that the consumer never writes to what it receives. Reversing the two lines removes the dependency entirely. capTreeCopy now copies the ProcessTree wrapper wholesale instead of field-listing it, so a field added to the wrapper later cannot be silently dropped -- the exact trap the three existing process copiers fell into. It also no longer assumes the inner copy is non-nil. Tests: two of the claims were not actually pinned. TestNoTickScaling asserted only that buildWireStream does not rewrite an already-correct literal, so it passed with a /10_000_000 injected into processRefFor, where such a bug would live; it now runs the whole producer path. The shutdown-honouring select added in ad55206 was covered by nothing -- every channel test buffers so the producer never blocks -- so a plain blocking send passed the suite; TestFlush_BlockedChannelSendHonoursShutdown enters the blocked path and fails against that mutation. Also replaced a fixed sleep with a poll on an observable signal, and covered the unattributed DNS key and the nil-manager branch. Docs: the key-collision claim was categorical but holds only for the structured IP form, not for unstructured DNS names; and the lock-discipline table described the flush while omitting that handleNetworkEvent holds the same mutex across an unbounded net.LookupAddr (pre-existing, untouched here). Dropped the two stale armoapi-go v0.0.696 go.sum lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
…e size observable (SUB-7786) Attribution changed what sizes the payload. The old batch key collapsed every process reaching one endpoint into a single entry carrying no tree, so size tracked distinct ENDPOINTS. The new key splits per process and each distinct process contributes a tree, so size tracks distinct PROCESSES that connected -- and nothing bounded that. SUB-7850's analysis modelled bytes per connection at a fixed 283 connections, which is precisely the quantity the key change stops holding fixed, so the multiplier on connection count was never budgeted. Measured with ~2 KB trees: 4,000 distinct processes produce 4.99 MB of JSON, or 6.65 MB once the synchronizer envelope's base64 applies -- over the 5 MiB limit. That is not graceful: sendNetworkEvent gets a non-2xx, Start() logs it and drops the snapshot, so the node loses its ENTIRE interval of traffic. Reachable on a node with heavy short-lived process churn. maxProcessTreeBytes budgets the trees at 1.5 MiB of estimated bytes. A byte budget rather than a tree count, because tree size still varies ~2.5x under the command-line cap. Connections are never dropped -- that is the data loss this change exists to fix -- only trees, and the refs stay put, so pid identity survives and ProcessTreeFor returns nil for them as specified. Candidates rank by connection count (highest fan-out is both the costliest attribution to lose and the shape reputation cares about), ties broken on the ref so the payload never depends on map iteration order. How often this binds in reality is NOT knowable from current data: today's sensor strips trees and emits no process identity, so distinct-processes-per-batch exists in no message. Fleet mean today is ~30 KB (pulsar_average_msg_size on network-stream-v1), ~175x under the limit -- comfortable baseline, but silent on the new multiplier. So both the budget firing and any payload above 2 MiB now log with their shape, which is what makes the question answerable after rollout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
…tion traffic (SUB-7786) The 1.5 MiB budget was picked from arithmetic, and it was wrong: it binds at 213 of 283 trees, and 283 connections is the largest batch ever observed in production. It would therefore clip trees routinely on the busiest nodes -- degrading exactly the attribution this work adds -- while the payload at that point is only 48% of the 5 MiB limit. Recalibrated to 2.5 MiB using measured inputs rather than assumptions. The reputation consumer gives the missing number: network_reputation_events_in_total over the topic's message count puts a batch at ~42 connections in prod-eu (~32 in prod-us), which against a 29 KB mean message makes a connection ~530 bytes of JSON without its tree. Taking the worst case the budget exists for -- every connection from a distinct process, so trees scale 1:1 -- 283 connections with p90 (~7 KB) trees now ships all 283 trees at 2.50 MB after base64, 48% of the limit. The budget binds above ~360 distinct processes at p90 and ~1280 at median, so it stays a safety valve rather than a routine limiter. TestBuildWireStream_ObservedWorstCaseFitsBudget pins the calibration itself and fails at 1.5 MiB, so a future change cannot silently start clipping observed traffic. Also fixes the test helper that made the original numbers untrustworthy: it piled the whole target size into one command line, which the 1 KB cap then truncated, so it produced ~1.2 KB trees however large a size it was asked for -- and the calibration test passed at both budgets because of it. It now builds a chain the way a real tree gets big, and TestBigTree_ReachesRequestedSize keeps it honest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
…ng) (SUB-7786) The budget was not a bound. estimateTreeBytes charged len(s), but that is not what encoding/json emits: it escapes " and \\, every control byte, and -- because Marshal enables HTML escaping -- <, > and &, which real command lines are full of (sh -c 'cmd > /dev/null 2>&1'). Invalid UTF-8 is replaced byte-for-byte with the 6-byte \ufffd, and process argv is arbitrary kernel bytes, not guaranteed UTF-8. Each such byte costs up to six where len() counted one. Measured: 629 processes with 1 KB of non-UTF-8 argv each estimated at 772 KB -- 29% of the budget -- so the under-budget fast path shipped every tree, dropped nothing and logged nothing, while the real message was 5.37 MB after base64. The broker rejects that, the snapshot is dropped, and the node loses its ENTIRE interval of traffic. Reachable by an ordinary shell loop, and reachable deliberately by anyone who can exec in a container on the node -- which made it a detection-evasion primitive with a wider blast radius than the data-loss bug this branch fixes. It also undercounted ordinary traffic: a realistic single node estimated 372 against 395 marshalled. escapedLen now charges the true escaped cost, rounding every escape up to 6 bytes, and processNodeOverheadBytes goes 200 -> 320, measured against a fully-populated node rather than a sparse test one. The estimator now overestimates by ~19% for realistic trees. TestEstimateTreeBytes_NeverUnderestimates enforces the direction across 15 shapes -- escape-heavy, non-UTF-8, wide, over-deep, legacy Children -- and fails on 10 of them with the old len()-based estimate. Ranking changed from most-connections-first to smallest-tree-first. The old rationale was backwards for the threat it named: a low-and-slow beacon opens exactly one connection per interval, so it sorted last and lost its tree first -- the precise case reputation attribution exists to catch. Smallest-first maximises the number of processes keeping a tree, which is the best objective available when the sensor cannot know which process matters. Also closes test gaps a mutation pass found: the determinism contract was unpinned (removing both ref tie-breaks passed the suite), as were skip-vs-break packing, the estimator's legacy-Children branch, the wrapper-field copy and countConnections. All numbers in the code comments and the feature doc are re-derived from one measured set -- the previous 4,000-connection row claimed 3.85 MB using lean test entries when production-weight entries put it at 6.01 MB, over the limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
…dercounts (SUB-7786) The budget's soundness is a property over ALL inputs, not the shapes I happened to enumerate, and process argv is arbitrary kernel bytes. 400 randomised trees built from bytes weighted toward what JSON escapes -- <, >, &, control bytes, the short escapes, and mostly-invalid high bytes -- each checked against real json.Marshal output. Fixed seed, so a failure reproduces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
…ture doc (SUB-7786) The comments had grown to restate measurements, production numbers, calibration tables and history inline -- all of which is already in docs/features/network-stream-process-attribution.md, where it belongs and can be kept current. Inline, it just made the code harder to read. Kept only what a reader needs at that spot: the invariants that cause a bug if violated (never scale StartTimeNs, never write to a shared tree node, don't replace the copier with DeepCopy, take the ref before the mutex) and one pointer to the doc. 419 -> 287 comment lines, no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
… in the estimate (SUB-7786) Addresses CodeRabbit's childrenMap-key finding. A child's comm is emitted TWICE -- as its own field and inside the parent's childrenMap key, which CommPID.MarshalText renders as comm<U+241F>pid -- and the estimate charged it once, funding the second copy out of per-node slack. estimateTreeBytes also charged containerID with a bare len(), three lines above a comment saying never to do that; it was the last one. Neither is reachable in production: comm is only ever 15 bytes because every source is a kernel TASK_COMM_LEN buffer (eBPF GetComm, procfs stat.Comm), and container IDs are hex. Measured at that bound the old accounting stays positive by +170 to +7291, so CodeRabbit's Major severity is overstated -- I could not reproduce an underestimate with a 15-byte comm. But the estimate is a BOUND, and it must not rest on an invariant nothing in this repo enforces: remove the kernel's comm limit and the old accounting runs 48% under (est 1,323,856 vs 2,531,171 marshalled), which is exactly the silent over-limit message the budget exists to prevent. Two corrections to the finding: the CommPID separator is U+241F (3 bytes), not '/', and the deficit needs a fully-populated node, not any node. The guard tests could not have caught this -- their children set four fields, so the slack was never consumed; 200k randomised trees found nothing. The generator now builds fully-populated children with escape-heavy 15-byte comms, and the table adds the cases that actually discriminate (unbounded comm, both child shapes, escape-heavy containerID), all three verified to fail without the fix. Recalibrated: a realistic 10-node chain now estimates 5683 (was 5102, ~33% over marshalled), so the budget holds ~461 trees rather than ~513. Still 1.6x the largest batch observed, and the 283-connection worst case still ships every tree. Two pre-existing assertions had tight constants tied to the old per-node accounting; both now state their intent proportionally instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
08a6750 to
86ea7a0
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
pkg/networkstream/v1/wire_test.go (2)
284-292: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAssert that the truncated output keeps the original prefix.
The over-cap assertions check the length, the marker suffix and UTF-8 validity. They do not check that the retained bytes come from the start of the input. An implementation that returned only
cmdlineTruncationMarker, or that kept the tail instead of the head, would still pass. Add a prefix assertion so the test pins which part of the command line survives.♻️ Proposed prefix assertion
over := strings.Repeat("a", maxCmdlineBytes+1) assert.LessOrEqual(t, len(capCmdline(over)), maxCmdlineBytes, "the marker is inside the budget") assert.True(t, strings.HasSuffix(capCmdline(over), cmdlineTruncationMarker)) + assert.True(t, strings.HasPrefix(over, strings.TrimSuffix(capCmdline(over), cmdlineTruncationMarker)), + "truncation keeps the head of the command line, not the tail") // Truncation never splits a rune. multibyte := strings.Repeat("é", maxCmdlineBytes) // 2 bytes per rune capped := capCmdline(multibyte) assert.LessOrEqual(t, len(capped), maxCmdlineBytes) assert.True(t, utf8.ValidString(capped), "a cut mid-rune would emit invalid UTF-8") + assert.True(t, strings.HasPrefix(multibyte, strings.TrimSuffix(capped, cmdlineTruncationMarker)), + "the surviving runes are the leading ones")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@pkg/networkstream/v1/wire_test.go` around lines 284 - 292, Add an assertion in the capCmdline truncation tests to verify the output preserves the original input prefix before the truncation marker. Cover the ASCII over-cap case and/or multibyte case using the existing over, multibyte, and capped variables, while retaining the current length, suffix, and UTF-8 assertions.
581-619: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCollapse the four child-attachment helpers into one.
escapeHeavyCommTree,escapeHeavyCommLegacyTree,unboundedCommTreeandunboundedCommLegacyTreeshare the same body. They differ only in the comm value and in whether the child goes intoChildrenMapor into theChildrenslice. Parameterise those two choices. A future change torealisticNodeor to the child shape then needs one edit, not four.The unbounded-comm generators are the right addition. They stop the estimate from resting on the kernel
TASK_COMM_LENbound, which nothing in this repository enforces.Based on learnings: size-estimation logic must not assume the 15-byte comm bound unless it explicitly enforces or validates the bound itself.
♻️ Proposed consolidation
-func escapeHeavyCommTree(children int) *armotypes.ProcessTree { - root := realisticNode(1, "sh") - for i := 0; i < children; i++ { - child := realisticNode(uint32(1000+i), "/usr/bin/curl https://example.com") - child.Comm = escapeHeavyComm - root.ChildrenMap[armotypes.CommPID{Comm: child.Comm, PID: child.PID}] = child - } - return treeOf("c1", root) -} - -func escapeHeavyCommLegacyTree(children int) *armotypes.ProcessTree { - root := realisticNode(1, "sh") - for i := 0; i < children; i++ { - child := realisticNode(uint32(1000+i), "/usr/bin/curl https://example.com") - child.Comm = escapeHeavyComm - root.Children = append(root.Children, *child) - } - return treeOf("c1", root) -} - -func unboundedCommTree(children, commBytes int) *armotypes.ProcessTree { - root := realisticNode(1, "sh") - for i := 0; i < children; i++ { - child := realisticNode(uint32(1000+i), "/usr/bin/curl https://example.com") - child.Comm = strings.Repeat("<", commBytes) - root.ChildrenMap[armotypes.CommPID{Comm: child.Comm, PID: child.PID}] = child - } - return treeOf("c1", root) -} - -func unboundedCommLegacyTree(children, commBytes int) *armotypes.ProcessTree { - root := realisticNode(1, "sh") - for i := 0; i < children; i++ { - child := realisticNode(uint32(1000+i), "/usr/bin/curl https://example.com") - child.Comm = strings.Repeat("<", commBytes) - root.Children = append(root.Children, *child) - } - return treeOf("c1", root) -} +// commFanOutTree builds a root with `children` children, each carrying `comm`. +// If legacy is true the children go into the deprecated Children slice instead +// of ChildrenMap, which is the shape capTreeCopy must normalise. +func commFanOutTree(children int, comm string, legacy bool) *armotypes.ProcessTree { + root := realisticNode(1, "sh") + for i := 0; i < children; i++ { + child := realisticNode(uint32(1000+i), "/usr/bin/curl https://example.com") + child.Comm = comm + if legacy { + root.Children = append(root.Children, *child) + continue + } + root.ChildrenMap[armotypes.CommPID{Comm: child.Comm, PID: child.PID}] = child + } + return treeOf("c1", root) +}Then update the table cases:
{"escape-heavy child comm, fan-out 5", commFanOutTree(5, escapeHeavyComm, false)}, {"escape-heavy child comm, fan-out 200", commFanOutTree(200, escapeHeavyComm, false)}, {"escape-heavy child comm, legacy Children", commFanOutTree(50, escapeHeavyComm, true)}, {"unbounded child comm, fan-out 200", commFanOutTree(200, strings.Repeat("<", 1024), false)}, {"unbounded child comm, legacy Children", commFanOutTree(200, strings.Repeat("<", 1024), true)},🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@pkg/networkstream/v1/wire_test.go` around lines 581 - 619, Replace the four helpers escapeHeavyCommTree, escapeHeavyCommLegacyTree, unboundedCommTree, and unboundedCommLegacyTree with one parameterized commFanOutTree helper that accepts child count, comm value, and a legacy-Children flag, then attaches children to either ChildrenMap or Children accordingly. Update the table cases to use this helper, preserving both escape-heavy and unbounded comm scenarios, including the 1024-byte comm value.Source: Learnings
docs/features/network-stream-process-attribution.md (1)
252-262: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueThe bullet list at Line 228 is interrupted by a heading and prose.
Line 228 opens a bullet list. Line 234 starts a
####heading, and lines 236-259 are prose. Line 260 then resumes bullets. Markdown renders this as two separate lists, so the reader loses the grouping of the budget properties. Move the escaping subsection after the bullets, or promote each bullet to its own subsection.The accounting content itself is correct. It charges
commtwice and does not rest the estimate on the kernelTASK_COMM_LENbound.Based on learnings: size-estimation logic must not assume the 15-byte comm bound unless it enforces or validates the bound itself.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/features/network-stream-process-attribution.md` around lines 252 - 262, Reorganize the documentation around the bullet list beginning at line 228 so it remains one continuous Markdown list: move the “escaping” subsection and its prose after all bullets, including the content currently resumed at line 260. Preserve the existing accounting guidance that charges comm twice and does not rely on an unenforced TASK_COMM_LEN bound.Source: Learnings
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/features/network-stream-process-attribution.md`:
- Around line 212-226: Clarify the 4000-row explanation to state that its 6.01
MiB estimate includes the 530 B per-entry production weighting, unlike the
events used by TestBuildWireStream_OverBudgetDropsTreesNotConnections, which
omit those connection fields and therefore remain under the 5 MiB test bound.
In `@pkg/networkstream/v1/wire_test.go`:
- Around line 773-792: Draw the child count once before the loop in the test
generator, then iterate using that fixed count instead of calling rng.Intn(6) in
the loop condition. Update the loop surrounding the child construction and
ChildrenMap insertion so fan-out is uniformly selected from 0 through 5,
preserving the existing child population logic.
---
Nitpick comments:
In `@docs/features/network-stream-process-attribution.md`:
- Around line 252-262: Reorganize the documentation around the bullet list
beginning at line 228 so it remains one continuous Markdown list: move the
“escaping” subsection and its prose after all bullets, including the content
currently resumed at line 260. Preserve the existing accounting guidance that
charges comm twice and does not rely on an unenforced TASK_COMM_LEN bound.
In `@pkg/networkstream/v1/wire_test.go`:
- Around line 284-292: Add an assertion in the capCmdline truncation tests to
verify the output preserves the original input prefix before the truncation
marker. Cover the ASCII over-cap case and/or multibyte case using the existing
over, multibyte, and capped variables, while retaining the current length,
suffix, and UTF-8 assertions.
- Around line 581-619: Replace the four helpers escapeHeavyCommTree,
escapeHeavyCommLegacyTree, unboundedCommTree, and unboundedCommLegacyTree with
one parameterized commFanOutTree helper that accepts child count, comm value,
and a legacy-Children flag, then attaches children to either ChildrenMap or
Children accordingly. Update the table cases to use this helper, preserving both
escape-heavy and unbounded comm scenarios, including the 1024-byte comm value.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b9f3c69d-bc66-4fd8-a359-f5c8a75c041b
⛔ Files ignored due to path filters (1)
go.sumis excluded by!**/*.sum
📒 Files selected for processing (6)
docs/features/network-stream-process-attribution.mdgo.modpkg/networkstream/v1/network_stream.gopkg/networkstream/v1/network_stream_test.gopkg/networkstream/v1/wire.gopkg/networkstream/v1/wire_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
- go.mod
- pkg/networkstream/v1/network_stream.go
- pkg/networkstream/v1/wire.go
- pkg/networkstream/v1/network_stream_test.go
|
Reviewed this locally — build, Four things below, none blocking. 1. The tree-ranking order isn't actually pinnedFlipping func TestSelectProcessTrees_MaximisesTreesKept(t *testing.T) {
const smallBytes, largeBytes = 20_000, 80_000
smallEst := estimateTreeBytes(bigTree(1, smallBytes))
require.Greater(t, estimateTreeBytes(bigTree(1, largeBytes)), smallEst*3,
"the two populations must be far enough apart to discriminate")
events := map[string]armotypes.NetworkStreamEvent{}
smallRefs := map[armotypes.ProcessRef]bool{}
for pid := uint32(1); pid <= 200; pid++ {
ref := &armotypes.ProcessRef{PID: pid, StartTimeNs: 10_000_000}
smallRefs[*ref] = true
events[fmt.Sprintf("10.1.0.%d/443/TCP/%d/10000000", pid%256, pid)] =
armotypes.NetworkStreamEvent{ProcessRef: ref, ProcessTree: bigTree(pid, smallBytes)}
}
for pid := uint32(1000); pid < 1100; pid++ {
ref := &armotypes.ProcessRef{PID: pid, StartTimeNs: 10_000_000}
events[fmt.Sprintf("10.2.0.%d/443/TCP/%d/10000000", pid%256, pid)] =
armotypes.NetworkStreamEvent{ProcessRef: ref, ProcessTree: bigTree(pid, largeBytes)}
}
wire := buildWireStream(outboundOnly("c1", events))
// Greedy-smallest is optimal for "most processes keep a tree", so the expected
// count is computable rather than a magic number.
want := min(maxProcessTreeBytes/smallEst, 200)
assert.Equal(t, want, len(wire.Processes),
"ranking must maximise how many processes keep a tree")
for ref := range wire.Processes {
assert.True(t, smallRefs[ref], "a large tree displaced a small one: ranking is not smallest-first")
}
}Passes on 2. The skip-vs-break branch is dead code, so the test named for it is vacuous
So 3. Derive the drop counters rather than accumulating themThe one real consequence of the loop shape is the warning log, and that's fixable by construction: totalBytes, totalConnections := 0, 0
for _, candidate := range candidates {
totalBytes += candidate.estBytes
totalConnections += candidate.connections
}
...
usedBytes, shippedConnections := 0, 0
for _, candidate := range ordered {
if usedBytes+candidate.estBytes > maxProcessTreeBytes {
continue
}
processes[candidate.ref] = capTreeCopy(candidate.tree)
usedBytes += candidate.estBytes
shippedConnections += candidate.connections
}
// Derived, not accumulated in the loop above: correct whatever that loop does.
droppedTrees := len(candidates) - len(processes)
droppedConnections := totalConnections - shippedConnectionsOn 1000 over-budget candidates, both loop shapes then log 4. The ref lookup is a new unconditional double-lock on the packet path
Benchmarked the duplicate-connection path with a real
Idle it's ~16%; under exec churn it's ~3.9× on that path. The cost is essentially all write-lock contention, so it lands hardest on exactly the fork-heavy nodes the tree budget was calibrated for (CI runners, cron shelling out). Inherent to the design since the batch key needs the ref — but a dedicated RWMutex for the Two smaller things while I was in there: Nice work on the |
…t counters (SUB-7786) Addresses matthyx's review. 1. The ranking order was not pinned. Inverting the estBytes comparator left the whole suite green, TestSelectProcessTrees_PrefersSmallestTrees included, because every tree in it is the same size -- ordering only decided WHICH equal-sized trees shipped, and the beacon's small tree landed in the leftover budget either way. His test mixes two size populations so the shipped COUNT depends on the order, which is the property smallest-first exists for. Verified: ships 126 (= maxProcessTreeBytes/smallEst) as written, 34 with the sort inverted, and the displacement assertion trips too. 2. The skip-vs-break branch is unreachable, so the comment claiming otherwise was wrong. His proof holds: with the ascending sort, if candidate i does not fit then used+est_i > budget, and for every j>i we have est_j >= est_i while used never decreases, so nothing after the first miss can fit. Kept the skip as defensive against a future ordering change, but it no longer claims to be load-bearing, and the test named for it now states what it does and does not prove. 3. The drop counters are now derived -- len(candidates) minus len(processes), and total connections minus shipped -- rather than accumulated in the packing loop. They are the decision input for whether the budget needs raising, so they should not depend on the loop body: as written before, a future early exit would have logged treesDropped 0 while dropping hundreds, with nothing asserting otherwise. Extracted selectWithinBudget so the counters are testable, and pinned the identities that must hold regardless. 4. processNodeOverheadBytes' comment claimed "~360 measured" while the constant is 320. He was right that the comment is the stale part: measured 133 bytes for a node with every numeric at max and strings empty, plus ~16 for the childrenMap wrapper, so ~149 actual. The comment now says 320 is deliberate headroom -- absorbing UniqueID and future fields -- rather than pretending to be a measurement. Also documented two costs he raised that are real but out of scope here. The ref lookup is on the packet path for every event including duplicates (unavoidable: the dedup key contains the ref), and under exec churn his benchmark puts it at ~3.9x on that path -- write-lock contention on the manager mutex. The fix is a dedicated lock or atomic read for the creator's pidStartTimeNs side map, which lives in pkg/processtree/creator and belongs to the workstream that owns it. And the budget bounds the wire but not the heap: storage retains one entry per process per endpoint per interval, each pinning a tree, which cannot be capped without reintroducing the connection drops this fixes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
|
Thanks @matthyx — this is a genuinely useful review, and the independent check on tree immutability (both All four addressed in 1. Ranking order — accepted, your test added verbatimYou're right and I'd fooled myself. Reproduced exactly: inverting the comparator ships 34 instead of 126 and trips the displacement assertion, while 2. Skip-vs-break is unreachable — accepted, the comment was wrongYour proof holds: ascending sort ⇒ if candidate i misses then 3. Derived counters — accepted, and this was the sharpest pointYour framing settled it: the counters are the decision input for "make the budget configurable rather than raising it blind", so they shouldn't depend on the loop body at all. Now derived as 4. Packet-path lock contention — accepted as real, deliberately not fixed hereYour numbers are the most consequential thing in the review, and I'm not going to hand-wave them: ~16% idle, ~3.9× under exec churn, landing hardest on precisely the fork-heavy nodes the budget was sized for. Two things I'd add rather than dispute:
That leaves your suggestion — a dedicated lock or atomic/sharded read for the creator's The two smaller ones
|
|
Re-checked at
Build, On the Leaving the lock contention to I also reproduced the revised numbers independently: a 10-node One correction, in the residual paragraph. This part isn't right as written:
The 3,000 is correct, but it isn't entries alone — it's the combined payload with the tree budget saturated. Entries alone at ~530 B don't breach until ~7,400 connections: the 5 MiB limit is 3.75 MiB of raw JSON before base64, and 3,932,160 / 530 = 7,419. Your own 4,000 row is the check — 4.29 MiB total, of which entries are only 4,000 × 530 = 2.02 MiB, so the other 2.27 MiB is the saturated tree budget. Back that out and the remaining headroom is 3.75 − 2.27 = 1.48 MiB, i.e. ~2,930 entries. That's where the 3,000 comes from. Which makes "No tree budget can help there" the part worth changing: trees occupy roughly 2.27 of the 3.75 MiB in that regime, so a tighter |
… the operator lever (SUB-7786) Addresses matthyx's correction. The residual paragraph said the connection entries alone breach the message limit at ~3,000 connections and that no tree budget can help there. The number was right but the attribution and the conclusion were both wrong. ~3,000 is the breach point WITH the tree budget saturated, not entries alone. The usable budget is 3.75 MiB of JSON (5 MiB after base64 x1.333); at 4,000 connections entries are 2.02 MiB and saturated trees ~1.91 MiB, so backing the trees out leaves room for ~2,900 entries. Entries alone would not breach until 3,932,160 / 530 = ~7,400. That makes "no tree budget can help" the damaging part: trees are roughly half the payload in that regime, so tightening maxProcessTreeBytes moves the breach point out toward ~7,400 as the budget approaches zero. As written, an operator whose node was dropping traffic would have concluded the budget was not a lever when it is the fastest one available -- no protocol change, no backend coordination. Both numbers are now tabulated with what each one means. Splitting the message stays the long-term fix, since tightening the budget buys headroom by shipping fewer trees -- the attribution this feature exists to deliver. Recorded one prerequisite before anyone builds it: the container-profile precedent reacts to a synchronous HTTP 413 from storage's QueueManager, whereas this path posts elsewhere, handles no 413 anywhere, and the 5 MiB cap is a broker limit whose enforcement point SUB-7850 lists as unverified. If it is enforced downstream the sensor sees 200 OK and never learns, so reactive splitting is unavailable and the split has to be decided before the first send. Also corrects the 4,000-connection row from 5.72 to 5.24 MiB: it double-counted the entry cost, adding a modelled 530 B per entry on top of a measured payload that already contained ~94 B per synthetic entry. Same class of error as the one above -- mixing a measured figure with a modelled one -- so the table now says which is which. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
|
You're right, and it's the useful kind of wrong — the number was fine and the advice attached to it wasn't. Fixed in I re-derived your decomposition rather than taking it: usable budget 3.75 MiB (5 MiB ÷ 4/3); at 4,000 connections entries are 4,000 × 530 = 2.02 MiB and the saturated trees ~1.91 MiB; back the trees out and 3.75 − 1.91 leaves room for 2,929 entries, which is the ~3,000. Entries alone: 3,932,160 ÷ 530 = 7,419. Both match yours. The residual section now tabulates the two numbers with what each one means, and states plainly that the tree budget is a lever in that regime — trees are ~half the payload, tightening it moves the breach point out toward ~7,400, and it's the fastest thing to reach for during an incident since it needs no protocol change or backend coordination. Your operator framing is what made this worth changing rather than just rewording; thank you for putting it that way. A second instance of the same error, which you didn't catch and I should have. The 4,000 row said 5.72 MiB. It double-counted the entries: the measured payload already contained ~94 B per synthetic entry, and I added a modelled 530 B on top, so I was charging ~624 B each. Honest figure is 3.93 MiB JSON → 5.24 MiB base64 — still over, so the row's verdict stands. Corrected, and the table now marks which figures are measured and which are modelled, because mixing the two without checking for overlap is precisely what produced both mistakes. On splitting — one prerequisite worth settling before anyone starts. I've recorded it in the doc rather than leaving it implicit, because I think it's a trap for whoever picks up the ticket. Your container-profile precedent works because storage's
So if the cap is enforced downstream at Pulsar rather than at the HTTP gateway, the sensor gets Two smaller notes while I was in there:
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/features/network-stream-process-attribution.md`:
- Around line 321-325: Update the connection-breach arithmetic in the
network-stream-process-attribution documentation so the residual budget and
connection threshold are consistent with the stated 3.75 MiB, 1.91 MiB saturated
trees, and 530 B per entry values. Recompute the remaining budget from the
documented numbers, then either revise the ~3,000 threshold and related prose to
match that result or explicitly add the missing envelope overhead into the
formula; keep the threshold guidance aligned with the same calculation used near
the other ~3,000 references.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 41b1d78c-aa9a-4c4f-991b-5ede52153b72
📒 Files selected for processing (1)
docs/features/network-stream-process-attribution.md
|
I derived 2.27 MiB of saturated trees by subtracting modelled entries from the then-stated 4.29 MiB total, which was itself the inconsistent number. You've since measured trees properly at ~1.91 MiB. I measured it independently on this head and agree: 465 trees at budget saturation marshal to 2,033,576 B = 1.94 MiB. Propagating that through your own formula: Using your 1.91 MiB rather than my measured 1.94 gives ~3,640. So the number is ~3,600, not ~3,000 — the Three places to update: the table caption ("exceeds the limit at roughly 3,000 connections"), the residual table row, and the "which is where the ~3,000 comes from" sentence. The ~7,400 entries-alone figure is unaffected — I get 3,932,160 / 530 = 7,419. Nothing else changed in |
…3,600 (SUB-7786) The previous commit corrected the saturated-tree figure from 2.27 to 1.91 MiB but left the threshold that had been derived from the old figure at ~3,000, so the paragraph contradicted its own arithmetic: 3.75 - 1.91 leaves 1.84 MiB, which is ~3,640 entries, not ~2,900. The ~2,900 only followed from the retired 2.27 MiB. Measured directly this time rather than derived from another derived number: a saturated tree map marshals to 1,978,505 B (1.89 MiB), leaving 3,932,160 - 1,978,505 = 1,953,655 B (1.86 MiB), so ~3,690 entries at 530 B. Two independent measurements put it at ~3,580 and ~3,600, so the doc states ~3,600 -- rounding low is the right direction for a breach threshold. Corrected in all three places: the table caption, the residual table row and the derivation. Added the rule that produced both of the errors in this chain, so the next person does not repeat it: derive the threshold from the measured tree total, never from a payload figure that already contains entries. Unaffected: entries alone still breach at 3,932,160 / 530 = ~7,400, and the operator conclusion -- that maxProcessTreeBytes is a real lever because trees are roughly half the payload in that regime -- holds unchanged at ~3,600. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
|
Good catch, and thanks for tracing it to the source rather than the symptom — you're right that Measured directly this time instead of deriving it from another derived figure, which is what produced this in the first place: a saturated tree map marshals to 1,978,505 B (1.89 MiB), leaving 3,932,160 − 1,978,505 = 1,953,655 B (1.86 MiB), so ~3,690 entries at 530 B. Your ~3,580 and CodeRabbit's ~3,600 land in the same band, so the doc now says ~3,600 — rounding low is the right direction for a breach threshold. Fixed in Unaffected, as you say: ~7,400 for entries alone (3,932,160 / 530 = 7,419), and the operator conclusion holds unchanged at ~3,600. |
…in the doc table (SUB-7786) Two CodeRabbit nitpicks, both valid. The randomised estimator generator had rng.Intn(6) in the loop condition, so Go redrew the bound before every iteration instead of once. The loop still terminated, but the child count was no longer uniform over 0..5: each step continues with probability (5-c)/6, making 5 children ~1.5% likely instead of ~16.7% and dropping the mean from 2.5 to ~1.8. Fan-out is one of the inputs that drives a tree into the region where the childrenMap key made the estimate undercount, which is the whole reason this generator was strengthened, so it was starving its own purpose. Drawn once now. The 4,000-connection row in the doc looks like it contradicts the two tests that drive comparable process counts and assert the payload stays UNDER the limit. It does not: the tests build synthetic events carrying only a ref and a key, ~94 B each, where a production entry is ~530 B -- the tests bound what the code emits, the table adds the real-world entry weight on top. Named both tests in the caption and spelled out the difference so a reader does not mistake the residual for a failing bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Alon <alon@armosec.io>
commit 4342b248418563a23eee0600ad94a7f6bb8b0d2f
Author: entlein <einentlein@gmail.com>
Date: Sat Aug 15 13:57:38 2026 +0200
test(e2e): fix issue-79 ladder measurement - read all node-agent pods, match containerName exactly, race-free readiness wait
The ladder under-counted to 0/N while the node-agent logs showed 5/5 R0001
for both the init and the ephemeral container: it read only one DaemonSet
pod's logs (the workload can land on any node), its grep could not match the
alert JSON's containerName field, and its readiness wait raced pod creation.
commit f7bcbd7631d67e0ab813f5364ea959819aa9f86f
Author: k8sstormcenter-bot <k8sstormcenter@users.noreply.github.com>
Date: Sat Aug 15 12:33:23 2026 +0200
fix(objectcache): do not evict status-lagged containers from the profile cache
The ContainerProfileCache reconciler classified any cache entry whose
container was absent from the pod's published status lists as reaped and
evicted it (reconciler.go isContainerTerminated). But kubelet publishes
the status groups incrementally: a just-attached ephemeral container has
no ephemeralContainerStatuses entry for several seconds while it is
already running and traced, and an entry added before the pod reached
the k8s cache carries an empty PodUID, which made the (Name, PodUID)
pre-running fallback unreachable for init containers. Eviction is
permanent (no re-add path exists), so every ProfileDependency=Required
rule (R0001/R0003/R0004) was silently suppressed for the container's
whole life: total alert loss for ephemeral containers, intermittent
exec-alert loss for init containers (issue #79, CI run 31846699597).
Evidence (live rig, issue #79): ephemeral container adopted at +1s,
evicted at the next reconciler tick +3s (entries 2->1), exec events
verifiably reached ReportEnrichedEvent at +75s and were dropped by the
Required-profile gate; the exec gadget's mntns filter map contained the
container's mntns the whole time (kernel/tracer exonerated).
Fix:
- treat absence from the status lists as reaped only when the pod SPEC
does not name the container either; a status entry with the same name
under a different non-empty ContainerID still evicts (replaced
instance)
- allow the pre-running (Name, PodUID) fallback to match when the
stored PodUID is empty
- backfill PodUID from the container runtime metadata when the pod is
not yet in the k8s cache at entry-build time
New tests fail on the pre-fix code and pass with the fix:
TestReconcilerKeepsJustAttachedEphemeralContainer,
TestReconcilerKeepsInitContainerWithEmptyStoredPodUID. Regression
guards (both-ways green): eviction after published termination, gone
from spec+status, replaced instance. Full objectcache, rulemanager and
containerprofilemanager suites pass unchanged.
commit 523246a94a53eee02a13b1cf48634a9754c98c75
Author: entlein <einentlein@gmail.com>
Date: Sat Aug 15 12:33:24 2026 +0200
fix(profilecache): do not classify status-lagged containers as reaped
The reconciler evicted any cache entry whose container was absent from
all published status lists once any statuses existed. A just-attached
ephemeral container is exactly that: the pod spec already declares it
while kubelet publishes its ephemeralContainerStatuses entry seconds
later. The freshly-adopted profile entry was evicted on the next tick,
nothing re-added it, and every ProfileDependency=Required rule was
suppressed for the container's entire life — zero alerts of any class
(issue #79 T5, ephemeral 0/N). Init-container entries created before the
pod reached the k8s cache (empty PodUID, status ContainerID not yet
published) hit the same branch, contributing to the init intermittency.
Observed: ephemeral container adopted +1s after attach; reconciler tick
3s later logged entries_before=2 entries_after=1; zero alerts over the
container's 75s life while the same pod alerted for its other containers.
Fix: absence from published statuses only counts as reaped when the
container is also absent from the pod SPEC (containers, initContainers,
ephemeralContainers). Additionally, the termination mark introduced with
the removal grace now resets when a marked container is observed alive
again, so a later genuine termination gets a full grace window.
Tests (red pre-fix): TestReconciler_KeepsEphemeralContainerAwaitingStatus,
TestReconciler_KeepsInitContainerAwaitingStatusWithEmptyPodUID,
TestReconciler_TerminationMarkResetsWhenContainerReappears; negative
contract TestReconciler_EvictsContainerRemovedFromSpecAndStatus.
Regression: go test -race ./pkg/objectcache/... ./pkg/containerwatcher/v2/
./pkg/rulemanager/... passes.
commit d9cf31923e68c95d1a68416529648c8890e57f53
Author: entlein <einentlein@gmail.com>
Date: Sat Aug 15 12:32:19 2026 +0200
test(profilecache): pin reconciler classification of status-lagged containers
Failing tests for the ephemeral total-loss leg of issue #79: the
reconciler classifies a container that is absent from all published
status lists as reaped, but a just-attached ephemeral container is
exactly that (kubelet publishes ephemeralContainerStatuses seconds after
the attach), and an init container whose entry carries an empty PodUID
hits the same branch while its status has no ContainerID yet. The entry
is evicted, nothing re-adds it, and every ProfileDependency=Required
rule is suppressed for the container's entire life.
Live-cluster evidence: ephemeral container adopted at +1s, reconciler
tick 3s later (entries_before=2 entries_after=1), zero alerts of any
class over its 75s life while the same pod alerted for other containers.
Contract pinned: a container still declared in the pod SPEC without a
published status is not reaped; absent from both spec and status is;
a termination mark resets when the container is observed alive again.
commit bcfbcde2c2fcfd3bde9bb07d72503254836dc8c6
Author: entlein <einentlein@gmail.com>
Date: Sat Aug 15 12:30:13 2026 +0200
test(e2e): add issue-79 end-of-life exec-delivery ladder script
Deterministic rig-side measurement for acceptance tests T4/T5: N repeated
init runs (terminal forbidden exec after a configurable runway) and N
ephemeral-container runs (terminal whoami+id), each asserting R0001
delivery via node-agent logs. Exits non-zero unless both legs are N/N.
commit eaca1294bd6ad32ec59db4248df7f56f912c6113
Author: entlein <einentlein@gmail.com>
Date: Sat Aug 15 12:28:45 2026 +0200
fix(containerwatcher,profilecache): grace period for events in flight at container end-of-life
An event emitted during a container's life can be processed after the
container's removal: the ordered event queue (50ms collection tick +
batching) and the worker pool delay evaluation past teardown. For a
container whose final process performs the exec and exits immediately
(init container with a terminal exec, ephemeral debug container), the
alert-carrying exec event loses this race and is dropped.
Observed failure: run 31846699597, Test_48_MultiSubtypeGroupedProfileDocument,
init container setup (sh -c "sleep 75; /usr/bin/id"): remove processed
22:44:26, terminal exec evaluated afterwards, zero R0001 while 98 R0003
fired during the container's life (assertion: 'id is not in the setup
section (initContainers)', component_test.go:3488). Same-shaped loss for
the ephemeral leg (R0001(debug,id)=0, remove 22:46:15).
Root cause, two drop points on the remove path:
1. EventHandlerFactory.ProcessEvent resolved container info only from the
live container collection plus a lazily-populated cache, silently
dropping events for just-removed containers that never had a prior
event processed.
2. ContainerProfileCache deleted the projected-profile entry immediately
(async) on the remove callback, so rules with ProfileDependency=Required
suppressed in-flight events as profile_incomplete.
Fix: keep container info and the projected profile resolvable for a
10s grace after removal, then evict:
- the factory now receives container lifecycle callbacks, warms its
lookup cache on add, and defers eviction by the grace period;
- the profile cache defers deleteContainer by the grace period and the
reconciler's terminated-eviction honors the same grace (mark on first
Terminated observation, evict on a later tick), so a reconciler tick
landing inside the window cannot reintroduce the race.
Tests: TestProcessEvent_DeliversEventForJustRemovedContainer{,_NoPriorEvent},
TestProcessEvent_RemovedContainerEvictedAfterGrace,
TestProjectedProfile_{SurvivesContainerRemovalGrace,EvictedAfterRemovalGrace},
TestReconciler_HonorsRemovalGraceForTerminatedContainer (all red on the
pre-fix code); TestReconcilerEvictsTerminatedContainer,
TestInitContainerEvictionViaRemoveEvent,
TestMissedRemoveEventEvictedByReconciler updated to the graced contract.
Regression: go test ./pkg/containerwatcher/... ./pkg/objectcache/...
./pkg/rulemanager/... passes (tracers field tests skipped locally: they
require the tracers.tar gadget bundle, unavailable off-CI); -race clean
on both touched packages.
commit b898329886daba36d8126148b66e94cf7fce6aa0
Author: entlein <einentlein@gmail.com>
Date: Sat Aug 15 12:21:59 2026 +0200
test(containerwatcher,profilecache): pin event delivery across container end-of-life
Failing tests for the teardown race behind issue #79 (init/ephemeral exec
loss): events emitted during a container's life are dropped when processed
after the container's removal.
- TestProcessEvent_DeliversEventForJustRemovedContainer[_NoPriorEvent]:
EventHandlerFactory.ProcessEvent silently drops events whose container
has left the live collection (evidence: run 31846699597, Test_48, init
container terminal exec at 22:44:26, remove processed 22:44:26, zero
R0001; ladder run1 total loss).
- TestProjectedProfile_SurvivesContainerRemovalGrace: the projected
profile is deleted immediately on the remove callback, so in-flight
events lose profile resolution and ProfileDependency=Required rules
suppress as profile_incomplete.
Both tests fail on current code by design; the fix must provide a
removal grace window covering the event pipeline delay.
commit f4dc94f6d0d6dee51be80596c4226c9601f72294
Author: entlein <einentlein@gmail.com>
Date: Sat Aug 15 00:50:08 2026 +0200
test(component): widen Test_48 init margin to 100s for CI runner variance
Init-phase enforcement is proven (interactive validation: adoption 3s after
deploy, init R0001 fires), but CI runners intermittently take longer than
75s to complete the first adoption; give the init container a 100s runway.
commit 0dbaeaf475c8fde427c50b37bab6372cbdffb18b
Author: entlein <einentlein@gmail.com>
Date: Sat Aug 15 00:27:26 2026 +0200
test(component): downgrade Test_48 ephemeral assertions to a tracked known limitation
The ephemeral debug container is adopted (section selected, monitor started,
tracers attached) but no events from it ever reach the rule engine - verified
in CI and interactively on a fresh cluster (zero exec/syscall/capability
events while the container demonstrably ran to completion). The
ephemeralContainers profile-selection contract stays covered by the cache
unit tests; event delivery for ephemeral containers is a container-watcher/
tracer scope issue independent of profile projection, tracked as follow-up.
Log the counts and self-signal when tracing starts working so the assertions
can be promoted back.
commit 7f5451de636b46005e9ef87859dd40a19fce1ebe
Author: entlein <einentlein@gmail.com>
Date: Sat Aug 15 00:00:09 2026 +0200
fix(containerwatcher): populate shared data for init containers during the init phase
getSharedWatchedContainerData refused to proceed while the pod phase was
Pending - but a pod executing its init containers is Pending by definition,
so shared data (and with it authored-profile adoption and rule enforcement)
could only arrive after the init phase completed. An init container could
never be enforced during its own execution; observed as an authored init
section whose forbidden binary produced no alert because adoption landed
seconds after the init container had already exited.
The phase gate existed because ImageID is empty in containerStatuses while
the pod is pending. Check that directly: when the pod is Pending, proceed as
soon as THIS container's status entry (containers, initContainers, or
ephemeralContainers) carries a non-empty ImageID, and keep retrying
otherwise.
commit bea971ddac9aef6840a17d1d2f0cffdc22efd417
Author: entlein <einentlein@gmail.com>
Date: Fri Aug 14 23:29:40 2026 +0200
test(component): size Test_48 waits to the observed init/ephemeral adoption latency
The per-section enforcement now works end to end, but the init and ephemeral
containers ran their forbidden binary 30s after start while authored-profile
adoption for those containers has been observed to take 60-70s on a loaded
runner - the exec raced adoption and produced no alert. Lengthen the in-pod
sleeps to 75s (fixture command, matching profile args) and the test's waits
accordingly.
commit abf33931e520b00b588b5205db2be0b5347481f4
Author: entlein <einentlein@gmail.com>
Date: Fri Aug 14 22:53:17 2026 +0200
chore: bump storage pin to the protobuf-marshaller regeneration
commit 422655f44939ab49f242ff1c41aed66ccb05f234
Author: entlein <einentlein@gmail.com>
Date: Fri Aug 14 21:59:26 2026 +0200
test(component): assert the grouped document round-trips before driving traffic
If a storage-side write path strips the subtype groups, Test_48's per-section
assertions fail later with misleading R0001 noise. Assert the served document
still carries all three groups (and the app section's execs) right after
apply, so a storage regression fails fast with the actual cause.
commit f8e00be1d6ed6d7949e9a25ab752cac1fa96ad8d
Author: entlein <einentlein@gmail.com>
Date: Fri Aug 14 21:18:55 2026 +0200
chore: bump storage pin to the subtype-group deflation fix
commit 801058d56d47e834562d425547c4cfe590667a09
Author: entlein <einentlein@gmail.com>
Date: Fri Aug 14 20:35:55 2026 +0200
test: renumber the multi-subtype component test to Test_48
Test_37 is already claimed by the signed-bundle overlay test in the fork CI
harness matrix; renumber to the next free slot so both suites can coexist.
commit 22a05c45903d580c2259c65bdf8bc52fa8f639a1
Author: entlein <einentlein@gmail.com>
Date: Fri Aug 14 17:17:51 2026 +0200
feat(containerprofilecache): recover container subtypes via grouped authored documents
The AP/NN decommission dropped the container subtype groups (containers/
initContainers/ephemeralContainers) the legacy specs carried: the flat
ContainerProfile could not describe a pod's init or ephemeral containers, and
the multi-container component test only exercised two REGULAR containers - so
the subtype contract was silently broken.
Storage now restores the subtype groups on ContainerProfileSpec (fork storage
module, pinned via replace); this consumes them:
- resolveAuthoredContainerSection maps an authored document to the per-
container view: flat documents pass through (single-container convention
unchanged); grouped documents select this container's section by name
across all three subtype groups, inheriting pod-level architectures and
the workload selector. A grouped document that does not cover the container
resolves to nil - never enforce a sibling's section - with a Warning on the
add path.
- Wired into both the add path (tryPopulateEntry) and the refresh path
(refreshOneEntry), before the learned-annotation validation.
- Unit tests: section selection across all three groups, flat pass-through,
uncovered-container nil (TestResolveAuthoredContainerSection), and an
add-path test proving a regular+init+ephemeral trio each adopt only their
own section from one shared document
(TestUserDefinedCP_GroupedDocumentPerSubtype).
Component test: Test_37_MultiSubtypeGroupedProfileDocument binds one grouped
document to a pod with a regular container, an init container whose startup
command runs a binary its section forbids (the init phase itself must alert),
and a runtime-attached ephemeral container (new
TestWorkload.AddEphemeralContainer helper using the ephemeralcontainers
subresource). Per-section allow/forbid is asserted for all three subtypes.
Test_36 and Test_37 are added to the component-tests matrix - Test_36 was
never listed, so it never ran in CI.
commit a60131bbe9b9c58f8cfc5f8de13d490abecd571a
Author: entlein <einentlein@gmail.com>
Date: Fri Aug 14 17:02:59 2026 +0200
feat(containerprofilecache): make authored-profile adoption visible (log + metric)
Adopting a user-authored ContainerProfile as the authoritative base for a
container silently switches what the rule engine enforces; the legacy overlay
path emitted metrics at the equivalent point, and the unresolved case already
has container_profile_user_defined_unresolved_total. Close the asymmetry:
- Info log naming the namespace/profile when an authored CP is adopted on the
add path.
- New counter (prometheus container_profile_user_defined_adopted_total,
OTEL node_agent.container_profile.user_defined_adopted.total), implemented
across the prometheus/OTEL/noop/mock managers.
gofmt applied to the touched metrics files (they were unformatted; no CI job
runs gofmt).
commit f9922e300a6defc31f9a7e9ecefdbedc499c49a4
Author: entlein <einentlein@gmail.com>
Date: Fri Aug 14 17:00:14 2026 +0200
test(projection): pin '*' exec-path classification as Pattern
The wildcard-classification fix routes any '*'- or dynamic-segment-bearing
entry on a path surface into Patterns instead of Values, but only the Opens
surface had a pinning test. Execs paths flow through the same projection, so
add the mirror case: a '*'-bearing exec path and a dynamic-segment exec path
are Patterns, a literal exec path stays a Value.
commit 48a67048265ca0901b38b678128d01c10b20c906
Author: entlein <einentlein@gmail.com>
Date: Fri Aug 14 16:59:30 2026 +0200
docs(containerprofilecache): update doc comments to the ContainerProfile-only design; restore network-wildcards fixture README
The AP/NN decommission left several doc comments describing the removed
overlay architecture: refreshAllEntries claimed to fast-skip on
UserAPRV/UserNNRV (fields that no longer exist), addContainer and
tryPopulateEntry said they fetch user-authored AP/NN CRDs, the workloadName
naming note still described the AP/NN aggregation target, and namespacedName
was documented as a legacy-CRD identifier. All now describe the authored
ContainerProfile flow that actually runs. Also drop a review-metadata
reference from a projection_apply comment.
tests/resources/network-wildcards/README.md was deleted with the legacy
fixtures, but the directory's ContainerProfile fixtures are still consumed by
containerprofilenetwork/fixtures_test.go and were left undocumented. Restore
the README updated to the ContainerProfile fixture shape and current test
names, including fixture 00 which postdates the deleted version.
No functional change; go build and the package tests are unchanged.
commit 8e44c662d68bc9d3cd895e4a76c5b7ed5799af4e
Author: ConstanzeTU <74674840+ConstanzeTU@users.noreply.github.com>
Date: Fri Aug 7 12:16:00 2026 +0200
ci: add dispatchable build workflow for the storage-triggered rebuild chain
The storage build dispatches this repo's build.yaml on the matching branch to
rebuild node-agent with the freshly built storage (go mod replace via
STORAGE_REF). This branch previously carried only the upstream .github, so the
dispatch failed with 'Workflow does not have workflow_dispatch'. Add the
dispatchable build workflow so the same-branch rebuild chain resolves.
commit a7345fea0661c955c2590ce67fec3078880c37bd
Author: Duck <70207455+entlein@users.noreply.github.com>
Date: Thu Aug 6 10:48:45 2026 +0200
Feat/celnetwork (#62)
* cel/containerprofilenetwork: add cp.was_selector_in_{ingress,egress}
Runtime label-based network matching, complementing was_address_in_*: resolve a
peer IP to its pod via the k8s object cache and match it against the profile
ingress/egress podSelector + namespaceSelector. Matches the peer by IDENTITY, so
it survives pod IP churn (learned IPs go stale on reschedule; selectors do not).
- projection carries each neighbor selector (IngressPeers/EgressPeers) instead of
dropping it at extract time
- wasSelectorIn shared impl + was_selector_in_ingress / was_selector_in_egress
direction wrappers, registered under the cp network library with cost estimates
- unit test for the podSelector + namespaceSelector match logic
* cel/containerprofilenetwork: match peer selectors on gadget-enriched labels
was_selector_in_{ingress,egress} resolved the peer IP to a pod through the
node-local K8s object cache (its pod watch is filtered by spec.nodeName), so
it could only match peers scheduled on the same node as the workload. A
matching peer on any other node did not resolve, was treated as unknown, and
alerted — ingress detection worked intra-node but not inter-node.
Match instead on the peer identity the network gadget's IP resolver already
attaches to the event (namespace + pod labels), resolved against a
cluster-wide pod inventory before the event reaches CEL. The IP-to-pod lookup
is dropped entirely; matching on peer identity is stable across pod IP churn
and holds across nodes.
- utils/cel: expose event.dstNamespace and event.dstPodLabels
- was_selector_in_{ingress,egress}: signature is now
(containerId, namespace, podLabels); the map argument bypasses the scalar
function cache
- add a compile guard that type-checks the selector rule expressions against
the real event object type
---------
Co-authored-by: ConstanzeTU <74674840+ConstanzeTU@users.noreply.github.com>
commit 2002fc54a732f825249d4355a0a5172b8206f71a
Author: Duck <70207455+entlein@users.noreply.github.com>
Date: Thu Aug 6 10:36:56 2026 +0200
testing a fix (#60)
Signed-off-by: entlein <einentlein@gmail.com>
commit 6365bdb76a6c24dad8564091fe709dd6dee9698a
Merge: 61d695f5 0212cdc1
Author: ConstanzeTU <74674840+ConstanzeTU@users.noreply.github.com>
Date: Thu Aug 6 10:35:22 2026 +0200
Merge remote-tracking branch 'origin/migrate/sbob' into mirrormain
commit 61d695f58f70cadcb151b81c0b664062029ffca9
Merge: 9393b35c 064da675
Author: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Date: Thu Aug 6 09:42:53 2026 +0300
Merge pull request #879 from AlonLiwsky/feat/networkstream-process-attribution
feat(networkstream): process attribution on the network stream (SUB-7786)
commit 064da6757fe5706fcb73646029c23999b4507958
Author: Alon <alon@armosec.io>
Date: Wed Aug 5 17:20:01 2026 +0300
fix(networkstream): draw the random child count once; name the tests in the doc table (SUB-7786)
Two CodeRabbit nitpicks, both valid.
The randomised estimator generator had rng.Intn(6) in the loop condition, so Go redrew
the bound before every iteration instead of once. The loop still terminated, but the
child count was no longer uniform over 0..5: each step continues with probability
(5-c)/6, making 5 children ~1.5% likely instead of ~16.7% and dropping the mean from
2.5 to ~1.8. Fan-out is one of the inputs that drives a tree into the region where the
childrenMap key made the estimate undercount, which is the whole reason this generator
was strengthened, so it was starving its own purpose. Drawn once now.
The 4,000-connection row in the doc looks like it contradicts the two tests that drive
comparable process counts and assert the payload stays UNDER the limit. It does not: the
tests build synthetic events carrying only a ref and a key, ~94 B each, where a
production entry is ~530 B -- the tests bound what the code emits, the table adds the
real-world entry weight on top. Named both tests in the caption and spelled out the
difference so a reader does not mistake the residual for a failing bound.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit 8bc4f41e1b38eddb1b778aa5570b4825ff68620a
Author: Alon <alon@armosec.io>
Date: Wed Aug 5 17:11:53 2026 +0300
docs(networkstream): correct the saturated-tree breach threshold to ~3,600 (SUB-7786)
The previous commit corrected the saturated-tree figure from 2.27 to 1.91 MiB but left
the threshold that had been derived from the old figure at ~3,000, so the paragraph
contradicted its own arithmetic: 3.75 - 1.91 leaves 1.84 MiB, which is ~3,640 entries,
not ~2,900. The ~2,900 only followed from the retired 2.27 MiB.
Measured directly this time rather than derived from another derived number: a
saturated tree map marshals to 1,978,505 B (1.89 MiB), leaving 3,932,160 - 1,978,505 =
1,953,655 B (1.86 MiB), so ~3,690 entries at 530 B. Two independent measurements put it
at ~3,580 and ~3,600, so the doc states ~3,600 -- rounding low is the right direction
for a breach threshold. Corrected in all three places: the table caption, the residual
table row and the derivation.
Added the rule that produced both of the errors in this chain, so the next person does
not repeat it: derive the threshold from the measured tree total, never from a payload
figure that already contains entries.
Unaffected: entries alone still breach at 3,932,160 / 530 = ~7,400, and the operator
conclusion -- that maxProcessTreeBytes is a real lever because trees are roughly half
the payload in that regime -- holds unchanged at ~3,600.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit 892df8b63b4742bb10118e17929fc1366b7e68d7
Author: Alon <alon@armosec.io>
Date: Wed Aug 5 16:01:32 2026 +0300
docs(networkstream): correct the payload-residual arithmetic and name the operator lever (SUB-7786)
Addresses matthyx's correction. The residual paragraph said the connection entries
alone breach the message limit at ~3,000 connections and that no tree budget can help
there. The number was right but the attribution and the conclusion were both wrong.
~3,000 is the breach point WITH the tree budget saturated, not entries alone. The
usable budget is 3.75 MiB of JSON (5 MiB after base64 x1.333); at 4,000 connections
entries are 2.02 MiB and saturated trees ~1.91 MiB, so backing the trees out leaves
room for ~2,900 entries. Entries alone would not breach until 3,932,160 / 530 = ~7,400.
That makes "no tree budget can help" the damaging part: trees are roughly half the
payload in that regime, so tightening maxProcessTreeBytes moves the breach point out
toward ~7,400 as the budget approaches zero. As written, an operator whose node was
dropping traffic would have concluded the budget was not a lever when it is the fastest
one available -- no protocol change, no backend coordination. Both numbers are now
tabulated with what each one means.
Splitting the message stays the long-term fix, since tightening the budget buys
headroom by shipping fewer trees -- the attribution this feature exists to deliver.
Recorded one prerequisite before anyone builds it: the container-profile precedent
reacts to a synchronous HTTP 413 from storage's QueueManager, whereas this path posts
elsewhere, handles no 413 anywhere, and the 5 MiB cap is a broker limit whose
enforcement point SUB-7850 lists as unverified. If it is enforced downstream the sensor
sees 200 OK and never learns, so reactive splitting is unavailable and the split has to
be decided before the first send.
Also corrects the 4,000-connection row from 5.72 to 5.24 MiB: it double-counted the
entry cost, adding a modelled 530 B per entry on top of a measured payload that already
contained ~94 B per synthetic entry. Same class of error as the one above -- mixing a
measured figure with a modelled one -- so the table now says which is which.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit 9393b35c5cd34f831aa36c6755e4a151245efa4d
Author: Matthias Bertschy <matthias.bertschy@gmail.com>
Date: Wed Aug 5 14:17:18 2026 +0200
feat(otel): add pod-level memory metric, harden cgroup scope resolution (#883)
* feat(otel): add pod-level memory metric, harden cgroup scope resolution
Adds node_agent.pod.memory.cgroup_bytes/cgroup_limit_bytes, read from
the parent kubepods-*-pod<UID>.slice cgroup rather than the container
.scope dir, so third-party sidecars with no OTEL instrumentation of
their own (e.g. clamav, gated behind malwareDetection) are covered by
the same read the kernel already aggregates at that level. The
existing node_agent.process.memory.* container-scoped metrics are
unchanged.
Also fixes two defects in findCgroupScopeDir surfaced while
investigating a live cgroup_bytes < rss_bytes anomaly:
- matched container IDs via an unanchored substring, now a
delimited-segment match
- accepted the first name-matching .scope dir without checking it
actually held memory.current, silently caching a 0 read on
cgroup-v1/hybrid hosts where the walk reaches another controller
subtree first; now keeps walking until a real match is found
Per-pod verification against production telemetry confirmed the
scope of this: 241/690 (35%) of live pods currently report
cgroup_bytes == 0 while rss_bytes > 0 (the second defect's
signature), and 13/690 (2%) show a genuine per-pod inversion. Scoped
to the systemd cgroup driver; cgroupfs hosts are a tracked follow-up.
Docs updated in docs/metrics-migration.md.
Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
* fix(otel): stop cgroup scope resolution falling through to node-wide numbers
Addresses review feedback on #883 (both blockers):
1. resolveCgroupMemoryPaths fell through to the unscoped strategies
(parse /proc/self/cgroup, fixed v1 mount path) whenever the known-
container-ID scoped lookup failed. Those strategies are only valid
when the caller doesn't know its own container ID, or mounts its
own namespaced cgroup root (the sbom-scanner sidecar topology) --
for the main agent, which bind-mounts the host's cgroup tree, that
same root is the whole node. On any host where the scoped lookup
failed, this would have silently reported node-wide memory as the
container's own -- a wrong-but-plausible number, worse than the 0
it was meant to replace. A known container ID that can't be
scope-resolved now returns "", "" directly; the unscoped strategies
only run when ownContainerID == "".
2. findCgroupScopeDir now properly supports cgroup v1: it accepts a
verified directory with either memory.current (v2) or
memory.usage_in_bytes (v1), and resolveCgroupMemoryPaths reads
whichever filename pair is actually present. v1 hosts get correctly
container-scoped numbers instead of always reading 0. Also adds a
generous sentinel threshold so a v1 "unlimited" limit (a huge
near-MaxInt64 value) reports as 0, matching the v2 "max" convention,
instead of a misleading large number.
Also raises the pod-level resolver's rejection log from Debug to
Warning (agreed in review -- it resolves once per process, so no
volume concern, and it's the only signal that resolution silently
degraded).
docs/metrics-migration.md softened: the 35%-of-fleet figure cited
during development is real telemetry, but the causal attribution to
the v1/hybrid defect specifically wasn't confirmed (a simpler
empty-container-ID explanation is equally consistent with the same
observed zeros, and no cgroup-version signal exists in current
telemetry to distinguish them). This fix addresses both candidate
mechanisms either way, and makes the true split observable post-deploy
instead of asserted pre-deploy.
Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
* fix(otel): thread hostCgroupMounted explicitly, close the residual node-wide gap
Addresses the second review round on #883.
Residual blocker: the previous fix inferred "is the unscoped fallback
safe" from ownContainerID == "", conflating two different topologies:
"caller doesn't know its container ID" and "caller mounts its own
namespaced cgroup root". Only the second makes the unscoped strategies
(parse /proc/self/cgroup, fixed v1 mount path) safe. The Kubernetes
DaemonSet bind-mounts the HOST's cgroup tree and can still hit
ownContainerID == "" (a permanently-cached early-startup race in
resolveOwnContainerID, before this pod's own ContainerStatuses entry
exists) -- and when it does, the old gate still fell through and would
have silently reported the whole node's memory as this container's
own, exactly the failure mode the previous commit claimed to close.
Fixed by passing the topology explicitly instead of inferring it:
hostCgroupMounted bool, threaded through NewOTELMetricsManager ->
registerResourceMetrics -> RegisterProcessMemoryMetrics/
RegisterPodMemoryMetrics -> readCgroupMem/readPodCgroupMem ->
resolveCgroupMemoryPaths(Under). cmd/main.go (the only caller in this
repo) passes true; the sbom-scanner sidecar's direct call passes
false. When hostCgroupMounted is true, only the scoped lookup (by
container ID) is ever tried; if it fails, this now returns "", ""
unconditionally -- the unscoped strategies never run, regardless of
why the container ID wasn't known.
New pinning test reproduces the reviewer's exact PoC: an empty
container ID on the host-mounted topology, with a v1 fixed-mount file
present at root, must still return "", "" rather than read it.
Also, from the same review round:
- Closed the observability gap: resolveOwnContainerID's two silent ""
returns now log Warning, and RegisterPodMemoryMetrics logs Warning
when an unresolved container ID reaches the DaemonSet topology
specifically (still silent for topologies with no pod concept at
all, so this doesn't add noise for cmd/host or cmd/ecs).
- Closed the pod-level cgroup-v1 gap found as a side effect of the
container-level v1 support: the pod-level resolver now also accepts
memory.usage_in_bytes at the verified parent slice, not just
memory.current. cgroupfs remains the only declared non-goal.
- Moved the cgroupScopeDir package-var publish out of the testable
..Under core and into the real resolveCgroupMemoryPaths wrapper, so
test invocations with synthetic roots no longer leave stale state in
the global (test-pollution nit).
docs/metrics-migration.md updated to describe the topology-explicit
model and the new observability signals.
Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
---------
Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
commit 24bf616f0872aa1dba5c00c6bdf24ebdc0ade98e
Author: Alon <alon@armosec.io>
Date: Wed Aug 5 13:57:15 2026 +0300
test+refactor(networkstream): pin the ranking order, derive the budget counters (SUB-7786)
Addresses matthyx's review.
1. The ranking order was not pinned. Inverting the estBytes comparator left the whole
suite green, TestSelectProcessTrees_PrefersSmallestTrees included, because every tree
in it is the same size -- ordering only decided WHICH equal-sized trees shipped, and
the beacon's small tree landed in the leftover budget either way. His test mixes two
size populations so the shipped COUNT depends on the order, which is the property
smallest-first exists for. Verified: ships 126 (= maxProcessTreeBytes/smallEst) as
written, 34 with the sort inverted, and the displacement assertion trips too.
2. The skip-vs-break branch is unreachable, so the comment claiming otherwise was
wrong. His proof holds: with the ascending sort, if candidate i does not fit then
used+est_i > budget, and for every j>i we have est_j >= est_i while used never
decreases, so nothing after the first miss can fit. Kept the skip as defensive against
a future ordering change, but it no longer claims to be load-bearing, and the test
named for it now states what it does and does not prove.
3. The drop counters are now derived -- len(candidates) minus len(processes), and total
connections minus shipped -- rather than accumulated in the packing loop. They are the
decision input for whether the budget needs raising, so they should not depend on the
loop body: as written before, a future early exit would have logged treesDropped 0
while dropping hundreds, with nothing asserting otherwise. Extracted selectWithinBudget
so the counters are testable, and pinned the identities that must hold regardless.
4. processNodeOverheadBytes' comment claimed "~360 measured" while the constant is 320.
He was right that the comment is the stale part: measured 133 bytes for a node with
every numeric at max and strings empty, plus ~16 for the childrenMap wrapper, so ~149
actual. The comment now says 320 is deliberate headroom -- absorbing UniqueID and
future fields -- rather than pretending to be a measurement.
Also documented two costs he raised that are real but out of scope here. The ref lookup
is on the packet path for every event including duplicates (unavoidable: the dedup key
contains the ref), and under exec churn his benchmark puts it at ~3.9x on that path --
write-lock contention on the manager mutex. The fix is a dedicated lock or atomic read
for the creator's pidStartTimeNs side map, which lives in pkg/processtree/creator and
belongs to the workstream that owns it. And the budget bounds the wire but not the
heap: storage retains one entry per process per endpoint per interval, each pinning a
tree, which cannot be capped without reintroducing the connection drops this fixes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit 86ea7a082dc6e389b6fcad6a11dae77164f2fd3d
Author: Alon <alon@armosec.io>
Date: Wed Aug 5 10:13:25 2026 +0300
fix(networkstream): charge the childrenMap key and escape containerID in the estimate (SUB-7786)
Addresses CodeRabbit's childrenMap-key finding. A child's comm is emitted TWICE --
as its own field and inside the parent's childrenMap key, which CommPID.MarshalText
renders as comm<U+241F>pid -- and the estimate charged it once, funding the second
copy out of per-node slack. estimateTreeBytes also charged containerID with a bare
len(), three lines above a comment saying never to do that; it was the last one.
Neither is reachable in production: comm is only ever 15 bytes because every source
is a kernel TASK_COMM_LEN buffer (eBPF GetComm, procfs stat.Comm), and container IDs
are hex. Measured at that bound the old accounting stays positive by +170 to +7291,
so CodeRabbit's Major severity is overstated -- I could not reproduce an underestimate
with a 15-byte comm. But the estimate is a BOUND, and it must not rest on an
invariant nothing in this repo enforces: remove the kernel's comm limit and the old
accounting runs 48% under (est 1,323,856 vs 2,531,171 marshalled), which is exactly
the silent over-limit message the budget exists to prevent.
Two corrections to the finding: the CommPID separator is U+241F (3 bytes), not '/',
and the deficit needs a fully-populated node, not any node.
The guard tests could not have caught this -- their children set four fields, so the
slack was never consumed; 200k randomised trees found nothing. The generator now
builds fully-populated children with escape-heavy 15-byte comms, and the table adds
the cases that actually discriminate (unbounded comm, both child shapes, escape-heavy
containerID), all three verified to fail without the fix.
Recalibrated: a realistic 10-node chain now estimates 5683 (was 5102, ~33% over
marshalled), so the budget holds ~461 trees rather than ~513. Still 1.6x the largest
batch observed, and the 283-connection worst case still ships every tree.
Two pre-existing assertions had tight constants tied to the old per-node accounting;
both now state their intent proportionally instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit a086e68ee86f45a858ce05df8da574bd4c990797
Author: Alon <alon@armosec.io>
Date: Tue Aug 4 18:16:56 2026 +0300
style(networkstream): trim comments, keeping the reasoning in the feature doc (SUB-7786)
The comments had grown to restate measurements, production numbers, calibration
tables and history inline -- all of which is already in
docs/features/network-stream-process-attribution.md, where it belongs and can be
kept current. Inline, it just made the code harder to read.
Kept only what a reader needs at that spot: the invariants that cause a bug if
violated (never scale StartTimeNs, never write to a shared tree node, don't
replace the copier with DeepCopy, take the ref before the mutex) and one pointer
to the doc. 419 -> 287 comment lines, no behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit ba68c0678df1c75bef54eb50725ab6dcb20b6e3a
Author: Alon <alon@armosec.io>
Date: Tue Aug 4 11:35:06 2026 +0300
test(networkstream): randomised guard that the tree estimate never undercounts (SUB-7786)
The budget's soundness is a property over ALL inputs, not the shapes I happened to
enumerate, and process argv is arbitrary kernel bytes. 400 randomised trees built
from bytes weighted toward what JSON escapes -- <, >, &, control bytes, the short
escapes, and mostly-invalid high bytes -- each checked against real json.Marshal
output. Fixed seed, so a failure reproduces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit 733d9bac0c58874e0656c1bd4098cfb1a68af699
Author: Alon <alon@armosec.io>
Date: Tue Aug 4 11:29:34 2026 +0300
fix(networkstream): make the tree budget an actual bound (JSON escaping) (SUB-7786)
The budget was not a bound. estimateTreeBytes charged len(s), but that is not what
encoding/json emits: it escapes " and \\, every control byte, and -- because
Marshal enables HTML escaping -- <, > and &, which real command lines are full of
(sh -c 'cmd > /dev/null 2>&1'). Invalid UTF-8 is replaced byte-for-byte with the
6-byte \ufffd, and process argv is arbitrary kernel bytes, not guaranteed UTF-8.
Each such byte costs up to six where len() counted one.
Measured: 629 processes with 1 KB of non-UTF-8 argv each estimated at 772 KB --
29% of the budget -- so the under-budget fast path shipped every tree, dropped
nothing and logged nothing, while the real message was 5.37 MB after base64. The
broker rejects that, the snapshot is dropped, and the node loses its ENTIRE
interval of traffic. Reachable by an ordinary shell loop, and reachable
deliberately by anyone who can exec in a container on the node -- which made it a
detection-evasion primitive with a wider blast radius than the data-loss bug this
branch fixes. It also undercounted ordinary traffic: a realistic single node
estimated 372 against 395 marshalled.
escapedLen now charges the true escaped cost, rounding every escape up to 6 bytes,
and processNodeOverheadBytes goes 200 -> 320, measured against a fully-populated
node rather than a sparse test one. The estimator now overestimates by ~19% for
realistic trees. TestEstimateTreeBytes_NeverUnderestimates enforces the direction
across 15 shapes -- escape-heavy, non-UTF-8, wide, over-deep, legacy Children --
and fails on 10 of them with the old len()-based estimate.
Ranking changed from most-connections-first to smallest-tree-first. The old
rationale was backwards for the threat it named: a low-and-slow beacon opens
exactly one connection per interval, so it sorted last and lost its tree first --
the precise case reputation attribution exists to catch. Smallest-first maximises
the number of processes keeping a tree, which is the best objective available when
the sensor cannot know which process matters.
Also closes test gaps a mutation pass found: the determinism contract was unpinned
(removing both ref tie-breaks passed the suite), as were skip-vs-break packing, the
estimator's legacy-Children branch, the wrapper-field copy and countConnections.
All numbers in the code comments and the feature doc are re-derived from one
measured set -- the previous 4,000-connection row claimed 3.85 MB using lean test
entries when production-weight entries put it at 6.01 MB, over the limit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit b32773d5d1fdf7480ce2fb187569f4e13358c7e5
Author: Alon <alon@armosec.io>
Date: Tue Aug 4 10:37:59 2026 +0300
fix(networkstream): calibrate the tree budget against measured production traffic (SUB-7786)
The 1.5 MiB budget was picked from arithmetic, and it was wrong: it binds at
213 of 283 trees, and 283 connections is the largest batch ever observed in
production. It would therefore clip trees routinely on the busiest nodes --
degrading exactly the attribution this work adds -- while the payload at that
point is only 48% of the 5 MiB limit.
Recalibrated to 2.5 MiB using measured inputs rather than assumptions. The
reputation consumer gives the missing number: network_reputation_events_in_total
over the topic's message count puts a batch at ~42 connections in prod-eu (~32 in
prod-us), which against a 29 KB mean message makes a connection ~530 bytes of JSON
without its tree. Taking the worst case the budget exists for -- every connection
from a distinct process, so trees scale 1:1 -- 283 connections with p90 (~7 KB)
trees now ships all 283 trees at 2.50 MB after base64, 48% of the limit. The
budget binds above ~360 distinct processes at p90 and ~1280 at median, so it stays
a safety valve rather than a routine limiter.
TestBuildWireStream_ObservedWorstCaseFitsBudget pins the calibration itself and
fails at 1.5 MiB, so a future change cannot silently start clipping observed
traffic.
Also fixes the test helper that made the original numbers untrustworthy: it piled
the whole target size into one command line, which the 1 KB cap then truncated, so
it produced ~1.2 KB trees however large a size it was asked for -- and the
calibration test passed at both budgets because of it. It now builds a chain the
way a real tree gets big, and TestBigTree_ReachesRequestedSize keeps it honest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit 0dd6419850290b2eb8bb24c290a8a28394821e6e
Author: Alon <alon@armosec.io>
Date: Tue Aug 4 10:07:27 2026 +0300
feat(networkstream): bound the process trees per message, and make the size observable (SUB-7786)
Attribution changed what sizes the payload. The old batch key collapsed every
process reaching one endpoint into a single entry carrying no tree, so size
tracked distinct ENDPOINTS. The new key splits per process and each distinct
process contributes a tree, so size tracks distinct PROCESSES that connected --
and nothing bounded that. SUB-7850's analysis modelled bytes per connection at a
fixed 283 connections, which is precisely the quantity the key change stops
holding fixed, so the multiplier on connection count was never budgeted.
Measured with ~2 KB trees: 4,000 distinct processes produce 4.99 MB of JSON, or
6.65 MB once the synchronizer envelope's base64 applies -- over the 5 MiB limit.
That is not graceful: sendNetworkEvent gets a non-2xx, Start() logs it and drops
the snapshot, so the node loses its ENTIRE interval of traffic. Reachable on a
node with heavy short-lived process churn.
maxProcessTreeBytes budgets the trees at 1.5 MiB of estimated bytes. A byte
budget rather than a tree count, because tree size still varies ~2.5x under the
command-line cap. Connections are never dropped -- that is the data loss this
change exists to fix -- only trees, and the refs stay put, so pid identity
survives and ProcessTreeFor returns nil for them as specified. Candidates rank by
connection count (highest fan-out is both the costliest attribution to lose and
the shape reputation cares about), ties broken on the ref so the payload never
depends on map iteration order.
How often this binds in reality is NOT knowable from current data: today's
sensor strips trees and emits no process identity, so distinct-processes-per-batch
exists in no message. Fleet mean today is ~30 KB (pulsar_average_msg_size on
network-stream-v1), ~175x under the limit -- comfortable baseline, but silent on
the new multiplier. So both the budget firing and any payload above 2 MiB now log
with their shape, which is what makes the question answerable after rollout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit dee5d909c57f0a6b7f06206ae95c11b665744a12
Author: Alon <alon@armosec.io>
Date: Mon Aug 3 19:08:17 2026 +0300
fix(networkstream): address code review (SUB-7786)
Derive the wire copy BEFORE handing the snapshot to the notification channel.
Both were already outside the lock and buildWireStream is read-only, so this was
not live -- but the ordering made the producer re-read maps the consumer already
owned, resting on an out-of-repo guarantee that the consumer never writes to what
it receives. Reversing the two lines removes the dependency entirely.
capTreeCopy now copies the ProcessTree wrapper wholesale instead of field-listing
it, so a field added to the wrapper later cannot be silently dropped -- the exact
trap the three existing process copiers fell into. It also no longer assumes the
inner copy is non-nil.
Tests: two of the claims were not actually pinned. TestNoTickScaling asserted only
that buildWireStream does not rewrite an already-correct literal, so it passed
with a /10_000_000 injected into processRefFor, where such a bug would live; it
now runs the whole producer path. The shutdown-honouring select added in ad55206c
was covered by nothing -- every channel test buffers so the producer never blocks
-- so a plain blocking send passed the suite; TestFlush_BlockedChannelSendHonoursShutdown enters the blocked path and fails against that mutation. Also replaced a
fixed sleep with a poll on an observable signal, and covered the unattributed DNS
key and the nil-manager branch.
Docs: the key-collision claim was categorical but holds only for the structured
IP form, not for unstructured DNS names; and the lock-discipline table described
the flush while omitting that handleNetworkEvent holds the same mutex across an
unbounded net.LookupAddr (pre-existing, untouched here).
Dropped the two stale armoapi-go v0.0.696 go.sum lines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit 0b30903a0e1fadb76cc89519758c664365f96609
Author: Alon <alon@armosec.io>
Date: Mon Aug 3 18:07:16 2026 +0300
fix(networkstream): let the flush honour shutdown while blocked on the channel (SUB-7786)
Moving the notification-channel send out of eventsStorageMutex removed the stall
on event recording but left the send itself blocking. A consumer that stops
reading would pin the flush goroutine past ctx cancellation, which the previous
under-the-lock send could not select against. The send stays blocking, so a slow
consumer still applies backpressure rather than silently losing traffic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit 29f246e184efbdbacaf4764b8f21e6852d11cd73
Author: Alon <alon@armosec.io>
Date: Mon Aug 3 18:01:49 2026 +0300
feat(networkstream): ship trees once per process, capped, with the attribution marker (SUB-7786)
buildWireStream derives the HTTP payload from the flush snapshot: per-event trees
move into the message-scoped Processes map, one copy per distinct ProcessRef, and
each event keeps only its ref. Trees dominate the payload, so shipping them once
per process rather than once per connection is the size decision; the duplicated
ref bytes are budgeted. ProcessAttributionVersion is stamped unconditionally --
an empty Processes map is not a capability signal, so a sensor that ran and found
nothing must stay distinguishable from one that predates attribution.
On collision the deeper chain wins: the tree cache TTL (1 min) is shorter than
the flush interval (2 min), so two lookups for one process inside one interval
can return chains with different ancestry resolved. First-wins would discard the
richer chain and make the payload depend on map iteration order.
Command lines are capped at 1024 bytes with a visible marker, never splitting a
rune. cmdline is ~40% of a tree's bytes and unbounded, so this is what bounds the
payload tail rather than an optimisation.
The copy is read-only by hand rather than via armotypes.Process.DeepCopy, which
mutates its receiver: it calls MigrateToMap -- allocating ChildrenMap and nilling
Children -- on itself and on every child it recurses into. Those nodes are shared
with the process-tree manager's LRU cache, the legacy alert paths and the
notification-channel consumer, so writing to them from the flush goroutine would
be a data race that also strips the uncapped values those consumers rely on.
copyCappedProcess normalises the deprecated Children slice on read instead, and
every walk is depth-bounded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit 6f5c8dd44d2c0d52889226d2e6281491b7a039a1
Author: Alon <alon@armosec.io>
Date: Mon Aug 3 17:52:59 2026 +0300
refactor(networkstream): independent flush snapshot; channel keeps trees, sleep removed (SUB-7786)
The flush handed the notification channel the LIVE storage struct, then -- still
holding eventsStorageMutex -- slept 100 ms and stripped every process tree from
the maps the consumer was reading. private-node-agent's host network sensor
reads outbound.ProcessTree off that channel, so the sleep was the only thing
standing between it and having its data erased mid-read. A test proved worse: an
event recorded 300 ms AFTER the flush appeared in the already-delivered
snapshot, because the clear loop and the consumer shared the same maps.
snapshotNetworkStream now allocates its own event maps, so the consumer's view
is immune to everything the producer does next. Trees are shared by pointer, not
walked: a tree is immutable once attached, which keeps the lock body to
O(entities + connections) struct copies. Both sends move outside the lock, and
the 100 ms sleep is deleted rather than shortened -- there is no shared state
left to race on. removeProcessTreeFromEvents goes with it; the wire copy is
where trees leave the payload from here.
Adds docs/features/network-stream-process-attribution.md, covering the emitted
schema, the batch key, the channel contract and the lock discipline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit 8631fa10cd521960af548c45e328821ccd31c89e
Author: Alon <alon@armosec.io>
Date: Mon Aug 3 17:34:47 2026 +0300
feat(networkstream): per-connection process ref + process-aware batch key (SUB-7786)
The per-batch connection key was address/port/protocol with first-writer-wins,
so a second process connecting to the same endpoint was silently DISCARDED --
data loss, not merely misattribution. The same held for two processes resolving
one domain. The key now carries the process ref, so distinct processes coexist
while first-writer-wins is retained per process.
The ref is appended, never reordered: an unattributed key stays byte-identical
to the old format and can never collide with an attributed one. StartTimeNs is
boot-relative nanoseconds emitted verbatim; zero is legal pid-only identity.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit a4b27b6436e6e89581a3f15da08495ec23c97de5
Author: Alon <alon@armosec.io>
Date: Mon Aug 3 17:25:42 2026 +0300
chore: pin armoapi-go v0.0.742 for NetworkStream process attribution (SUB-7786)
v0.0.696 predates the process-attribution schema (ProcessRef,
NetworkStream.Processes, ProcessAttributionVersion). v0.0.742 is the exact
tag carrying it — v0.0.741 does not, so a newer tag is not evidence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Alon <alon@armosec.io>
commit 5b5ff1c230e933bb679c05c9fa30f7cdccbc485e
Merge: 8866b6c8 dd16818c
Author: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Date: Wed Aug 5 09:24:55 2026 +0300
Merge pull request #876 from AlonLiwsky/fix/processtree-pid-recycle-lifecycle
fix(processtree): correct the exit-cleanup lifecycle under shutdown and pid reuse (SUB-7847, SUB-7846)
commit dd16818c09301abf5720f6248d5c545cbaa8b988
Author: Alon <alon@armosec.io>
Date: Mon Aug 3 18:42:02 2026 +0300
fix(processtree): harden the tree against process-id reuse (SUB-7846)
The kernel recycles pids, and the tree keeps an exited process's node for up
to exitCleanup::cleanupDelay. For that window a pid can be held by a LIVE
process while the tree still holds the DEAD one's node under it. Nothing
noticed, and two things went wrong. Both are reproduced in
pid_reuse_test.go, not inferred — the delayed-deletion half was found by
matthyx reviewing #873 and reproduced on that PR's head:
- handleForkEvent fills only EMPTY fields and a stale node is not empty, so
a recycled pid kept the predecessor's Comm, Cmdline, Path, Cwd, Uid and
Gid. A runtime alert of any type could name the wrong command.
- exitByPid matches on pid alone, so the dead process's delayed exit deleted
whatever node held that pid by then — a live process — reparenting its
children around it and taking its start time with it.
Three layers, each falling through to today's behaviour when its input is
unknown, and none of which ever KEEPS a node it cannot prove is newer:
1. A fork or exec on a pid with a pending exit retires the predecessor. The
event proves a new live incarnation, since a zombie can neither fork nor
exec, so no clock is needed. Deliberately NOT applied to procfs events:
/proc lists zombies, so there a pending exit can belong to the same
incarnation and proves nothing.
2. A delayed exit skips a node whose recorded creation postdates the exit
event's arrival. Both sides are boot-relative — the side map is procfs
ticks x 10^7, the arrival is CLOCK_BOOTTIME — so there is no btime skew
and no wall-clock margin. pendingExit.Timestamp and .StartTimeNs stay
wall-clock with their existing jobs and are not used here.
3. The procfs scan rebuilds a node whose start time changed. Two readings of
one process yield identical ticks, so a different non-zero value is proof
with no tolerance needed. The rebuild re-applies the Kubernetes
host-process policy, which the first-sighting path enforces and an
unconditional rebuild would bypass.
Layer 1 implements the shape matthyx proposed on SUB-7846, with one
difference. His snippet forces ok = false from the branch where a stale node
was found; the guard here runs before the lookup instead, so ok is false
naturally and no stale proc pointer exists. That also covers a case the
in-branch position misses: a pending exit can outlive its node, and the
recycled fork then builds a fresh node that the stale entry deletes at the
next cleanup. TestPidReuse_ForkAfterExit_ConsumesPendingExitWithNoNode fails
if the guard is gated on an existing node.
Retiring means the full teardown, NOT delete(pt.pendingExits, pid). That
simplification passes the obvious tests while leaving the dead process's
children linked to the pid, so the new process silently inherits them and
the tree claims an unrelated live process is their parent. It is quieter
than the bug it replaces.
TestPidReuse_ForkAfterExit_DoesNotInheritDeadChildren fails if anyone tries
it.
removeProcessNode extracts the teardown so the exit path and the procfs
rebuild cannot drift; it is independent of pendingExits because the rebuild
needs it for a pid with no pending exit at all. The extraction landed with
all 24 exit-manager and reparenting tests green before layer 3 used it. It
returns false when reparenting fails, and both callers then leave their
bookkeeping alone so the operation degrades to today's retry-next-tick. A
nil reparenting strategy takes the same failure path rather than being
dereferenced: unreachable today, since NewReparentingLogic ends in an
unconditional `return rl, nil`, but the agent has no recover() anywhere, so
the panic would end the process rather than one goroutine.
Under pt.mutex the additions are a map lookup and an integer comparison, and
no /proc read is added. The one syscall this work needs — CLOCK_BOOTTIME for
layer 2 — is read in handleExitEvent BEFORE the lock and passed into
addPendingExit, because unix.ClockGettime is a raw syscall rather than a vDSO
call and exits are about as frequent as forks. Same pattern, and same reason,
as the fork path's ~7.5 us start-time read.
forceCleanupOldest loses its second, redundant pass over the same pending
exits. That pass was silent while a repeat call always found the node gone;
now that a guard can keep a node and consume its pending entry, it would log
a warning for every node kept.
This is shared process-tree lifecycle: GetContainerProcessTree serves the
rule manager and every exporter, so this path sits behind every alert type.
Same-incarnation behaviour is therefore pinned by controls —
ProcfsSameStartTime_MergesAsToday keeps the merge semantics and children,
DelayedExit_NormalExitStillDeletes keeps ordinary exits deleting (without
it, an inverted comparison would leak every exited node and nothing would
catch it), and DelayedExit_UnknownStartTimeStillDeletes keeps the no-data
case byte-for-byte today's logic. Each guard was mutation-tested: deleting
it must turn a named test red.
The exit-manager lifecycle doc's SUB-7846 caveat is updated here rather than
in the preceding commit, where it was still accurate and where this doc does
not yet exist to link to.
Three limitations are documented rather than fixed. Layer 1 cannot
distinguish a recycle from a reordered exit, since the tracers do not
preserve kernel order across one queue. Layer 3 declines when the side map
holds no prior value, which lets layer 2 keep a node the scan merged rather
than rebuilt. And when a fork is processed BEFORE the exit it follows, all
three layers decline and both original bugs survive — confirmed against the
implementation, and left alone because the window is about one drain batch
and closing it would make the guards depend on ordering they cannot verify.
exitByPid settles the node-absent case before the arrival comparison, so the
guard cannot return while leaving an orphaned side-map entry. Unreachable
today, since side-map entries only e…
Summary
Puts process identity on the network-stream wire, so a malicious-endpoint finding can
aggregate onto the same incident as the exec, file and other findings from the same
process chain. Until now the stream carried no process data at all on the wire: the
sensor captured a process tree per connection and then stripped it before sending.
This also fixes silent data loss. The per-batch connection key was
address/port/protocolwith first-writer-wins, so when two processes in one containerreached the same endpoint inside a flush interval, the second connection was discarded
entirely — it never reached the wire under any identity. The same held for two processes
resolving one domain.
TestHandleNetworkEvent_TwoProcessesSameEndpointpins that: beforethe fix the batch holds one entry, not two.
What changed
processRefper connection, plusprocessAttributionVersionper message(
NetworkStreamProcessAttributionV1 = 1, set unconditionally — an emptyprocessesmap is not a capability signal).
stays byte-identical to the old format. For IP connections the forms cannot collide
(address/port/protocol are structured, so an attributed key carries exactly two more
components); for DNS the name is unstructured, so a contrived name ending in
/<pid>/<startTimeNs>can collide, costing one record — documented in the feature doc.First-writer-wins is retained per process.
processesmap, not once perconnection — trees dominate the payload, the duplicated ref bytes do not. On collision
the deeper chain wins, because the tree cache TTL (1 min) is shorter than the flush
interval (2 min) and first-wins would both discard the richer chain and make the
payload depend on map iteration order.
…,never splitting a rune.
cmdlineis ~40% of a tree's bytes and unbounded, so this iswhat bounds the payload tail rather than an optimisation.
rather than papering over it (below).
sizes the payload (below).
go.mod:armoapi-gov0.0.696 → v0.0.742, the exact tag carrying the schema.v0.0.741 does not contain it, so a newer tag is not evidence.
docs/features/network-stream-process-attribution.md.The flush race, and lock discipline
The flush used to hand the notification channel the live storage struct, then — still
holding
eventsStorageMutex— sleep 100 ms and strip every process tree from the mapsthe consumer was reading. The sleep was the only thing standing between
private-node-agent's host network sensor and having its data erased mid-read. A testshowed worse: a connection recorded 300 ms after the flush appeared inside the
already-delivered snapshot, because the clear loop and the consumer shared the same maps.
snapshotNetworkStreamnow allocates its own event maps, so the delivered value isimmune to everything the producer does next. The 100 ms sleep is deleted rather than
shortened — there is no shared state left to race on.
This file has a prior mutex-stall fix in its history, so the bound on lock-held work is
part of the design:
eventsStorageMutexTree pointers are copied into the snapshot, never walked. That is safe because a tree
is immutable once attached:
buildBranchToShimallocates fresh nodes per branch, soevent trees are not shared with the process tree's live map — only with its 1-minute LRU
entry, which nothing writes to after construction. The ref is also computed before
eventsStorageMutexis taken, so no storage→tree lock-order edge is added.Payload budget — a gap in the approved plan, not only in the code
Attribution changed what sizes the payload. The old batch key collapsed every
process reaching one endpoint into a single entry carrying no tree, so size tracked
distinct endpoints. The new key splits per process and each distinct process
contributes a tree, so size tracks distinct processes that connected — and nothing
bounded that. SUB-7850's analysis modelled bytes per connection at a fixed count of
283 connections, which is exactly the quantity the key change stops holding fixed, so
the multiplier on connection count was never budgeted.
Breaching the broker's 5 MiB limit is not graceful:
sendNetworkEventgets a non-2xx,Start()logs it and drops the snapshot — the node loses its entire interval oftraffic, no retry, no split. A node with heavy short-lived process churn (a cron loop
shelling out, a CI runner, exec-based probes) can reach that.
maxProcessTreeBytesbounds it at 2.5 MiB of estimated tree bytes, calibratedagainst measured production traffic rather than guessed:
pulsar_average_msg_sizeonnetwork-stream-v1network_reputation_events_in_total÷ topic message countMeasured end to end against the worst case the budget exists for — every connection
from a distinct process, so trees scale 1:1 — with production-weight entries included:
Design points:
are variable-length, JSON escaping inflates some content ~6×), so no count bounds the
payload. The estimator overestimates realistic trees by ~19%, and two tests enforce
that it never goes the other way (see below).
are, and the refs stay on the connections, so pid identity survives and
ProcessTreeForreturns nil for them as specified.processes keeping a tree. Connection-count ranking was tried and rejected: a
low-and-slow beacon opens exactly one connection per interval, so it sorted last and
lost its tree first — the precise case reputation exists to catch.
busiest nodes. A tighter 1.5 MiB was tried first and rejected — it clips 70 of 283
trees while the payload there is barely a third of the limit.
TestBuildWireStream_ObservedWorstCaseFitsBudgetpins this and fails at 1.5 MiB.The budget only became a real bound after a review caught this
The first version charged
len(s)per string. That is not whatencoding/jsonemits: it escapes
"and\, every control byte, and — becauseMarshalenables HTMLescaping —
<,>and&, which real command lines are full of(
sh -c 'cmd > /dev/null 2>&1'). Invalid UTF-8 becomes the 6-byte\ufffd, and processargv is arbitrary kernel bytes. Each such byte costs up to six where
len()countedone.
Measured: 629 processes with 1 KB of non-UTF-8 argv each estimated at 29% of the
budget, so nothing was dropped and nothing logged, while the real message was
5.37 MB after base64 — rejected by the broker, node loses its whole interval. It also
undercounted ordinary traffic (a realistic node: 372 estimated vs 395 marshalled).
Reachable deliberately by anyone able to exec in a container on the node, which made it
a detection-evasion primitive with a wider blast radius than the bug this PR fixes.
escapedLennow charges the true escaped cost, and the direction is enforced rather thanasserted:
TestEstimateTreeBytes_NeverUnderestimatesover 15 adversarial shapes (failson 10 of them with the old estimator) plus a 400-case randomised version checked against
real
json.Marshaloutput.How often this binds in reality is not knowable from current data, because today's
sensor strips trees and emits no process identity — distinct-processes-per-batch exists
in no message. So two log lines make it answerable after rollout:
process tree budget exceeded(withprocessesWithTrees,treesShipped,treesDropped,connectionsWithoutTree) andlarge payloadabove 2 MiB (size, entity, connection andtree counts). Quiet at the ~30 KB fleet mean. If the first fires routinely, the budget
should become configurable rather than being raised blind.
Residual, deliberately not fixed here: with trees bounded, the connection entries
alone breach the limit at roughly 3,300 connections (11× the observed worst; measured
6.01 MiB at 4,000). No tree budget can help there — the fix for that regime is splitting
the message, as
docs/features/container-profile-split-on-413.mddoes on HTTP 413, notdropping connections.
Not
Process.DeepCopy()armotypes.Process.DeepCopymutates its receiver: it callsMigrateToMap, whichallocates
ChildrenMapand nilsChildren, and it does so on every child it recursesinto. Those nodes are shared with the process-tree manager's LRU cache, the legacy alert
paths and the channel consumer, so calling it from the flush goroutine would be a data
race that also strips the uncapped command lines those consumers rely on.
copyCappedProcessis read-only instead, normalising the deprecatedChildrensliceinto the copy without touching the source.
TestBuildWireStream_DoesNotMutateSharedTreepins this so a future refactor cannot regress to
DeepCopy. Every recursive walk isdepth-bounded (64).
StartTimeNsis boot-relative nanoseconds, emitted verbatimConverted once on the way in from
/proc(SUB-7845); nothing here rescales it. Adivision would still join correctly within one message — key and ref share a producer —
while silently breaking identity across messages by seven orders of magnitude.
StartTimeNsappears in exactly one non-test statement in this diff and in no arithmeticexpression. Zero is legal and means unknown; the ref is emitted anyway, degrading to
pid-only identity. See
docs/features/process-start-time.md.Handoff for the host/ECS workstream
cmd/host,cmd/ecs, the KubernetesMode streaming gate andpkg/hostnetworksensorareuntouched. Inheriting from this change:
sleep existed to survive. The channel now delivers an independent snapshot, still
with trees.
context cancellation.
channel is the only consumer today.
Testing
pkg/networkstreamcannot be built or tested on macOS (inspektor-gadget/pkg/utils/hostexcludes darwin, imported transitively). Everything below ran in a Linux container:
pkg/networkstream/v1had two test functions before this change, so it brings its owncoverage.
go test ./pkg/...shows the same pass/fail set asmain: the onlyfailures are pre-existing and unrelated (
pkg/containerwatcher/v2/tracersneeds atracers.tarbuild artifact;pkg/validatorTestCheckPrerequisites).Two plan amendments this work implies
Neither is a code change; both are for the plan owner:
cmdlinecap were the only lever.It is not — the connection-count multiplier introduced by the key change was never
analysed, which is what the budget above addresses.
5.1 KB, while the epic plan's "~2 MB at 283 connections" implies ~7 KB. This work
calibrated against 7 KB (the pessimistic figure).
CI note
build-and-push-imagewill fail on missing Quay secrets. That is structural for across-fork PR — the job gets no secrets — not a defect in this change.
AI Review
Two independent fresh-context reviews by Claude Opus 5, each in a clean subagent
following
armosec-shared-rules:code-review-standards, each with its own probe tests andmutation passes.
Round 1 (commits through
ad55206c) — no must-fix. It independently confirmed thefour highest-risk properties: no
StartTimeNsscaling anywhere, no lock-order hazard, nodata race under
-racewith concurrent producers/flushers over a shared tree graph, andthat trees really are immutable once attached (so sharing pointers into the snapshot is
safe). It also confirmed the
DeepCopydeviation is justified by reading the armoapi-gosource. Six should-fix/nit items were addressed in
82ba0dd2, including two of my owntests that did not pin what they claimed —
TestNoTickScalingpassed with a/10_000_000injected intoprocessRefFor, and the shutdownselectwas covered bynothing because every channel test buffers so the producer never blocks. Both now fail
against those mutations.
Round 2 (the budget commits, which round 1 never saw) — one must-fix, now fixed.
The tree budget was not a bound: the estimator charged
len(s)whileencoding/jsonescapes, so 629 processes with non-UTF-8 argv estimated at 29% of budget and produced a
5.37 MB message that the broker rejects, costing the node its whole interval — see the
section above. Fixed in
1e978edewithescapedLen, a raised per-node overhead, a15-shape property test and a 400-case randomised one, both checked against real
json.Marshaloutput. The review also found the determinism contract, skip-vs-breakpacking, the estimator's legacy-
Childrenbranch and the wrapper-field copy were allunpinned (mutations survived the suite) — closed in the same commit — and that my
connection-count ranking rationale was backwards for the beacon case it named, which is
why ranking is now smallest-tree-first.
Verdict: approved after fixes. Everything else in both rounds was verified correct.
Round 2's closing note: "the test suite is unusually load-bearing: 5 of 13 mutations were
killed by named assertions."
Ticket
SUB-7786
AI-skills: none
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests
Chores