Skip to content

testing a fix - #60

Merged
entlein merged 1 commit into
mirrormainfrom
fix/openspathprefix
Aug 6, 2026
Merged

entlein merged 1 commit into
mirrormainfrom
fix/openspathprefix

Conversation

@entlein

@entlein entlein commented Aug 6, 2026

Copy link
Copy Markdown

Overview

Signed-off-by: entlein <einentlein@gmail.com>
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 10687541-0c69-40e4-9277-f0fef335cc25

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@entlein
entlein merged commit 2002fc5 into mirrormain Aug 6, 2026
2 of 4 checks passed
entlein added a commit 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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant