Skip to content

feat(rulemanager): CEL rule state store for cross-event correlation - #875

Open
slashben wants to merge 17 commits into
mainfrom
feat/cel-rule-state-store
Open

feat(rulemanager): CEL rule state store for cross-event correlation#875
slashben wants to merge 17 commits into
mainfrom
feat/cel-rule-state-store

Conversation

@slashben

@slashben slashben commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Gives CEL rules memory across events via a declarative stateWrites: clause and state.has/state.get read functions, so cross-event detections (exec → network, webshell chains, create/exec/delete) become expressible for the first time. Proven end-to-end on kind against real eBPF, not just unit tested.

Blocking follow-up: the canonical Rules CRD in kubescape/helm-charts must declare stateWrites, or the API server silently prunes it and the feature is inert in production. Details below.

What this adds

CEL rules can currently only be pure predicates over a single event. This gives them memory across events, so a rule can remember a fact on one stream and read it back on another — execnetwork, webshell chains, create/exec/delete of a pod. None of those are expressible today.

A rule declares what it remembers in a new stateWrites: clause and reads it back with state.has(...) / state.get(...):

stateWrites:
  - eventType: exec                 # the stream that drives the write
    when: "<CEL guard>"             # optional
    scope: container                # container | pod | node
    name: mount_exec                # a literal, never an expression
    key: "string(event.pid)"        # who the fact is about
    ttl: 10m
expressions:
  ruleExpression:
    - eventType: network            # alerts on a DIFFERENT stream
      expression: |
        state.has("mount_exec", string(event.pid)) && !net.is_private_ip(event.dstIP)

Design doc: shared-designs-and-docs/projects/2026-07-28-cel-rule-state-store/spec.md.
User-facing docs: docs/features/cel-rule-state-store.md (in this PR) — the best entry point for review.

Verified end-to-end, not just unit tested

Test_36_CelStateStoreCorrelation runs on kind against real eBPF events and passes in ~137s. The alert it produces:

state correlation: pid=241824 comm=nc remembered=sh

Same pid on both legs, and remembered=sh is the value: written on the exec leg read back through state.get(...) in the network leg's message. A negative control (reading a name no rule writes) produces zero alerts, so the positive result is not vacuous.

The test also wires Test_35_ExecTTYFieldTest into CI, which was written earlier but never added to the matrix.

⚠️ Blocking follow-up in another repo

The canonical Rules CRD in kubescape/helm-charts must declare stateWrites.

The CRD has a structural schema with no x-kubernetes-preserve-unknown-fields at the rule level, so the API server silently prunes the clause. It fails in the worst possible way: kubectl apply succeeds, the rule loads, and it never fires — no error in kubectl, node-agent logs, or metrics. --validate=false does not help (client-side only).

This PR fixes only tests/chart/crds/rules.crd.yaml, which is a test-only copy. Merging this without the helm-charts change ships a feature that is inert in production. Verify with:

kubectl get rules <name> -n kubescape -o jsonpath='{.spec.rules[0].stateWrites}'

Empty output after a successful apply means it is still being pruned.

Design decisions worth a reviewer's attention

state is a CEL variable with member functions, not a state.* function namespace. cel-go hands a function binding only its arguments, never the activation, so a global state.has could not discover which rule or container it was evaluating for. Putting that context in a receiver is also the security property: the rule ID, scope IDs and ancestor list live there and no CEL syntax can supply or override them, so reading another rule's or another container's state is inexpressible, not merely forbidden.

Writes are declarative, never a CEL setter. A setter inside a predicate could be skipped by short-circuiting, reordered by the static optimiser, and could never express "remember without alerting" — which is exactly what the first leg of every cross-event rule needs.

Writes run after the predicate, so a predicate only ever sees state from earlier events. This required extracting the per-rule body of the event loop into evaluateRuleAndAlert (pkg/rulemanager/rule_manager.go) so its early exits are return rather than continue — otherwise a cooldown-suppressed alert would also skip the write and break the next leg of the chain. It returns a bool so ReportRuleProcessed keeps its exact pre-refactor meaning.

Caps reject writes, never evict. Eviction would let one container silently disable detection for its neighbours. Host processes get their own c:__host__ bucket with a larger cap, since it holds the whole node's process space and never receives a container-removal purge.

typesv1.Rule now embeds armotypes.RuntimeRule so the CRD contract has one definition shared with the operator. Expressions and ProfileDataRequired stay shadowed because their types genuinely differ. Note the two decoders disagree: encoding/json resolves same-tag conflicts by depth, while apimachinery's converter (the production CRD path) has no depth rule and fills both. Both are pinned by rule_embedding_test.go.

Reviewing a 50-file diff

Suggested order:

  1. docs/features/cel-rule-state-store.md — what it does and how it fails
  2. pkg/rulestate/ — the store; no CEL or rule knowledge, readable standalone
  3. pkg/rulemanager/statewrites/ — validation and execution
  4. pkg/rulemanager/cel/libraries/state/ — the read functions and the receiver
  5. pkg/rulemanager/rule_manager.gothe riskiest change, the loop refactor
  6. tests/ + tests/chart/crds/rules.crd.yaml — the end-to-end proof

Testing

  • go build ./... clean; full unit suite green.
  • go test -race ./pkg/rulestate/... ./pkg/rulemanager/... clean.
  • Component test passes on kind (above).
  • No new gofmt violations relative to main.
  • Two packages fail on this branch and identically on a clean maincontainerwatcher/v2/tracers and pkg/validator — both needing host/eBPF prerequisites. Unrelated to this change.

Not in this PR

  • The helm-charts CRD change (above) — blocking.
  • Asserting the correlations[] evidence payload in the component test. Alertmanager labels are a flat string map and structurally cannot carry a nested array, so that needs an in-cluster receiver. Planned, optional, and does not gate this.
  • The operator/admission side, and authoring the 11 correlation-dependent detection rules. Those are separate plans; this PR is the capability plus its proof.

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features

    • Added CEL state storage for sharing information across related events, with scoped and ancestor lookups, expiration, capacity limits, and cleanup.
    • Added configurable state-write rules with validation, timestamps, and correlation evidence in alerts.
    • Added metrics for state writes, rejections, expirations, purges, and current entries.
  • Documentation

    • Added guidance for configuring, operating, troubleshooting, and monitoring the CEL rule state store.
  • Tests

    • Added end-to-end coverage for cross-event state correlation, validation, expiration, capacity limits, and reliability.

slashben added 16 commits August 3, 2026 17:41
Gives the CRD contract -- including the new StateWrites clause -- exactly one
definition shared with the operator, without retiring typesv1.Rule. Expressions
and ProfileDataRequired stay shadowed because their types genuinely differ:
utils.EventType covers all node-agent event streams, and FieldRequirement
carries a Declared flag plus strict unknown-key rejection that
armotypes.ProfileDataField has neither of.

The two decoders that reach Rule disagree about the shadows. encoding/json
resolves same-tag conflicts by depth, so only the depth-0 fields are populated.
apimachinery's converter -- the production CRD path, via
DefaultUnstructuredConverter.FromUnstructured -- has no depth rule and visits
every field independently, so it fills the embedded copies too. Either way the
depth-0 fields are what node-agent code reads. rule_embedding_test.go pins both
decoders, including the apimachinery path the plan originally left untested.

Docs-exempt: struct-embedding refactor; StateWrites is inert until Task 6 reads it
Signed-off-by: Ben <ben@armosec.io>
Ordering guards need to compare when events happened, not when the worker pool
observed them. ResolveEventTime prefers the event's kernel timestamp and falls
back to enrichment time only when it is zero.

Exposed as a top-level 'timestamp' variable rather than an event field: CelFields
getters receive an xcel wrapper around the event and cannot reach
EnrichedEvent.Timestamp, so a field would be a second, divergent source of
truth. The store will stamp entries from this same function.

The CEL-level tests use a real utils.StructEvent rather than a fake: the eval
context casts the event to utils.CelEvent and calls GetEventType(), so a fake
embedding a nil utils.K8sEvent panics before reaching any assertion. They also
assert instants rather than rendered text -- time.Unix yields a local-zone Time,
so the rendered offset is whatever the node's TZ is.
Signed-off-by: Ben <ben@armosec.io>
Walks the creator's global process map instead of GetPidBranch, which resolves a
container shim and therefore errors for every host / cgroup-0 process -- leaving
those events with a zero-value ProcessTree. Ancestor matching must work
identically on a VM and in a pod.

maxDepth bounds the walk, and a seen-set breaks parent cycles that a reparenting
race could produce, so a malformed tree cannot hang rule evaluation. A PPID of 0
terminates the walk: it means "parent unknown", and recording it would put a key
in the ancestor list that no state entry can ever be stored under.

The manager mock takes a settable ancestor chain rather than always returning
nil, because the rule-level tests for ancestor matching need to stub a chain
without building a real process tree.

Docs-exempt: internal API; the CEL surface that exposes it is documented when it lands
Signed-off-by: Ben <ben@armosec.io>
Sharded by scope ID so the per-scope cap is a plain len(), container-removal
purge is one map delete, and one container's churn stays off its neighbours'
locks.

Over-cap writes are REJECTED, never satisfied by eviction: evicting would let a
container that sprays events silently disable detection for itself or a
neighbour. The host bucket (c:__host__) gets its own larger cap because it holds
the whole node's process space and never receives a removal purge.

Two properties worth knowing when reading this code:

Replacing an existing key bypasses the cap check, because it does not grow the
scope. Without that, a scope sitting at its cap could never update its own
markers and a bidirectional rule would freeze on stale state.

The global ceiling is approximate under concurrency and says so in a comment.
Concurrent writers can each pass the size check before any increments, so the
store can overshoot MaxSize by up to the number of in-flight writers. Making it
exact would serialise every write on one lock. The per-scope cap is exact, and
that is the one that bounds a single workload.

Knows nothing about CEL or rules, so it is testable without an evaluator.

Docs-exempt: internal store; the rule-facing surface is documented in Tasks 5-8
Signed-off-by: Ben <ben@armosec.io>
Reads are pure and rule-private. The plan called for ruleID, scope IDs and the
ancestor list to be "injected into the eval context", which needed a concrete
mechanism: cel-go hands a function binding only its arguments, never the
activation, so a global function named "state.has" cannot discover which rule or
container it is evaluating for.

So "state" is a VARIABLE whose value is a per-(rule, event) Accessor, and the
read functions are member overloads on it. The authored syntax is unchanged --
state.has("mount_exec", "4471") -- but the context now lives in the receiver,
which is what makes cross-rule and cross-container reads inexpressible rather
than merely forbidden. It also keeps the library itself immutable, so it is safe
to share across the worker pool; a library holding per-evaluation state would
race, since node-agent evaluates events concurrently against one shared cel.Env.

A read resolves its scope by looking the name up in the rule's own stateWrites
declarations, per the spec: state is rule-private, so a name determines its
scope. An undeclared name reads as a miss rather than an error -- load-time
validation is what rejects it, and erroring here would take out a working rule.

The member functions are named "has" and "get", which collide with CEL's built-in
has() macro by identifier. TestState_DoesNotShadowTheHasMacro pins that
has(event.field) still parses and evaluates, because if that ever regressed it
would break every existing rule using field presence.

The cost estimator keys off overloadID, not the function name, for the same
reason: "has" and "get" are too generic to match on. It returns nil for unknown
overloads. Note these estimators are currently inert -- nothing in the repo calls
NewCompositeCostEstimator -- but it is written to be correct if wired up.
Signed-off-by: Ben <ben@armosec.io>
Writes run AFTER the predicate, so a predicate only ever sees state from earlier
events -- otherwise a rule reading and writing the same name on one event type
would trivially satisfy itself.

To make that possible the per-rule body of the event loop is now its own
function, evaluateRuleAndAlert. Its early exits were continues, which would have
skipped the write clause whenever an alert was suppressed -- silently breaking
the NEXT leg of the chain. As returns, the caller still runs the writes. Cooldown
in particular must not suppress a write: writes are evidence gathering.

Validation is at load, not runtime: an unknown event type, the `all` binding
wildcard, a bad or non-positive TTL, an identity scope (operator-only) or a
reserved _-prefixed name or value key fails loudly instead of producing a rule
that silently never matches. A malformed clause degrades that one rule to
non-correlating rather than breaking evaluation for the rest of the CRD.

ValidateAll also rejects one name declared in two scopes. Reads take no scope
argument -- they infer it from the name -- so that would make every read of the
name ambiguous. The same name across several event types in one scope is the
normal bidirectional idiom and stays legal.

isSupportedEventType now also considers stateWrites event types; without that,
write-only legs are filtered out before the loop and no chain ever forms.

Two deliberate choices worth recording:

Compilation happens per event, not once at rule load. It is pure string and
duration parsing -- the CEL expressions are compiled and cached by the evaluator,
keyed by expression text -- and it only runs for rules that declare writes, a
small minority. Caching it on the Rule would need invalidation on every CRD
change, and rules reach the loop by two paths of which only one populates
load-time derived fields, so the cached field would be silently empty for host
rules.

utils.IsValidEventType is new and narrower than armotypes.IsKnownEventType, which
spans both engines: k8s-admission is a real armotypes event type node-agent never
emits, so a node-agent rule naming it has to be rejected at load.

Also brings forward the celStateStore config field and the five state metrics
from Task 8, since the executor and cost estimator need them to compile.
Signed-off-by: Ben <ben@armosec.io>
Entries the predicate actually read become armotypes.CorrelationEvidence on the
alert, so a correlation alert describes BOTH ends of the chain -- without it the
alert would say only 'a process made an outbound connection' and drop the exec
that makes it interesting.

Populated in CreateRuleFailure rather than an event adapter, because
SetFailureMetadata is per-event-type while correlation is not.

InfectedPID and RuntimeProcessDetails still describe the triggering event:
correlation enriches an incident, it does not re-key it, so backend grouping is
unchanged. TestCorrelationEvidence_DoesNotRekeyTheAlert pins that, and an alert
with no correlations still serializes with no correlations key at all.

message/uniqueId now reuse the predicate's eval context so state.get() resolves
against the same entries, and uniqueId can be derived from the join key -- which
is what lets rulecooldown collapse both legs of a bidirectional rule.

Note the plan specified `Scope: string(h.Scope)` here; CorrelationEvidence.Scope
shipped as a typed armotypes.StateScope in v0.0.739 (a review change on
armoapi-go #681), so the copy is direct and the plan text was stale.
Signed-off-by: Ben <ben@armosec.io>
…purge

Adds the celStateStore config defaults and immediate scope purge on container
removal, so a churning node does not hold markers for containers that no longer
exist.

The purge deliberately uses Runtime.ContainerID VERBATIM. The plan specified
utils.TrimRuntimePrefix here, which would have been actively destructive: that
helper returns "" for an ID with no "//" separator, a bare runtime container ID
has none, and ContainerScopeID("") resolves to the HOST bucket -- so every
container exit would have wiped all host-process state instead of that
container's. The write path stores under the untrimmed Runtime.ContainerID
(EnrichedEvent.ContainerID is assigned from it in containercallback.go), so
untrimmed is also the only form that matches.
TestContainerScopeID_TrimmedRuntimeIDWouldHitTheHostBucket pins the trap.

main.go needs no change: the store is constructed inside CreateRuleManager, which
already has the ctx to run the sweeper on, and NewCEL does not need the store
because the per-rule Accessor carries it.

state_join_fired_total is NOT added. It has no call site until plan 3's
bidirectional component test exercises it, and an unwired metric is worse than a
noted gap.
Signed-off-by: Ben <ben@armosec.io>
…refactor

The pre-refactor loop reached ReportRuleProcessed only by falling off the end, so
an eval error or a cooldown-suppressed alert did not count as processed. Those
were continues; extracting the alert path turned them into returns, which would
have silently started counting them -- a changed metric meaning for every
existing rule, and a violation of the plan's byte-for-byte constraint for rules
with no stateWrites.

evaluateRuleAndAlert now reports whether it ran to completion and the caller
gates the metric on it. A predicate that simply did not match still counts as
processed, exactly as the old fall-through did.

Docs-exempt: restores pre-existing metric semantics; no documented behaviour changes
Signed-off-by: Ben <ben@armosec.io>
Four rules rather than one, for the same reason the TTY test has four: a CEL
expression that fails to compile returns (false, nil) rather than erroring, so
'no alert' is ambiguous between 'the predicate was false' and 'the rule never
ran'. R9912/R9913 must always fire and R9914 must never, which turns a silent
R9911 into a diagnosable result instead of a mystery.

R9911 deliberately has no exec ruleExpression -- only a stateWrites clause on
exec -- so it also exercises write-without-alerting, the shape every cross-event
rule depends on.

Docs-exempt: test fixtures only
Signed-off-by: Ben <ben@armosec.io>
The alert's existence is the proof: R9911's network-leg predicate is
state.has(...), so if the store does not work no alert is emitted. That needs
only the existing Alertmanager label assertion -- no payload receiver.

The trigger puts 8 seconds between the write and the read by sleeping inside the
shell and then exec-ing nc, which replaces the image without forking so the pid
is stable across both legs. Without that gap the two events are milliseconds
apart and node-agent evaluates on a concurrent worker pool, so a failure could
be reordering rather than a defect.

Controls are asserted before the correlation rule on purpose: if they are silent
their messages are the only diagnostic, and they are gone once the test fails.

Docs-exempt: test only
Signed-off-by: Ben <ben@armosec.io>
Test_35 was written but never added to the matrix, so the TTY field has only
ever been verified by hand. Both are wired in now.

Docs-exempt: CI configuration only
Signed-off-by: Ben <ben@armosec.io>
…uned

The Rules CRD has a structural schema and no x-kubernetes-preserve-unknown-fields
at the rule level, so the API server SILENTLY STRIPPED stateWrites on write. The
field never reached node-agent, and every correlation rule loaded cleanly and
then never fired.

Nothing in the Go code was wrong. Every unit test passed -- including a new one
added here that drives the real production CEL env -- because none of them go
through the API server. Only the component test found it, which is exactly what
it was written for. Verified directly: before this change
`kubectl get rules ... -o jsonpath={.spec.rules[0].stateWrites}` returned null
after a successful apply.

statewiring_test.go is the regression test that was missing. The state library's
own tests build a bare cel.NewEnv; production builds an env with an xcel
TypeAdapter/TypeProvider, every other library and a static optimizer. This drives
the write guard, the key expression and the cross-leg read through THAT env, so a
wiring problem that only appears in the real evaluator is caught in unit tests
rather than on a cluster.

IMPORTANT -- this fixes only the copy of the CRD in tests/chart. The canonical
Rules CRD ships from the kubescape/helm-charts repo, and the same property must
be added there or the feature is inert in production no matter what node-agent
does.

Docs-exempt: CRD schema fix; the feature page already documents stateWrites
Signed-off-by: Ben <ben@armosec.io>
…isite

The status block claimed the feature was untested on a cluster. It is now proven
end-to-end against real eBPF by Test_36.

Adds the deployment prerequisite that cost this a debugging cycle: the canonical
Rules CRD lives in kubescape/helm-charts, and without a stateWrites property
there the API server strips the clause silently -- rules load cleanly and never
fire, with no error in any log.
Signed-off-by: Ben <ben@armosec.io>
…rebase

main's SUB-7845 added GetProcessBootTimeNs to ProcessTreeCreator, so the stub
creator in ancestors_test.go no longer satisfied the interface. Ancestor walking
does not consult start times, so the stub reports "unknown" (0) rather than
inventing values.

Docs-exempt: test-only rebase integration fix
Signed-off-by: Ben <ben@armosec.io>
Every way a correlation rule can fail to fire is silent -- the rule applies,
loads, and never matches. This orders the causes by likelihood and cost to check,
leading with CRD pruning because that is the one that cost a debugging cycle and
the one nobody would guess: kubectl apply reports success and no log or metric
records the loss.
Signed-off-by: Ben <ben@armosec.io>
@slashben slashben added ai-assisted Created through Armosec AI tooling (armosec-shared-rules plugin) ai-reviewed-local labels Aug 3, 2026
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 74abb009-3357-423a-9069-c39969210560

📥 Commits

Reviewing files that changed from the base of the PR and between 67a2c61 and 811a482.

📒 Files selected for processing (5)
  • pkg/rulemanager/rule_manager.go
  • pkg/rulemanager/statewrites/executor.go
  • pkg/rulemanager/statewrites/executor_test.go
  • pkg/rulestate/store.go
  • pkg/rulestate/store_test.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • pkg/rulestate/store.go
  • pkg/rulemanager/statewrites/executor_test.go
  • pkg/rulemanager/statewrites/executor.go
  • pkg/rulemanager/rule_manager.go
  • pkg/rulestate/store_test.go

📝 Walkthrough

Walkthrough

This change adds a bounded CEL rule state store. Rules can write and read state across events, use ancestor process lookup, attach correlation evidence to alerts, expose state metrics, and validate the feature through unit and component tests.

Changes

CEL rule state store

Layer / File(s) Summary
State model and storage
go.mod, pkg/config/*, pkg/rulestate/*, pkg/utils/events.go, tests/chart/crds/rules.crd.yaml, pkg/rulemanager/types/v1/*, pkg/objectcache/containerprofilecache/*, pkg/rulemanager/rulecreator/*
Adds state entries, scope helpers, configuration, bounded storage, event validation, CRD fields, embedded runtime rule metadata, and updated rule fixtures.
CEL state access and event time
pkg/rulemanager/cel/*, pkg/rulemanager/cel/libraries/state/*, pkg/processtree/*
Adds the state CEL library, event timestamps, scoped and ancestor lookups, read tracking, context-aware expression evaluation, and bounded ancestor traversal.
State-write validation and execution
pkg/rulemanager/statewrites/*
Validates state-write clauses and evaluates guards, keys, values, scopes, TTLs, and process metadata before storing entries.
Rule-manager state lifecycle
pkg/rulemanager/rule_manager.go, pkg/rulemanager/statecontext.go, pkg/rulemanager/containercallbacks.go
Initializes the store, processes write-only event legs, tracks state reads, applies writes, forwards metrics, and purges removed container scopes.
Alert correlation, metrics, and validation
pkg/rulemanager/ruleadapters/*, pkg/rulemanager/types/failure.go, pkg/exporters/http_exporter.go, pkg/metricsmanager/*, docs/features/*, tests/component_test.go, tests/resources/*, .github/workflows/*
Adds correlation evidence and state metrics, documents the feature, and adds end-to-end component-test resources and coverage.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: matthyx

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.96% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the primary change: adding a CEL rule state store for cross-event correlation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cel-rule-state-store

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (4)
pkg/rulemanager/cel/cel.go (1)

216-254: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared logic from the two new context-aware evaluators.

EvaluateBoolExpressionWithContext and EvaluateStringExpressionWithContext duplicate the same three-step pattern already used by EvaluateRuleWithContext: call evaluateProgramWithContext, treat a nil result as a typed zero value, then type-assert the result. Extract a small shared helper that takes the target type's zero value and a type-assertion function, and have all three methods call it. This keeps the "cached compile failure" behavior in one place instead of three.

♻️ Example of a shared helper
+func evalTyped[T any](c *CEL, evalContext map[string]any, expression string) (T, error) {
+	var zero T
+	out, err := c.evaluateProgramWithContext(expression, evalContext)
+	if err != nil {
+		return zero, err
+	}
+	if out == nil {
+		return zero, nil
+	}
+	val, ok := out.Value().(T)
+	if !ok {
+		return zero, fmt.Errorf("expression returned %T, expected %T", out.Value(), zero)
+	}
+	return val, nil
+}

 func (c *CEL) EvaluateBoolExpressionWithContext(evalContext map[string]any, expression string) (bool, error) {
-	out, err := c.evaluateProgramWithContext(expression, evalContext)
-	if err != nil {
-		return false, err
-	}
-	if out == nil {
-		return false, nil
-	}
-	boolVal, ok := out.Value().(bool)
-	if !ok {
-		return false, fmt.Errorf("expression returned %T, expected bool", out.Value())
-	}
-	return boolVal, nil
+	return evalTyped[bool](c, evalContext, expression)
 }

 func (c *CEL) EvaluateStringExpressionWithContext(evalContext map[string]any, expression string) (string, error) {
-	out, err := c.evaluateProgramWithContext(expression, evalContext)
-	if err != nil {
-		return "", err
-	}
-	if out == nil {
-		return "", nil
-	}
-	strVal, ok := out.Value().(string)
-	if !ok {
-		return "", fmt.Errorf("expression returned %T, expected string", out.Value())
-	}
-	return strVal, nil
+	return evalTyped[string](c, evalContext, expression)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/rulemanager/cel/cel.go` around lines 216 - 254, Extract the duplicated
evaluateProgramWithContext, nil-result, and type-assertion flow from
EvaluateRuleWithContext, EvaluateBoolExpressionWithContext, and
EvaluateStringExpressionWithContext into one shared helper. Have the helper
accept the expression context and a typed zero value plus a type-assertion
callback, preserving cached compile failures as typed zero results and returning
the existing type-mismatch errors; update all three evaluators to use it.
pkg/rulemanager/statewrites/validate.go (1)

44-48: 🎯 Functional Correctness | 🔵 Trivial | 🏗️ Heavy lift

Compile the key, when, and value expressions at load time.

Validate accepts these three fields as opaque strings. A malformed expression therefore passes rule load and fails only during execution, where the executor logs at debug and drops the write (executor.go lines 118-124 and 158-164). That is the exact failure mode this package documents as unacceptable: the rule loads cleanly and never fires.

Pass the CEL environment into validation and compile each expression, so a typo fails the rule at load.

Also applies to: 93-109

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/rulemanager/statewrites/validate.go` around lines 44 - 48, Update
Validate to accept the CEL environment and compile the key, when, and value
expressions during rule loading, rejecting malformed expressions before runtime.
Propagate the environment from the caller and preserve the existing
converted-write behavior for valid expressions.
pkg/rulemanager/cel/libraries/state/accessor.go (1)

156-161: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename _container or restrict it to container scope.

e.ScopeID holds the ID of whatever scope the write declared. For a pod-scoped or node-scoped entry, _container then carries p:ns/pod or n:node. Rule authors read this key directly, so the name becomes part of the public CEL surface and a later rename breaks existing rules. Expose a scope-neutral key, and optionally keep _container only when e.Scope == armotypes.StateScopeContainer.

♻️ Proposed change
 	m := map[string]any{
 		"_ts":        e.Timestamp,
 		"_eventType": string(e.EventType),
-		"_container": e.ScopeID,
+		"_scope":     string(e.Scope),
+		"_scopeId":   e.ScopeID,
 	}
+	if e.Scope == armotypes.StateScopeContainer {
+		m["_container"] = e.ScopeID
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/rulemanager/cel/libraries/state/accessor.go` around lines 156 - 161,
Update entryToMap so the public CEL field is scope-neutral rather than exposing
every entry’s ScopeID as _container. Rename the key to an appropriate
scope-neutral symbol, or only populate _container when e.Scope equals
armotypes.StateScopeContainer; preserve the existing timestamp and event-type
mappings.
pkg/metricsmanager/otel/otel_metrics_manager.go (1)

545-556: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Rename the ReportStateWrite attribute key to match the Prometheus label.

ReportStateWrite reuses suppressedOption, which hardcodes the attribute key "reason" for its second argument. The Prometheus implementation of the same metric explicitly labels this dimension "result" (node_agent_state_writes_total uses []string{prometheusRuleIdLabel, "result"}). As a result, the OTEL and Prometheus exports of the same logical metric use different attribute names for the write outcome, which breaks cross-backend dashboard or alert-rule consistency.

Add a dedicated attribute-set helper (or a result-keyed variant) for ReportStateWrite instead of reusing suppressedOption.

♻️ Proposed fix
+func (m *OTELMetricsManager) stateWriteOption(ruleID, result string) metric.MeasurementOption {
+	key := ruleID + "\x00" + result
+	if v, ok := m.stateWriteCache.Load(key); ok {
+		return v.(metric.MeasurementOption)
+	}
+	opt := metric.WithAttributeSet(attribute.NewSet(
+		attribute.String("rule_id", ruleID),
+		attribute.String("result", result),
+	))
+	m.stateWriteCache.Store(key, opt)
+	return opt
+}
+
 func (m *OTELMetricsManager) ReportStateWrite(ruleID, result string) {
-	m.stateWritesTotal.Add(context.Background(), 1, m.suppressedOption(ruleID, result))
+	m.stateWritesTotal.Add(context.Background(), 1, m.stateWriteOption(ruleID, result))
 }

(Add a stateWriteCache sync.Map field alongside the other attribute-set caches.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/metricsmanager/otel/otel_metrics_manager.go` around lines 545 - 556,
Update ReportStateWrite to use a dedicated result-keyed attribute helper or
cache, with the second attribute named "result" instead of reusing
suppressedOption’s "reason" key. Add the corresponding stateWriteCache field
alongside the existing attribute caches, while leaving ReportStateWriteRejected
on the reason-keyed path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/rulemanager/rule_manager.go`:
- Around line 714-726: Update getUniqueIdAndMessage so a failure from evaluating
rule.Expressions.Message is preserved and returned instead of being overwritten
by the unique-ID evaluation assignment. Keep the existing logging, evaluate the
unique ID independently, and ensure the returned error reflects either
evaluation failure so evaluateRuleAndAlert cannot send an alert with an invalid
message.

In `@pkg/rulemanager/statewrites/executor_test.go`:
- Around line 299-303: Update TestScopeIDs_ResolvesFromTheEventOnly to compare
the node scope against rulestate.NodeScopeID() instead of the literal "n:",
while preserving the existing container and pod assertions.

In `@pkg/rulemanager/statewrites/executor.go`:
- Around line 141-146: Update the store-rejection branch in the executor’s
e.store.Set(entry) handling to report the rejection through the same counting
mechanism used by the guard, scope, and key paths, while preserving the existing
debug log and error context.
- Around line 81-85: Update the guard at the start of the relevant executor
method to return when enriched is nil or enriched.Event is nil, before calling
enriched.Event.GetEventType(). Preserve the existing checks and subsequent
processing for valid enriched events, and extend
TestApply_NilSafeOnMissingPieces to cover nil enriched/event input if
appropriate.

In `@pkg/rulestate/store.go`:
- Around line 53-58: Update Store.scopeCap to treat NodeScopeID() the same as
IsHostScopeID, returning MaxEntriesForHost for both node and host scopes while
retaining MaxEntriesPerContainer for container scopes. Add a regression test
alongside TestStore_HostBucketHasItsOwnLargerCap verifying node scope receives
the larger cap.
- Around line 62-105: Update Store.Set to determine whether the entry key
already exists before applying the global-cap gate, and bypass global-cap
rejection for replacing writes while retaining the gate for new entries.
Preserve the existing replacement behavior and add a regression test alongside
TestStore_GlobalCapSweepsBeforeRejecting that fills the store to MaxSize,
overwrites an existing key, and asserts success.

---

Nitpick comments:
In `@pkg/metricsmanager/otel/otel_metrics_manager.go`:
- Around line 545-556: Update ReportStateWrite to use a dedicated result-keyed
attribute helper or cache, with the second attribute named "result" instead of
reusing suppressedOption’s "reason" key. Add the corresponding stateWriteCache
field alongside the existing attribute caches, while leaving
ReportStateWriteRejected on the reason-keyed path.

In `@pkg/rulemanager/cel/cel.go`:
- Around line 216-254: Extract the duplicated evaluateProgramWithContext,
nil-result, and type-assertion flow from EvaluateRuleWithContext,
EvaluateBoolExpressionWithContext, and EvaluateStringExpressionWithContext into
one shared helper. Have the helper accept the expression context and a typed
zero value plus a type-assertion callback, preserving cached compile failures as
typed zero results and returning the existing type-mismatch errors; update all
three evaluators to use it.

In `@pkg/rulemanager/cel/libraries/state/accessor.go`:
- Around line 156-161: Update entryToMap so the public CEL field is
scope-neutral rather than exposing every entry’s ScopeID as _container. Rename
the key to an appropriate scope-neutral symbol, or only populate _container when
e.Scope equals armotypes.StateScopeContainer; preserve the existing timestamp
and event-type mappings.

In `@pkg/rulemanager/statewrites/validate.go`:
- Around line 44-48: Update Validate to accept the CEL environment and compile
the key, when, and value expressions during rule loading, rejecting malformed
expressions before runtime. Propagate the environment from the caller and
preserve the existing converted-write behavior for valid expressions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e6f42675-9045-4640-abf1-b228ac6effb2

📥 Commits

Reviewing files that changed from the base of the PR and between 8866b6c and 67a2c61.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (49)
  • .github/workflows/component-tests.yaml
  • docs/features/cel-rule-state-store.md
  • go.mod
  • pkg/config/config.go
  • pkg/config/config_test.go
  • pkg/exporters/http_exporter.go
  • pkg/metricsmanager/metrics_manager_interface.go
  • pkg/metricsmanager/metrics_manager_mock.go
  • pkg/metricsmanager/metrics_manager_noop.go
  • pkg/metricsmanager/otel/otel_metrics_manager.go
  • pkg/metricsmanager/prometheus/prometheus.go
  • pkg/objectcache/containerprofilecache/projection_compile_test.go
  • pkg/processtree/ancestors.go
  • pkg/processtree/ancestors_test.go
  • pkg/processtree/process_tree_manager_interface.go
  • pkg/processtree/process_tree_manager_mock.go
  • pkg/rulemanager/cel/cel.go
  • pkg/rulemanager/cel/cel_interface.go
  • pkg/rulemanager/cel/eventtime.go
  • pkg/rulemanager/cel/eventtime_test.go
  • pkg/rulemanager/cel/libraries/state/accessor.go
  • pkg/rulemanager/cel/libraries/state/readtracker.go
  • pkg/rulemanager/cel/libraries/state/statelib.go
  • pkg/rulemanager/cel/libraries/state/statelib_test.go
  • pkg/rulemanager/cel/statewiring_test.go
  • pkg/rulemanager/containercallbacks.go
  • pkg/rulemanager/rule_manager.go
  • pkg/rulemanager/ruleadapters/correlation_test.go
  • pkg/rulemanager/ruleadapters/creator.go
  • pkg/rulemanager/ruleadapters/creator_interface.go
  • pkg/rulemanager/rulecreator/ruleengine_mock.go
  • pkg/rulemanager/statecontext.go
  • pkg/rulemanager/statecontext_test.go
  • pkg/rulemanager/statewrites/executor.go
  • pkg/rulemanager/statewrites/executor_test.go
  • pkg/rulemanager/statewrites/validate.go
  • pkg/rulemanager/statewrites/validate_test.go
  • pkg/rulemanager/types/failure.go
  • pkg/rulemanager/types/v1/rule_embedding_test.go
  • pkg/rulemanager/types/v1/types.go
  • pkg/rulestate/store.go
  • pkg/rulestate/store_test.go
  • pkg/rulestate/types.go
  • pkg/utils/events.go
  • tests/chart/crds/rules.crd.yaml
  • tests/component_test.go
  • tests/resources/cel-state-deployment.yaml
  • tests/resources/cel-state-rulebinding.yaml
  • tests/resources/cel-state-rules.yaml

Comment thread pkg/rulemanager/rule_manager.go Outdated
Comment thread pkg/rulemanager/statewrites/executor_test.go
Comment thread pkg/rulemanager/statewrites/executor.go
Comment thread pkg/rulemanager/statewrites/executor.go
Comment thread pkg/rulestate/store.go
Comment thread pkg/rulestate/store.go
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Performance Benchmark Results

Node-Agent Resource Usage
Metric BEFORE AFTER Delta
Avg CPU (cores) 0.203 0.202 -0.4%
Peak CPU (cores) 0.214 0.217 +1.4%
Avg Memory (MiB) 339.102 266.929 -21.3%
Peak Memory (MiB) 340.527 273.941 -19.6%
Dedup Effectiveness

No data available.

…nts, nil guard

Five of CodeRabbit's six findings, with tests for the two that were real
correctness bugs.

Node scope now gets the larger cap. It is a single node-wide bucket shared by
every rule and workload, and PurgeScope is only ever called with a container's
scope ID so it is never reclaimed on container removal -- exactly the reasoning
that already justified the host bucket's headroom. Bounding it by the
per-container cap (256) would have starved node-scoped correlation on a busy node.

The global ceiling no longer rejects a write that merely REPLACES an existing
key. A replacement does not grow the store, so the per-scope cap already exempted
it; the ceiling did not, which meant a rule lost the ability to refresh an
established marker exactly when the store was under most pressure -- i.e. when an
incident is most likely in progress. The existence peek costs an extra RLock but
runs only on the already-degraded path (at the ceiling, nothing reclaimable), so
the hot path is unchanged.

Apply now guards a nil enriched/Event. podIdentity and processOf already tolerate
a nil Event, so without this the very next line panicked instead and the
package's nil handling was inconsistent. Not reachable from the rule loop, which
dereferences Event earlier, but an exported entry point should not depend on that.

getUniqueIdAndMessage no longer shadows err. Behaviour is deliberately unchanged:
only the uniqueId error is returned and only it drops the alert, because uniqueId
drives cooldown and backend dedup while a failed message costs description only.
Dropping a real detection because its text did not render is the worse failure.
That asymmetry is now stated in a comment rather than being an accident of
shadowing.

Docs-exempt: review fixes; no change to the documented rule-authoring surface
Signed-off-by: Ben <ben@armosec.io>
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Performance Benchmark Results

Node-Agent Resource Usage
Metric BEFORE AFTER Delta
Avg CPU (cores) 0.147 0.156 +6.4%
Peak CPU (cores) 0.154 0.164 +6.3%
Avg Memory (MiB) 337.784 269.954 -20.1%
Peak Memory (MiB) 339.727 274.348 -19.2%
Dedup Effectiveness

No data available.

@slashben
slashben requested a review from matthyx August 3, 2026 17:20

@matthyx matthyx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at 811a4826, built and tested locally: go build ./... clean, go test green for pkg/rulestate, pkg/rulemanager/... (incl. statewrites, types/v1, cel/libraries/state, ruleadapters), pkg/processtree, pkg/config.

The design is sound and unusually well documented — writes-after-predicate, receiver-scoped reads that make cross-rule/cross-container access inexpressible, reject-never-evict, and the evaluateRuleAndAlert extraction are all the right calls. The e2e test with positive controls and a negative control is real proof, not theatre. Nothing below is architectural; it's hot-path cost plus a few "silently never fires" gaps of exactly the kind this PR is elsewhere careful about.

Separate non-blocking comments follow with four more items and three questions.


1. gofmt violation, contradicting the PR's testing claim

pkg/metricsmanager/prometheus/prometheus.go is not gofmt-clean on this branch; the same file is clean on main. The new struct block at ~line 106 has one space too many:

stateWritesCounter         *prometheus.CounterVec   →   stateWritesCounter        *prometheus.CounterVec

Reproduce with:

gofmt -l $(git diff --name-only origin/main...HEAD | grep '\.go$')

2. node_agent_state_entries{scope} is a dead gauge

ReportStateEntries is plumbed through the rulestate.Metrics interface, NoopMetrics, the mock, the OTEL manager and the Prometheus manager, and it is documented in docs/features/cel-rule-state-store.md — but nothing ever calls it. pkg/rulestate/store.go never invokes it, so the gauge never receives a value and the documented metric silently does not exist.

Either emit it from Sweep() (per-scope counts are already in hand there, and it already walks every bucket), or drop it from the interface, both managers and the docs table.

3. Validation runs per event, not at load — so one bad rule floods the log

compileStateWrites is called inside the per-rule loop in ReportEnrichedEvent (pkg/rulemanager/rule_manager.go:361), not once at rule load. Two consequences:

  • A rule with a malformed clause hits logger.L().Error("RuleManager - invalid stateWrites clause...") on every matching event, forever.
  • A rule with ttl above maxTtl hits logger.L().Warning("statewrites - clamping ttl...") (pkg/rulemanager/statewrites/validate.go:85) on every matching event.

On a busy node that is thousands of identical lines per second from a single misconfigured rule — a self-inflicted outage risk from a rule an operator can push at any time.

Related: docs/features/cel-rule-state-store.md says "Validation happens at load" and "rejected when the rule loads", which contradicts the code comment directly above compileStateWrites. At minimum, dedupe these two logs per rule ID — the alertLogDedup expirable LRU already in rule_manager.go is the pattern — and align the doc with what the code does.

4. Per-rule, per-event allocations on the hot path for rules that never use state

stateTracker.Reset() and rm.seedStateContext(...) run unconditionally for every rule that survives the prefilter (rule_manager.go:392-393). seedStateContext allocates a statewrites.ScopeIDs() map plus an Accessor per rule, per event — so a node running 40 rules pays ~80 allocations per event for a feature almost none of them use. The "keeps the common no-state path to a single allocation" comment covers the tracker, but not this.

Gating on len(stateScopes) > 0 makes the no-state path free. The only cost is that a rule reading a name it never declared would get an unknown-variable eval error instead of a silent miss — arguably the better failure anyway, and consistent with the load-time-rejection philosophy elsewhere in this PR.

Same argument applies to compileStateWrites itself: caching per rule version rather than re-parsing durations on every event would remove the rest of it.

5. Store.Set has a performance cliff at the global ceiling

Once size >= MaxSize, every write calls Sweep(), which write-locks all 16 shards in turn and walks up to MaxSize (100k default) entries — with no rate limit:

if s.currentSize() >= s.cfg.MaxSize {
    if s.Sweep() == 0 {
        ...
    }
}

At the ceiling with nothing expirable, a container writing at any rate turns each write into an O(n) all-shard stall, and the rule loop is concurrent, so it stalls every worker. Reaching 100k is plausible on a dense node: ~300 containers × 256, plus pod scopes, plus the node and host buckets.

An atomic.Int64 holding the last sweep time, so at most one sweep per sweepInterval runs from the write path, keeps the backstop without the cliff.

6. currentSize() takes a global mutex on every write

sizeMu is acquired on every Set, which partly defeats the shard design the store's own doc comment is built around. An atomic.Int64 instead of sizeMu + int removes the contention point outright and simplifies addSize/currentSize to one-liners.

@matthyx matthyx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Four more items — non-blocking, but 8 in particular is worth a decision rather than a shrug.

7. ReportRuleProcessed semantics did drift, in the one case the refactor didn't consider

The refactor is otherwise faithful — the processed bool reproduces the old control flow exactly for eval errors, cooldown, getUniqueIdAndMessage failure and a nil ruleFailure. But:

processed := true
if len(ruleExpressions) > 0 {
    processed = rm.evaluateRuleAndAlert(...)
}

means a write-only leg — a rule with no ruleExpression for this event type — now increments ReportRuleProcessed, where the old loop continued at len(ruleExpressions) == 0 and never counted it. That inflates the counter for exactly the new case this PR introduces.

processed := len(ruleExpressions) > 0 restores the pre-refactor meaning.

8. The prefilter can silently eat a write leg

The table in docs/features/cel-rule-state-store.md honestly lists prefilter / policy / profile-dependency as suppressing writes, so this is a known choice rather than an oversight. It still deserves a second look, because rule.Prefilter is built from the rule as a whole — in practice mostly from the alerting leg's params.

Concretely: a correlation rule whose network leg carries ignorePrefixes or excludeProcesses will have those same params applied to its exec event, silently dropping the write. The chain then never forms, with no signal anywhere — precisely the failure class the write-after-cooldown ordering was designed to prevent, arriving through a different door.

If the behaviour stays as documented, please at least make it visible: state_write_rejected_total{reason="prefiltered"} turns an invisible failure into a countable one, and it slots straight into step 4 of the "When a correlation rule does not fire" list.

9. Pod-scope buckets are never purged

PurgeScope is only ever called from ContainerCallback, with a container scope ID. p:<ns>/<pod> entries therefore survive pod deletion until TTL — and scopeCap gives them MaxEntriesPerContainer (256) rather than the larger host cap, since the larger cap is reserved for c:__host__ and node scope.

So pod scope is both un-reclaimed on churn and on the smaller cap, which makes it the most likely bucket to hit ErrScopeCapReached on a busy node. Either purge pod scope when the pod's last container goes, or document it next to the existing host-bucket caveat so the behaviour is at least expected.

10. Store.Get's scope parameter is unused

func (s *Store) Get(ruleID string, _ armotypes.StateScope, scopeID, name, key string) (*Entry, bool)

An ignored scope argument on the read path of a scope-keyed store invites a future bug where someone passes a scope that disagrees with scopeID and assumes it is checked. Drop it, or assert it against the scope encoded in scopeID.

@matthyx

matthyx commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Three questions, none of them blocking — but each is a place where the answer being "no" is invisible.

Correlation evidence only reaches http_exporter

CorrelationAlert is wired into createRuleAlert in pkg/exporters/http_exporter.go and nowhere else. The PR is explicit and correct that Alertmanager's flat label map structurally cannot carry a nested correlations[], so that omission is understood.

What about the others — stdout, CSV, syslog? Is dropping correlations[] there a deliberate choice (they carry a flatter alert shape anyway), or just not reached yet? A sentence either way would keep the next person from assuming it's a bug and "fixing" it.

_pid is a uint in the map state.get returns

entryToMap stamps _pid/_ppid from armotypes.Process, so they land in CEL as unsigned. A rule author's first instinct is:

state.get("mount_exec", k)._pid == event.pid

which may hit a numeric-type mismatch rather than comparing. The docs already steer people to key: string(event.pid) for the join, so in practice the good path is signposted — but the provenance table lists _pid as directly available, and someone will reach for it in a when: guard or a message.

Is a one-line note under that table (compare via string(), or use the key) worth adding, or have you already confirmed cel-go's heterogeneous numeric equality handles this cleanly?

ResolveEventTime mixes the event clock with the wall clock

ExpiresAt is derived from the event's own timestamp, but expired() compares against time.Now(). I traced the current producers and it is fine today: DatasourceEvent.GetTimestamp goes through gadgets.WallTimeFromBootTime, and procfs and syscall events use time.Now().UnixNano() directly — all wall-clock, so the two clocks agree.

It stays fine only as long as no future event source hands over a raw boot-time value. If one ever does, time.Unix(0, ns) yields 1970-plus-uptime, every entry from that stream is born expired, and the rule silently never fires — the exact failure mode this PR works hardest everywhere else to design out.

Would a plausibility bound in ResolveEventTime be worth it — fall back to enrichedEvent.Timestamp when the resolved instant is implausibly far from now — so the invariant is enforced rather than merely currently true?

@matthyx matthyx moved this from WIP to Waiting on Author in KS PRs tracking Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

Status: Waiting on Author

Development

Successfully merging this pull request may close these issues.

2 participants