Skip to content

cel(network): alert on egress/ingress ports, not only addresses (#80) - #85

Open
ConstanzeTU wants to merge 75 commits into
mainfrom
network-port-alerts
Open

cel(network): alert on egress/ingress ports, not only addresses (#80)#85
ConstanzeTU wants to merge 75 commits into
mainfrom
network-port-alerts

Conversation

@ConstanzeTU

@ConstanzeTU ConstanzeTU commented Aug 15, 2026

Copy link
Copy Markdown

Egress/ingress port alerting — issue #80

How it used to work (before this change)

was_address_port_protocol_in_{egress,ingress} validated the (port, protocol) arguments and then degraded to address-onlymatchIPField(&cp.EgressAddresses, addr) (network.go: "port/protocol projection … out of scope … matchers degrade to address-only"). Consequences:

How it works after this change

  • was_address_port_protocol_in_{egress,ingress} matches IP + port + protocol per NetworkNeighbor entry, via a projected AddrPortGroup{addrs, ports} (shared objectcache.ExtractAddrPorts, mirrored in the rule-cache mock). An allowlisted address on a non-allowlisted (port, protocol) fires R0011.
  • Wildcards: an entry with port: 0, a nil port, or an omitted ports: stanza = any port for that address (silent on any port).
  • Internal in scope: R0011/R0012 guard !net.is_private_ip(dstAddr)!dstAddr.startsWith('127.') (loopback-only), so private-IP peers are evaluated. Infra traffic (cluster DNS) stays silent by allowlisting it in the profile.

How the tests evidence the new behaviour

Unitport_protocol_test.go: per-entry CIDR+port (the fixture-19 contract), protocol case-insensitivity, port: 0/nil/empty-stanza wildcard, ingress symmetry. wildcard_test.go / network_test.go / integration_test.go — which previously pinned the address-only degrade — updated to assert the real port contract (a wrong port on an allowlisted address is now a non-match).

Component (Test_28, kind cluster, run 31965647879):

leg scenario result
port_violation_different_port_R0011 external 162.0.217.171 allowed :80, connect :443 R0011 fires
internal_port_violation_R0011 internal 10.96.0.1 allowed :443, connect :80 R0011 fires — Unexpected egress network communication to: 10.96.0.1:80 using TCP
port_wildcard_zero_allows_any port: 0 entry silent on any port
port_wildcard_empty_stanza_allows_any no ports: stanza silent on any port
allowed_fusioncore_no_alert, mitm_coredns_poisoning DNS legs (assert R0011==0) still green — guard flip introduced no internal false positives

Live A/B (kubescape#864 r3792018550): the identical scenario on the pre-feature image (test-bff56fa, address-only) stays silent on a port mismatch; on the feature image it fires — with a 1.1.1.1 control confirming the engine was live.

Run tally: 43 pass / 2 fail; the two (Test_01, Test_22) are the known storage single-writer SQLite contention flake (database is locked), not this change — the internal-IP alert set is exactly {10.96.0.1:80}, no spurious internal alerts.

Entlein and others added 30 commits July 25, 2026 14:05
… -> ContainerProfile

ConvertUserProfilesToContainerProfile produces the single user-defined
ContainerProfile equivalent to a legacy user-authored ApplicationProfile +
NetworkNeighborhood pair, reusing the existing projectUserProfiles merge onto
an empty base.

The differential oracle pins the migration contract at the enforcement level:
for representative user-defined shapes (opens/exec argv wildcards, HTTP
endpoints, egress/ingress + LabelSelector) the ProjectedContainerProfile from
the legacy AP+NN overlay path equals the one from using the converted
ContainerProfile as the base with no overlay. Behaviour-preserving by
construction.

Signed-off-by: entlein <einentlein@gmail.com>
…e authoritative base

When the user-defined-profile pod label names a ContainerProfile carrying
managed-by: User, the cache now uses it directly as the container's base
profile (the migrated "new way"), instead of overlaying a legacy
ApplicationProfile + NetworkNeighborhood pair. It falls back to the legacy AP+NN
pair when no such CP exists, which still fires the existing deprecation signal.

- add-time (tryPopulateEntry) and refresh (reconciler) both prefer the user CP,
  gated on the managed-by: User annotation so a learned CP at the same name is
  never mistaken for a user-defined one
- UserCPRef/UserCPRV bookkeeping mirrors the legacy UserAPRV/UserNNRV RV tracking
  so the reconciler re-fetches and rebuilds only when the user CP changes

Signed-off-by: entlein <einentlein@gmail.com>
Test_28 now creates one ContainerProfile (managed-by: User) carrying the merged
exec/syscall + egress/selector surfaces, instead of a separate user-authored
ApplicationProfile + NetworkNeighborhood pair — exercising the migrated
read-path end to end. Assertions unchanged.

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

The new path (converted CP as base, no overlay) is ~35% faster and allocates
~27% less than the legacy AP+NN overlay per projection, since it skips the
two-object merge.

Signed-off-by: entlein <einentlein@gmail.com>
Test_27 (opens R0002, both the regex and the curl-wildcard profile sites),
Test_32 (R0040 argv wildcards), and Test_33 (opens wildcard anchoring — the
previously-fixed one) now each create a single user-defined ContainerProfile
(managed-by: User) carrying the merged exec/open/syscall (+ egress/selector for
32) surfaces, replacing the legacy ApplicationProfile + NetworkNeighborhood
pair. Assertions unchanged. Test_28 was ported in an earlier commit.

Signed-off-by: entlein <einentlein@gmail.com>
containsDynamicSegment recognised only the one-segment DynamicIdentifier
('⋯'), so a path-surface opens entry bearing the zero-or-more
WildcardIdentifier ('*') — e.g. '/etc/ssl/*' — was routed to Values as if it
were a literal. was_path_opened tolerates this (Values and Patterns are both
matched via CompareDynamic), but it is wrong for any consumer that treats
Values as exact membership, and it drops '*'-only entries a rule needs when
spec.All is false and no prefix/suffix matcher retains them. Recognise both
wildcard markers. Regression test pins '/etc/ssl/*' -> Patterns.

Pre-existing in upstream (identical containsDynamicSegment).

Signed-off-by: Entlein <eineintlein@gmail.com>
Signed-off-by: entlein <einentlein@gmail.com>
Test_33's anchoring subtests assert R0002 alerts, but the rule's file-access
monitoring is opt-in (monitored prefixes incl. /etc/). Test_33 never applied
r0002-files-access-enabled.yaml (Test_27 does via enableR0002ForTest), so R0002
never evaluated the opens and every 'expect alert' case silently passed as a
no-alert — invisible because Test_33 had never run in CI. Enable it like Test_27.
Verified on a live cluster: /etc/ssl/* correctly alerts on the bare parent
/etc/ssl and stays silent on the child /etc/ssl/openssl.cnf.

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

A user-authored profile is authoritative and complete by definition — it should
not carry the learning-lifecycle status/completion markers, nor a managed-by
annotation. The pod's user-defined-profile label is what declares it
user-authored (a signature, added by the signing tooling, is the integrity
marker).

- read-path/reconciler no longer gate on managed-by: the label-referenced CP is
  used as authoritative, and the entry State is forced to Completed+Full so the
  rule engine enforces it despite the absent completion annotation
- the converter emits clean CPs (name + namespace + spec only)
- fake client models an absent overlay-name fetch (drives the legacy fallback)

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

- Test_28 now loads its user-defined ContainerProfile from a real yaml
  (resources/containerprofile-user-defined-network.yaml) via a loader helper, so
  the fixture doubles as the copy-pasteable "how to author a user-defined
  profile" example.
- The example — and all the test CPs — carry only name + spec: the nonsensical
  learning-lifecycle annotations (status/completion) and the managed-by marker
  are dropped (node-agent now treats a label-referenced CP as authoritative and
  forces the enforce-state itself).

Signed-off-by: entlein <einentlein@gmail.com>
Migrate the network-wildcards NetworkNeighborhood fixtures into their
user-authored ContainerProfile form so users can copy-paste them to
author user-defined-profile allow-lists:

- per-container spec.egress/ingress (NN's spec.containers[] collapses,
  one CP document per container; fixture 20 splits into two)
- no lifecycle annotations — name only (namespace injected by tooling),
  matching the clean user-managed CP contract
- teaching comments preserved verbatim from the NN fixtures

Adds 00-fusioncore-homoglyph-attack.yaml: a pinned single-vendor
allow-list and the look-alike (homoglyph) domains its exact-match
dnsNames compare rejects (each fires R0005).

All 21 documents strict-parse against v1beta1 ContainerProfile and
carry zero annotations (verified).

Signed-off-by: entlein <einentlein@gmail.com>
The component tests build their user-defined profiles inline as
ContainerProfiles (Test_27/32/33) or load a CP yaml (Test_28); the
legacy ApplicationProfile / NetworkNeighborhood fixtures they were
derived from are no longer referenced by any Go test.

Migrate the two CT-relevant ones to their user-authored CP form and
delete the orphan:

- exec-arg-wildcards-profile.yaml (AP curl-32-overlay) ->
  containerprofile-exec-arg-wildcards.yaml. CP form mirrors Test_32's
  inline CP exactly (same execs incl. busybox-symlink + literal-* entries,
  same syscalls, matchLabels app: curl-32). Demonstrates the exec-arg
  wildcard grammar for authoring.
- known-network-neighborhood.yaml (NN fusioncore-network) ->
  containerprofile-fusioncore-network.yaml. Clean user-managed CP: name
  only, no managed-by / status / completion annotations.
- user-profile.yaml: deleted. Zero references anywhere; its nginx/server
  exec surface matches no current test or deployment.

Both new documents strict-parse against v1beta1 ContainerProfile and
carry zero annotations (verified). CT compiles unchanged (go vet -tags
component); no Go test referenced the deleted files.

Signed-off-by: entlein <einentlein@gmail.com>
Signed-off-by: entlein <einentlein@gmail.com>
Signed-off-by: entlein <einentlein@gmail.com>
… decommission them -step 1 of many -- I understand that this has a long tail of decommissions

Signed-off-by: entlein <einentlein@gmail.com>
…storage etc

Signed-off-by: entlein <einentlein@gmail.com>
…icontainer labels, tests not reviewed yet

Signed-off-by: entlein <einentlein@gmail.com>
Signed-off-by: entlein <einentlein@gmail.com>
Co-authored-by: Matthias Bertschy <matthias.bertschy@gmail.com>
Signed-off-by: Duck <70207455+entlein@users.noreply.github.com>
Signed-off-by: entlein <einentlein@gmail.com>
Apply had assertion-style unit tests but no frozen contract for its full
projected output. Add TestApply_Golden: a corpus (execs incl. literal-* and
ellipsis args, opens incl. trailing-* and dynamic segments, syscalls,
capabilities, endpoints, ingress/egress with CIDR/*-sentinel/DNS, call stacks)
projected via the real CompileSpec, frozen to goldens (regenerate with
UPDATE_GOLDEN=1). Plus idempotency and spec-hash-stability invariants. This is a
characterization/regression freeze, not a two-path differential -- the legacy
AP/NN projection was removed, so there is no second implementation to diff.

Signed-off-by: entlein <einentlein@gmail.com>
The projection golden corpus set the deprecated DNS/IPAddress singulars (to
distinct values) alongside the modern lists. Drop them: the corpus now uses
DNSNames/IPAddresses only (still covering DNS name, literal IP, CIDR and the *
sentinel). Goldens regenerated from the CP-native corpus.

Signed-off-by: entlein <einentlein@gmail.com>
…ace)

The rulemanager CEL libraries already read ContainerProfile data but still
carried AP/NN naming. Rename applicationprofile -> containerprofile and
networkneighborhood -> containerprofilenetwork (dirs via git mv, packages,
types, constructors, LibraryName), and unify the CEL function namespace:
ap.* and nn.* -> cp.* (cp.was_executed, cp.is_domain_in_egress, ...). Update
cel.go registration, the bundled default-rules.yaml, and the network-wildcards
test rules to the cp.* namespace so rules compile against the renamed engine.
Rule content mirrors the rulelibrary migrate/sbob rename.

Signed-off-by: entlein <einentlein@gmail.com>
The CP-form fixtures lived in a parallel network-wildcards-cp/ dir alongside the
now-dead network-wildcards/. 'cp' is an implementation detail, not part of what
the fixtures are. Remove the dead dir, move the CP fixtures to network-wildcards/
(update the nnlint/fixtures test references), and drop the explanatory README.

Signed-off-by: entlein <einentlein@gmail.com>
Signed-off-by: entlein <einentlein@gmail.com>
Resolve the Test_35 collision: upstream added Test_35_ExecTTYFieldTest at the
same location as our Test_35_MultiContainerPerContainerBinding. Keep both;
renumber ours to Test_36_MultiContainerPerContainerBinding.
Add cluster-free unit coverage for the migration's rule-side surface:
- cp.* HTTP evaluators (http.go, was 0%/no file): endpoint/host match, method
  and prefix/suffix variants, empty-CP, nil-cache error.
- isExecInPodSpec + the wasExecuted/wasExecutedWithArgs podspec-exempt fall-through.
- containerprofilenetwork matchers: no-match / wildcard-domain / empty-neighborhood.
- Declarations smoke+drift guard: compile every cp.* overload, assert the decl set.
- profilehelper GetProjectedContainerProfile/GetPodSpec/GetContainerName.
- HasFinalApplicationProfile enforce-vs-learn gate (Completed+Full -> enforce).

containerprofile 37->69%, containerprofilenetwork 75->80%, profilehelper 95%,
HasFinalApplicationProfile 100%. Test-only; no production changes.

Signed-off-by: entlein <einentlein@gmail.com>
… racy learning)

Test_20/21 hung to the 20-minute global panic in WaitForContainerProfileCompletion*:
Test_20's blacklist wait can never succeed (the old completed profile stays
matched and blacklisted after the deployment restart), and Test_21's
multi-container profile never reached 'completed'. Both raced the natural
learn -> daemonset-restart -> deployment-restart -> re-learn cycle.

Rewrite to enforce an AUTHORED user-defined ContainerProfile, updated in place:
an action not in the profile alerts; the same action, once added, does not.
Determinism comes from a positive reload gate -- the update that adds the
subject also removes a canary, so the canary starts alerting the moment
node-agent reloads the revision (alert-appears signal), confirming the reload
before the negative assertion. Single-container workloads; bounded pollers that
dump ContainerProfile status on timeout (never a 20m panic); Eventually-style
polling on the real alert condition instead of fixed sleeps.

Also fixes a latent bug: the old 'no new alert' check filtered on a
process_name label that alerts never carry, so it always matched zero and
passed regardless; the rewrite keys on comm (process) and rule_id (network).

Verified: both pass twice on a live rig (rc5l), 0 'database is locked'. This now
tests profile ENFORCEMENT (authored partial->full), not natural learning.

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

Test_16 slept a hard-coded 175s after the restart-inducing 'service nginx stop'
before exec'ing the ls violation (the WaitForContainerProfileCompletion guard was
commented out), then asserted the 'Unexpected process launched' alert. On a quiet
system that had ~110s of slack; under full-CT concurrency the storage single-writer
SQLite contention (database is locked) defers the merged profile's completion-status
write past the fixed window, so ls ran against a non-enforcing profile and no alert
fired -> the assertion failed (observed as Test_16 red in a full CT with 12 lock events).

Replace the fixed sleep with a bounded poll (5m, fail-fast, dumps ContainerProfiles
on timeout) for the MERGED ContainerProfile reaching 'completed' after the restart --
keyed on the merged profile only (name has no -<32 hex> suffix), not
WaitForContainerProfileCompletion which requires ALL matching profiles completed and
would hang on the transient per-instance profiles the restart spawns. Then exec ls
and poll (Eventually) for the alert. Validated 3/3 on a live rig; runtime ~241s -> ~90s
since it no longer over-sleeps.

Signed-off-by: entlein <einentlein@gmail.com>
entlein and others added 25 commits August 15, 2026 00:27
…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.
…ance

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.
…ner 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.
… 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.
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.
…ntainers

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.
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.
…ile 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.
…, 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.
The gadget rework removed IsResolvedFullPath from path.go while
datasource_event.go still guards the fname fallback with it, leaving the
tree uncompilable. Restore the helper unchanged; whether the guard is still
needed under kernel-side full-path resolution can be decided separately.
Supersedes the IsResolvedFullPath restoration one commit back: upstream kubescape#889
retires the helper entirely and replaces the call-site guard with a plain
empty-fpath fallback (the patched gadget resolves full paths kernel-side, so
the fragment hazard no longer exists). Take upstream's GetFullPath body, drop
the helper again together with its fork-only test, leaving path.go and
normalize_path_test.go byte-identical to upstream main.
The backward dentry walk in get_path_str terminates at the mount-tree
root of the file's vfsmount. runc >= 1.2 accesses procfs during
container init and exec through a private detached mount created via
fsopen(2)/fsmount(2); such a mount has no mountpoint (mnt_parent ==
mnt), so the walk correctly reaches its top with no /proc dentry to
prepend and emits prefix-stripped paths such as /1/task/1/fd. The
kernel's own d_path reports the same rootless string, so no userspace
consumer can recover the prefix afterwards.

Detect this termination case by checking the superblock magic of the
final dentry: if it is PROC_SUPER_MAGIC the walk ended inside a
procfs instance that is the top of its own mount chain, which cannot
be the real global root, and the path is canonicalized by prepending
proc/. Attached procfs mounts are unaffected because their walk
continues through mnt_mountpoint before reaching this branch, and
non-procfs detached mounts keep their previous behavior.

Verified on kernel 6.1.167 (x86_64): fsopen/fsmount reproducer emits
/proc/1/task/1/fd (was /1/task/1/fd), detached procfs root open emits
/proc, detached tmpfs root open remains /, regular file paths
unchanged, program accepted by the verifier.

Refs #81
The 5-byte proc/ prepend decremented buf_off unguarded; a dentry chain
longer than the half-buffer could wrap the offset and corrupt the emitted
path (masked writes keep it memory-safe; result is garbage, not OOB).
Unreachable for PATH_MAX-bounded paths - defense in depth only. On
insufficient space the prepend is skipped and the unprefixed path kept.
…metic

Proves the u32 wrap condition the guard defends against: an unguarded 5-byte
prepend at buf_off < 5 wraps past the half-buffer (garbage path), while
buf_off >= 5 lands exactly at buf_off-5 in bounds; the guard skips the
prepend untouched below the threshold. Also pins the shipped header: guard
present, MAX_PERCPU_BUFSIZE and buf_off initialization unchanged - so the
arithmetic assumptions break loudly if the header drifts.
The scrambled-path assertions only ran against nginx in CI; the detached-
procfs reproducer (bitnami container init) was never exercised there. Add
the bitnami-redis learned leg with a /proc positive control so every CT run
provokes and checks the runc procfs open class.
…longer ready budget

The bitnami-redis leg timed out on pod readiness in CI - Docker Hub pulls on
shared runners are rate-limited and the image is large. Use the public ECR
mirror and double the readiness budget.
…ro-demo redis manifest

Replace the hand-written fixture with the verbatim helm render of the redis
distro deployment (bitnamicharts/redis 27.0.18, digest-pinned image - the
tag was removed from the registries, which is what timed the previous leg
out). The test applies every rendered document and waits on the statefulset
pod and the learned profile completion directly.
Requiring /proc opens in the learned profile raced tracer attach against
container start (runc-init opens are only learned when the tracer wins).
Replace it: after the profile freezes, read /proc/1/cmdline in the container
and require the R0002 alert - no alert means a silent gadget, a scrambled
path would fail the path checks.
…t in the positive control

The probe swallowed exec errors and matched only R0002; the exec itself
(R0001, deterministic - cat is never in the learned profile) is equally
valid pipeline evidence.
…ort_protocol_in_{egress,ingress}

Projects each neighbor's addresses grouped with its ports (AddrPortGroup) and matches IP+port+protocol within a single entry; empty/0/nil ports = any-port wildcard. Replaces the address-only degrade.
…ort violation

Chart R0011 calls was_address_port_protocol_in_egress(dstAddr,dstPort,proto); Test_28 adds a subtest that curls the NN-allowed IP 162.0.217.171 on :443 (allowed only on TCP/80) and asserts R0011 fires.
…ts stanza = any

Fixture allowlists 9.9.9.9 on port 0 and 208.67.222.222 with no ports stanza; two subtests assert neither fires R0011 on any port. Verified on a live cluster.
…ard to loopback-only + internal CT leg

R0011 guard is_private_ip → startsWith('127.') so private-IP peers are evaluated; fixture allowlists cluster DNS (10.96.0.10, any port) and kube-api (10.96.0.1 TCP/443); Test_28 internal_port_violation_R0011 curls 10.96.0.1:80 (allowed only :443) and asserts R0011 fires.
@entlein
entlein changed the base branch from migrate-sbob-fix to main August 18, 2026 11:55
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.

2 participants