Skip to content

feat(networkstream): process attribution on the network stream (SUB-7786) - #879

Merged
AlonLiwsky merged 16 commits into
kubescape:mainfrom
AlonLiwsky:feat/networkstream-process-attribution
Aug 6, 2026
Merged

feat(networkstream): process attribution on the network stream (SUB-7786)#879
AlonLiwsky merged 16 commits into
kubescape:mainfrom
AlonLiwsky:feat/networkstream-process-attribution

Conversation

@AlonLiwsky

@AlonLiwsky AlonLiwsky commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

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/protocol with first-writer-wins, so when two processes in one container
reached 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_TwoProcessesSameEndpoint pins that: before
the fix the batch holds one entry, not two.

What changed

  • processRef per connection, plus processAttributionVersion per message
    (NetworkStreamProcessAttributionV1 = 1, set unconditionally — an empty processes
    map is not a capability signal).
  • The batch key gained the ref, appended and never reordered: an unattributed key
    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.
  • Trees ship once per process in the message-scoped processes map, not once per
    connection — 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.
  • Command lines capped at 1024 bytes on the wire copy only, with a visible ,
    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 flush snapshot is now independent of the live storage, which deletes a race
    rather than papering over it (below).
  • A per-message budget on the process trees, because attribution changed what
    sizes the payload (below).
  • go.mod: armoapi-go v0.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.
  • New feature doc: 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 maps
the 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 test
showed 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.

snapshotNetworkStream now allocates its own event maps, so the delivered value is
immune 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:

Inside eventsStorageMutex Outside
Work Allocate the snapshot's maps; copy each event by value; clear the live storage Both sends; the wire copy's tree copies and command-line caps
Bound O(entities + connections) small struct copies — a few hundred at the largest observed message (109 entities, 283 connections) one capped tree copy per selected process — transient memory is bounded by the tree budget (2.5 MiB), not by the connection count, and freed after the send

Tree pointers are copied into the snapshot, never walked. That is safe because a tree
is immutable once attached: buildBranchToShim allocates fresh nodes per branch, so
event 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
eventsStorageMutex is 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: sendNetworkEvent gets a non-2xx,
Start() logs it and drops the snapshot — the node loses its entire interval of
traffic
, 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.

maxProcessTreeBytes bounds it at 2.5 MiB of estimated tree bytes, calibrated
against measured production traffic rather than guessed:

Input Value Source
Mean message on topic 29 KB (prod-us 32 KB) pulsar_average_msg_size on network-stream-v1
Mean connections per batch ~42 (prod-us ~32) network_reputation_events_in_total ÷ topic message count
⇒ bytes per connection, no tree ~530 B JSON derived
Largest batch ever observed 283 connections SUB-7850

Measured 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:

Connections (all distinct) Trees shipped JSON after base64
42 (the mean) all 42 0.20 MiB 0.26 MiB
283 (observed worst) all 283 1.33 MiB 1.77 MiB — 35% of limit
500 all 500 2.35 MiB 3.13 MiB
1000 513 (budget binds) 2.70 MiB 3.60 MiB
4000 513 4.50 MiB 6.01 MiB — over, on entries alone

Design points:

  • A byte budget, not a tree count — tree size is not uniform (depth varies, fields
    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).
  • Connections are never dropped — that is the data loss this PR fixes. Only trees
    are, and the refs stay on the connections, so pid identity survives and
    ProcessTreeFor returns nil for them as specified.
  • Ranked smallest-tree-first, ties broken on the ref, which maximises the number of
    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.
  • It must not bind on real traffic, or it degrades attribution on exactly the
    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_ObservedWorstCaseFitsBudget pins 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 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 becomes the 6-byte \ufffd, and process
argv is arbitrary kernel bytes. 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 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.

escapedLen now charges the true escaped cost, and the direction is enforced rather than
asserted: TestEstimateTreeBytes_NeverUnderestimates over 15 adversarial shapes (fails
on 10 of them with the old estimator) plus a 400-case randomised version checked against
real json.Marshal output.

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 (with processesWithTrees, treesShipped, treesDropped,
connectionsWithoutTree) and large payload above 2 MiB (size, entity, connection and
tree 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.md does on HTTP 413, not
dropping connections.

Not Process.DeepCopy()

armotypes.Process.DeepCopy mutates its receiver: it calls MigrateToMap, which
allocates ChildrenMap and nils Children, and it does so on every child it recurses
into. 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.
copyCappedProcess is read-only instead, normalising the deprecated Children slice
into the copy without touching the source. TestBuildWireStream_DoesNotMutateSharedTree
pins this so a future refactor cannot regress to DeepCopy. Every recursive walk is
depth-bounded (64).

StartTimeNs is boot-relative nanoseconds, emitted verbatim

Converted once on the way in from /proc (SUB-7845); nothing here rescales it. A
division would still join correctly within one message — key and ref share a producer —
while silently breaking identity across messages by seven orders of magnitude.
StartTimeNs appears in exactly one non-test statement in this diff and in no arithmetic
expression. 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 and pkg/hostnetworksensor are
untouched. Inheriting from this change:

  • The tree strip is gone, and with it the shared-map race the host sensor's 100 ms
    sleep existed to survive
    . The channel now delivers an independent snapshot, still
    with trees.
  • The batch key gained a process suffix (format above).
  • The channel send is still blocking (backpressure, not data loss) but now selects on
    context cancellation.
  • Outside Kubernetes mode the HTTP export is disabled entirely, so that in-process
    channel is the only consumer today.
  • In Kubernetes mode, pre-existing host (non-container) processes carry no start time.

Testing

pkg/networkstream cannot be built or tested on macOS (inspektor-gadget/pkg/utils/host
excludes darwin, imported transitively). Everything below ran in a Linux container:

docker run --rm -v "$PWD":/src -v "$(go env GOMODCACHE)":/go/pkg/mod -w /src \
  -e GOFLAGS=-mod=mod -e CGO_ENABLED=1 golang:1.25 \
  sh -c 'go build ./... && go vet ./pkg/networkstream/... && go test -race ./pkg/networkstream/... -count=1'

pkg/networkstream/v1 had two test functions before this change, so it brings its own
coverage. go test ./pkg/... shows the same pass/fail set as main: the only
failures are pre-existing and unrelated (pkg/containerwatcher/v2/tracers needs a
tracers.tar build artifact; pkg/validator TestCheckPrerequisites).

Two plan amendments this work implies

Neither is a code change; both are for the plan owner:

  1. §2.3's payload paragraph reads as though the cmdline cap 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.
  2. The two plan documents disagree on p90 tree size: the implementation plan says
    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-image will fail on missing Quay secrets. That is structural for a
cross-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 and
mutation passes.

Round 1 (commits through ad55206c) — no must-fix. It independently confirmed the
four highest-risk properties: no StartTimeNs scaling anywhere, no lock-order hazard, no
data race under -race with concurrent producers/flushers over a shared tree graph, and
that trees really are immutable once attached (so sharing pointers into the snapshot is
safe). It also confirmed the DeepCopy deviation is justified by reading the armoapi-go
source. Six should-fix/nit items were addressed in 82ba0dd2, including two of my own
tests that did not pin what they claimed
TestNoTickScaling passed with a
/10_000_000 injected into processRefFor, and the shutdown select was covered by
nothing 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) while encoding/json
escapes, 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 1e978ede with escapedLen, a raised per-node overhead, a
15-shape property test and a 400-case randomised one, both checked against real
json.Marshal output. The review also found the determinism contract, skip-vs-break
packing, the estimator's legacy-Children branch and the wrapper-field copy were all
unpinned (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

    • Network and DNS events now include process attribution with process-reference deduplication and connection counting.
    • Stream payloads retain process references while selecting deduplicated process-trees under a deterministic byte budget, with UTF-8 command-line truncation and warnings for dropped trees.
  • Bug Fixes

    • Improved flush behavior with snapshot isolation, safer shutdown-aware notifications/backpressure, and more consistent event/payload handling.
  • Documentation

    • Added detailed documentation on network-stream process attribution semantics and testing notes.
  • Tests

    • Expanded coverage for attribution, snapshot/flush guarantees, budgeting statistics, and shutdown/cancellation scenarios.
  • Chores

    • Updated the ArmoAPI Go dependency to a newer version.

@AlonLiwsky AlonLiwsky added ai-assisted Created through Armosec AI tooling (armosec-shared-rules plugin) ai-reviewed-local labels Aug 4, 2026
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Changes

The 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 armoapi-go dependency were updated.

Network stream attribution

Layer / File(s) Summary
Process-aware event attribution
pkg/networkstream/v1/network_stream.go, pkg/networkstream/v1/network_stream_test.go, go.mod, docs/features/network-stream-process-attribution.md
Network and DNS keys and payloads include process references when available. PID-less events retain legacy keys. Tests cover process lookup, deduplication, and connection counting.
Snapshot-based flushing
pkg/networkstream/v1/network_stream.go, pkg/networkstream/v1/network_stream_test.go, docs/features/network-stream-process-attribution.md
Flush operations snapshot and clear live maps under lock. Wire conversion and delivery occur outside the lock. Notification sends honor shutdown cancellation.
Bounded wire payload construction
pkg/networkstream/v1/wire.go, pkg/networkstream/v1/wire_test.go, docs/features/network-stream-process-attribution.md
Wire conversion deduplicates process trees, preserves references, copies trees defensively, truncates command lines safely, estimates escaped JSON size, and selects trees within the payload budget.
Payload observability and operational documentation
pkg/networkstream/v1/network_stream.go, docs/features/network-stream-process-attribution.md
Large payloads log serialized size and counts. Documentation describes handoff behavior, platform scope, residual limits, and testing.

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
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding process attribution to network-stream events.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
pkg/networkstream/v1/network_stream.go (1)

209-214: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Skip the wire copy when the HTTP exporter is disabled.

buildWireStream performs every tree deep-copy, command-line cap and size estimate for the flush. sendNetworkEvent then 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 sendNetworkEvent uses.

♻️ 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 win

Equal-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.depth comparison keeps the first-visited tree when two distinct trees for one ref have equal depth. The retained tree, and therefore estBytes, then varies between runs. Because estBytes feeds the budget ranking in selectProcessTrees, the shipped set can also vary in that case.

Add a deterministic secondary tie-break, for example the larger estBytes or 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8866b6c and 7d33da0.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (6)
  • docs/features/network-stream-process-attribution.md
  • go.mod
  • pkg/networkstream/v1/network_stream.go
  • pkg/networkstream/v1/network_stream_test.go
  • pkg/networkstream/v1/wire.go
  • pkg/networkstream/v1/wire_test.go

Comment thread pkg/networkstream/v1/wire.go
Comment thread pkg/networkstream/v1/wire.go
AlonLiwsky and others added 12 commits August 5, 2026 10:13
…(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>
@AlonLiwsky
AlonLiwsky force-pushed the feat/networkstream-process-attribution branch from 08a6750 to 86ea7a0 Compare August 5, 2026 07:14
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
pkg/networkstream/v1/wire_test.go (2)

284-292: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert 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 win

Collapse the four child-attachment helpers into one.

escapeHeavyCommTree, escapeHeavyCommLegacyTree, unboundedCommTree and unboundedCommLegacyTree share the same body. They differ only in the comm value and in whether the child goes into ChildrenMap or into the Children slice. Parameterise those two choices. A future change to realisticNode or 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_LEN bound, 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 value

The 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 comm twice and does not rest the estimate on the kernel TASK_COMM_LEN bound.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 5b5ff1c and 86ea7a0.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (6)
  • docs/features/network-stream-process-attribution.md
  • go.mod
  • pkg/networkstream/v1/network_stream.go
  • pkg/networkstream/v1/network_stream_test.go
  • pkg/networkstream/v1/wire.go
  • pkg/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

Comment thread docs/features/network-stream-process-attribution.md Outdated
Comment thread pkg/networkstream/v1/wire_test.go
@matthyx

matthyx commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Reviewed this locally — build, go vet and go test -race ./pkg/networkstream/... all pass on go1.25.8. No correctness defect found, and the parts that carry the most risk hold up: I mutation-tested the suite and it killed StartTimeNs rescaling, the snapshot sharing the live maps again, capTreeCopyProcess.DeepCopy(), escapedLenlen(s), and dropping the childrenMap-key charge. I also checked the tree-immutability claim independently — buildBranchToShim allocates fresh nodes, and both MigrateToMap callers reachable on that graph (alert_bulk_manager.go:83, utils.CalculateProcessTreeDepth) are no-ops for branch-built trees, since every node gets a non-nil ChildrenMap and never a Children slice. So the new outside-the-lock walks add readers, not writers.

Four things below, none blocking.

1. The tree-ranking order isn't actually pinned

Flipping a.estBytes < b.estBytes to > in wire.go:159 leaves the whole suite green, TestSelectProcessTrees_PrefersSmallestTrees included. Every tree in that test is the same size, so ordering only decides which equal-sized trees ship — and the beacon's small tree lands in the leftover budget either way. Mixing two size populations makes the shipped count depend on ordering, which is the property the ranking exists for:

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 86ea7a08 (ships 126, exactly maxProcessTreeBytes/smallEst); fails with the sort inverted (ships 34, and the displacement assertion trips too).

2. The skip-vs-break branch is dead code, so the test named for it is vacuous

continue and break in the packing loop are provably equivalent given the ascending sort: if candidate i doesn't fit then used + est_i > budget, and for every j > i the sort guarantees est_j ≥ est_i while used never decreases, so used + est_j > budget too. Nothing after the first miss can ever fit. I ran 5000 randomised budget/size trials against both loop shapes — identical selected set every time.

So wire.go:170's "Skip rather than stop, so a small tree still ships after an oversized one" describes a scenario the sort makes unreachable, and TestSelectProcessTrees_OversizedTreeDoesNotBlockSmallOnes passes trivially because the five small trees sort ahead of the oversized one regardless. Worth either dropping the comment or keeping the defensive continue without claiming it's load-bearing. (It only became reachable in my mutation run because I had also inverted the sort — which is why neither mutation failed on its own.)

3. Derive the drop counters rather than accumulating them

The 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 - shippedConnections

On 1000 over-budget candidates, both loop shapes then log treesShipped: 187; treesDropped: 813; connectionsWithoutTree: 813. As written today, a future break logs treesDropped: 0 while still dropping 813, and nothing asserts otherwise. Since the plan is "if this fires routinely, make the budget configurable rather than raising it blind", those two numbers are the whole decision input — making them independent of the loop body seems better than pinning the loop body.

4. The ref lookup is a new unconditional double-lock on the packet path

processRefFor runs before the dedup check, so it's on every network and DNS event including duplicates, and it takes ProcessTreeManagerImpl.mutex.RLock() then processTreeCreatorImpl.mutex.RLock() to read one map entry. ProcessTreeManagerImpl.ReportEvent write-locks that same manager mutex for every exec/fork/exit/procfs event. Before this change the network path never touched those locks at all — getProcessTreeByPid only inspects its argument.

Benchmarked the duplicate-connection path with a real ProcessTreeManagerImpl, 8 threads. The control is the identical benchmark with processRefFor short-circuited to nil, i.e. the pre-change hot path:

ns/op
No process-tree churn, control 210
No process-tree churn, with ref lookup 244
4 goroutines of exec churn, control 254–271
4 goroutines of exec churn, with ref lookup 996–1035

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 pidStartTimeNs side map, or an atomic/sharded read, would keep packet handling off the process-tree writer's critical path.


Two smaller things while I was in there: processNodeOverheadBytes' comment says "~360 measured for a fully-populated node; generous on purpose" while the constant is 320 — I measured 176 actual bytes for a fully-populated node (every numeric field at max, strings empty), so the constant is genuinely generous and it's the 360 that's stale. And the tree budget bounds the wire but not the heap: with the process-aware key, networkEventsStorage retains one entry per process per endpoint for the whole 2-minute interval, each pinning a *ProcessTree. The residual note covers payload at ~3300 connections but not resident memory on the same shape.

Nice work on the escapedLen fix — the pre-fix estimator fails three named cases plus TestBuildWireStream_EscapeHeavyPayloadStaysUnderLimit, so that one is well pinned.

@matthyx matthyx moved this to Waiting on Author in KS PRs tracking Aug 5, 2026
…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>
@AlonLiwsky

Copy link
Copy Markdown
Collaborator Author

Thanks @matthyx — this is a genuinely useful review, and the independent check on tree immutability (both MigrateToMap callers being no-ops for branch-built trees) is exactly the premise the whole outside-the-lock design rests on, so it's good to have that confirmed by someone else.

All four addressed in 24bf616f. Taking them in order:

1. Ranking order — accepted, your test added verbatim

You're right and I'd fooled myself. Reproduced exactly: inverting the comparator ships 34 instead of 126 and trips the displacement assertion, while TestSelectProcessTrees_PrefersSmallestTrees stays green — so it was pinning "the beacon survives", not the order. Two same-size populations was the missing ingredient. Landed as TestSelectProcessTrees_MaximisesTreesKept, with your min(maxProcessTreeBytes/smallEst, 200) derivation kept, since a computed expectation beats a magic number here.

2. Skip-vs-break is unreachable — accepted, the comment was wrong

Your proof holds: ascending sort ⇒ if candidate i misses then used + est_i > budget, and for j > i, est_j ≥ est_i while used never decreases, so nothing after the first miss can fit. I've kept the skip as cheap insurance against a future ordering change but stripped the claim that it's load-bearing, and renamed the test to TestSelectProcessTrees_OversizedTreeIsExcluded with an explicit note that it passes under either loop shape. A test whose name promises more than it delivers is worse than no test — that's the second one of mine you and CodeRabbit have caught between you, which is a useful signal about how I was writing them.

3. Derived counters — accepted, and this was the sharpest point

Your 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 len(candidates) - len(processes) and totalConnections - shippedConnections. I extracted selectWithinBudget returning a budgetStats so they're assertable, and pinned the identities that must hold regardless of the loop: treesShipped + treesDropped == candidates, and connectionsWithoutTree following from the trees actually dropped.

4. Packet-path lock contention — accepted as real, deliberately not fixed here

Your 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:

  • The lookup itself is unavoidable in this design — the dedup key contains the ref, so the ref must exist before the key can be built. There's no reordering that makes it conditional on the dedup check.
  • Caching the ref per pid across a flush interval, which is the obvious in-package mitigation, is not safe: a pid recycled mid-interval would inherit the dead process's start time, which is a confident falsehood in exactly the case the start time exists to prevent. So a cache trades the bug this field was added for against CPU. Not doing that.

That leaves your suggestion — a dedicated lock or atomic/sharded read for the creator's pidStartTimeNs side map — as the right fix, and it's in pkg/processtree/creator, which another workstream owns and is actively changing (exit-manager race, pid-reuse hardening). Touching it from here would collide. I've documented the measurement and your recommendation in docs/features/network-stream-process-attribution.md so it lands with the owner rather than being rediscovered under load. Happy to be told to fold it in here instead if you'd rather not split it.

The two smaller ones

  • processNodeOverheadBytes: you're right that the comment is the stale part, not the constant. I measured 133 bytes for a node with every numeric at max and strings empty, plus ~16 for the childrenMap wrapper — so ~149 actual against a constant of 320. (Your 33-byte wrapper+key figure matches my processMapKeyOverheadBytes of 17 plus that ~16 exactly.) The comment now says 320 is deliberate headroom absorbing UniqueID and future fields, instead of pretending to be a measurement.
  • Heap vs wire: correct, and it wasn't documented. The budget trims the payload on the way out but networkEventsStorage still retains one entry per (process, endpoint) for the whole interval, each pinning a tree. Capping that would mean dropping connections — the data loss this PR exists to fix — so the honest position is "unbounded and monitored", which the residual section now says explicitly rather than implying the budget covers it.

@matthyx

matthyx commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Re-checked at 24bf616f. All four are addressed, and I verified the fixes rather than reading them — re-ran the mutation battery on the new head:

Mutation on 86ea7a08 on 24bf616f
Ranking inverted to largest-first survived killed
treesDropped desynced from the result killed
break instead of continue survived survived (now correct — see below)
escapedLenlen(s) killed killed
capTreeCopyDeepCopy killed killed
Snapshot shares the live maps killed killed
StartTimeNs rescaled killed killed
Network key drops the ref killed killed

Build, go vet, gofmt and go test -race all clean, and nothing previously pinned regressed under the refactor.

On the break mutation still surviving: that's now the documented, correct state rather than a gap. I confirmed the point of the change holds — with the counters derived, continue and break both report treesShipped=187, treesDropped=813, connectionsWithoutTree=3252 on 1000 over-budget candidates, where the old accumulator reported 0 dropped under break. Renaming to TestSelectProcessTrees_OversizedTreeIsExcluded and spelling out what it does not prove is the honest fix.

Leaving the lock contention to pkg/processtree/creator seems right — it's that package's invariant to change, and documenting the measurement is what makes it actionable there.

I also reproduced the revised numbers independently: a 10-node realisticNode chain estimates 5633 B against 4345 B marshalled (29.6% over, vs the doc's ~33%), and the budget holds 465 of them (doc: ~461). Both check out. The ~149 overhead figure does too — I measure 138 B for a bare node with every numeric at max and strings empty, which is your 133 plus the timezone-offset form of startTime.


One correction, in the residual paragraph. This part isn't right as written:

the connection entries alone breach the message limit at roughly 3,000 connections … No tree budget can help there.

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 maxProcessTreeBytes moves the breach point out substantially — toward 7,400 as the tree budget approaches zero. Splitting the message is still the right long-term fix, but an operator staring at this during an incident would currently conclude the tree budget is not a lever, when it's about half the payload.

… 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>
@AlonLiwsky

AlonLiwsky commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

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 892df8b6.

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 QueueManager does a Content-Length check and returns 413 synchronously — the sender learns immediately and can halve and retry. The network stream posts to HTTPExporterConfig.URL + "/v1/networkstreams", nothing in pkg/networkstream or pkg/exporters handles 413 at all (grepped: zero hits), and the 5 MiB cap is a broker limit whose enforcement point SUB-7850 explicitly lists as unverified:

Not verified: the production broker configs, and any namespace- or topic-level size policy.

So if the cap is enforced downstream at Pulsar rather than at the HTTP gateway, the sensor gets 200 OK, the message dies silently, and there is no signal to react to — reactive halving simply isn't available on this path, and the split has to be decided before the first send. Different design, same goal. Worth one question to a backend owner before any code.

Two smaller notes while I was in there:

  • The natural split axis looks benign: cut on entity boundaries, and since a process belongs to exactly one container, its tree lands in exactly one chunk — no duplication. Trees only get duplicated if a single container is itself oversized and its connections have to straddle a cut.
  • The precondition I'd want confirmed is that the ingester merges rows per message rather than replacing per message. The signals point that way (it logs "upserting network stream", and the traffic-view row identity carries no process field, which is why process-split entries already collapse), but if it replaces, splitting would make chunk 2 wipe chunk 1 — recreating the exact data loss this PR fixes. Not something to assume.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 24bf616 and 892df8b.

📒 Files selected for processing (1)
  • docs/features/network-stream-process-attribution.md

Comment thread docs/features/network-stream-process-attribution.md Outdated
@matthyx

matthyx commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

892df8b6 fixes the framing, and the new tree figure is right — but the threshold didn't move with it, and the ~3,000 was my bad arithmetic, not yours. CodeRabbit is correct to flag it.

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:

usable JSON            = 5 MiB x 3/4        = 3,932,160 B  (3.75 MiB)
saturated trees        =                      2,033,576 B  (1.94 MiB, measured)
room left for entries  =                      1,898,584 B  (1.81 MiB)
                       / 530 B per entry    = ~3,580 connections

Using your 1.91 MiB rather than my measured 1.94 gives ~3,640. So the number is ~3,600, not ~3,000 — the 3.75 - 1.91 step leaves 1.84 MiB, which is ~3,640 entries, not ~2,900. The ~2,900 only follows from the old 2.27 MiB tree figure that the same commit correctly retired.

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 892df8b6 (docs only), so the code verification from my last pass stands, and go build ./... && go test -race ./pkg/networkstream/... is still clean on this head. The conclusion the paragraph now draws — that maxProcessTreeBytes is a real operator lever because trees are roughly half the payload — holds unchanged at ~3,600; it's only the threshold that's off.

…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>
@AlonLiwsky

Copy link
Copy Markdown
Collaborator Author

Good catch, and thanks for tracing it to the source rather than the symptom — you're right that 892df8b6 fixed the input and left the output stale. CodeRabbit landed on the same line independently.

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 8bc4f41e, all three places: the table caption, the residual table row and the derivation sentence. I also wrote down the rule that produced both errors in this chain, since it's the actual lesson — derive the threshold from the measured tree total, never from a payload figure that already contains entries. That's what gave us a row counting entries twice, and then a threshold that outlived the figure it came from.

Unaffected, as you say: ~7,400 for entries alone (3,932,160 / 530 = 7,419), and the operator conclusion holds unchanged at ~3,600.

matthyx
matthyx previously approved these changes Aug 5, 2026
…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>
@AlonLiwsky
AlonLiwsky merged commit 61d695f into kubescape:main Aug 6, 2026
8 of 9 checks passed
@matthyx matthyx moved this from Waiting on Author to To Archive in KS PRs tracking Aug 6, 2026
entlein added a commit to k8sstormcenter/node-agent that referenced this pull request Aug 15, 2026
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…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-assisted Created through Armosec AI tooling (armosec-shared-rules plugin) ai-reviewed-local

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants