Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions pkg/rulemanager/cel/libraries/applicationprofile/ap.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,28 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt {
}),
),
},
// ap.was_path_opened_with_suffix and ap.was_path_opened_with_prefix
// — rule-author contract (CodeRabbit upstream PR #807 finding #7):
//
// These helpers answer "did any RECORDED concrete path open match
// this suffix/prefix?". When the profile-projection cache is in
// pass-through mode (no rule declared an Opens-projection slice,
// so cp.Opens.All == true), wildcard patterns in cp.Opens.Patterns
// are NOT scanned via string-level HasSuffix/HasPrefix because the
// pattern text contains '*' / '⋯' tokens whose string shape doesn't
// safely answer suffix/prefix questions (see open.go comment).
// Concrete-only Values are scanned.
//
// False-negative gap: if a profile entry is `/var/log/pods/*/foo.log`,
// the runtime path `/var/log/pods/web-7d6f/volumes/foo.log` actually
// matches this pattern, but `was_path_opened_with_suffix("/foo.log")`
// returns FALSE because the pattern text doesn't end in `/foo.log`
// literally. Rule authors who need wildcard-aware coverage should
// either: (a) declare an Opens projection slice in the rule's
// ProfileDataRequired (then SuffixHits/PrefixHits become authoritative
// and the projector pre-computes the hit map for wildcard entries),
// or (b) use ap.was_path_opened(path) which DOES run dynamic-segment
// matching over Patterns via CompareDynamic.
Comment on lines +133 to +154
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Helper contract comment is out of sync with runtime behavior.

Lines 139-154 still describe cp.Opens.Patterns as effectively not participating in suffix/prefix answers, but open.go now scans patterns via patternConcreteSuffix/patternConcretePrefix in pass-through mode. This doc mismatch can mislead rule authors.

🤖 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/applicationprofile/ap.go` around lines 133 -
154, The comment for
ap.was_path_opened_with_suffix/ap.was_path_opened_with_prefix is stale: update
the doc to reflect that when the projector is in pass-through mode
cp.Opens.Patterns ARE scanned for concrete suffix/prefix matches via the
patternConcreteSuffix and patternConcretePrefix helpers in open.go (so Patterns
can contribute to suffix/prefix answers), and adjust the guidance about when to
declare an Opens projection slice or use ap.was_path_opened(path) accordingly;
reference ap.was_path_opened_with_suffix, ap.was_path_opened_with_prefix,
cp.Opens.Patterns, patternConcreteSuffix, patternConcretePrefix and
SuffixHits/PrefixHits in the updated comment so rule authors aren’t misled.

"ap.was_path_opened_with_suffix": {
cel.Overload(
"ap_was_path_opened_with_suffix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType,
Expand Down
121 changes: 115 additions & 6 deletions pkg/rulemanager/cel/libraries/applicationprofile/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ func (l *apLibrary) wasPathOpened(containerID, path ref.Val) ref.Val {
return types.Bool(false)
}

// wasPathOpenedWithFlags answers whether the projected ApplicationProfile
// contains an open-entry whose path matches the given path. The flags
// argument is parsed and validated for shape but is not used for matching
// in v1 — the OpenFlagsByPath projection slice is out of scope for v1
// (composite-key projection would balloon the cache footprint). When the
// flags-projection slice is added in a future spec revision, this helper
// becomes the path-AND-flag matcher and v1 callers continue to work.
func (l *apLibrary) wasPathOpenedWithFlags(containerID, path, flags ref.Val) ref.Val {
if l.objectCache == nil {
return types.NewErr("objectCache is nil")
Expand Down Expand Up @@ -105,14 +112,32 @@ func (l *apLibrary) wasPathOpenedWithSuffix(containerID, suffix ref.Val) ref.Val
}

if cp.Opens.All {
// All entries retained — scan to check for the suffix.
// All entries retained (no rule declared SuffixHits-style
// projection). Scan concrete entries in Values first — exact
// strings.HasSuffix is correct for those.
for openPath := range cp.Opens.Values {
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.

Blocking: projectField() still keeps dynamic open paths only in cp.Opens.Patterns when cp.Opens.All == true (see TestApply_DynamicRetainedInPassThrough). Removing the Patterns scan here makes ap.was_path_opened_with_suffix() / _with_prefix() return false for dynamic entries in pass-through mode, so rules that omit profileDataRequired.opens regress. If the old strings.HasPrefix/HasSuffix behavior on pattern text is too permissive, we still need a narrower fallback here instead of ignoring Patterns entirely.

if strings.HasSuffix(openPath, suffixStr) {
return types.Bool(true)
}
}
for _, openPath := range cp.Opens.Patterns {
if strings.HasSuffix(openPath, suffixStr) {
// Patterns hold dynamic entries (containing `*` / `⋯`). We
// can't run strings.HasSuffix on the raw pattern text — a
// pattern like "/var/log/pods/*/volumes/..." has wildcard
// tokens that don't textually end with "foo.log" even though
// its concrete realisations might. Matthias upstream PR #811
// review: a NARROWER fallback is the answer here — split off
// the pattern's concrete tail (the literal text after the
// last wildcard segment) and only check HasSuffix against
// that. If the pattern ends in a wildcard segment, the tail
// is empty and concrete realisations could match ANY suffix —
// be permissive (return true) to avoid the false-negative on
// rules that omit profileDataRequired.opens.
for _, openPattern := range cp.Opens.Patterns {
tail := patternConcreteSuffix(openPattern)
if tail == "" {
return types.Bool(true)
}
if strings.HasSuffix(tail, suffixStr) {
return types.Bool(true)
}
}
Expand Down Expand Up @@ -149,14 +174,24 @@ func (l *apLibrary) wasPathOpenedWithPrefix(containerID, prefix ref.Val) ref.Val
}

if cp.Opens.All {
// All entries retained — scan to check for the prefix.
// All entries retained. Scan concrete entries in Values first —
// exact strings.HasPrefix is correct for those.
for openPath := range cp.Opens.Values {
if strings.HasPrefix(openPath, prefixStr) {
return types.Bool(true)
}
}
for _, openPath := range cp.Opens.Patterns {
if strings.HasPrefix(openPath, prefixStr) {
// Patterns: same narrower-fallback strategy as the suffix path.
// Split off the pattern's concrete head (the literal text
// BEFORE the first wildcard segment). If the pattern starts
// with a wildcard, concrete realisations could match ANY
// prefix — be permissive. Matthias upstream PR #811 review.
for _, openPattern := range cp.Opens.Patterns {
head := patternConcretePrefix(openPattern)
if head == "" {
return types.Bool(true)
}
if strings.HasPrefix(head, prefixStr) {
return types.Bool(true)
}
}
Expand All @@ -173,3 +208,77 @@ func (l *apLibrary) wasPathOpenedWithPrefix(containerID, prefix ref.Val) ref.Val
return types.Bool(hit)
}

// patternConcreteSuffix returns the literal text at the tail of a
// wildcard-bearing path pattern, dropped to start after the LAST
// wildcard segment's trailing `/`. Returns the input unchanged when
// no wildcard segments are present, or "" when the pattern ends in
// a wildcard segment (concrete realisations could match any suffix).
//
// Examples:
//
// "/var/log/⋯/foo.log" → "foo.log" (last wildcard `⋯`, concrete tail follows)
// "/var/log/pods/*" → "" (trailing wildcard, permissive caller)
// "/var/log/foo.log" → "/var/log/foo.log" (no wildcards, whole pattern)
// "*" → "" (lone wildcard)
//
// Matthias upstream PR #811 review.
func patternConcreteSuffix(p string) string {
lastWildEnd := -1
i := 0
for i < len(p) {
segStart := i
for i < len(p) && p[i] != '/' {
i++
}
seg := p[segStart:i]
if seg == "*" || seg == dynamicpathdetector.DynamicIdentifier {
lastWildEnd = i
}
if i < len(p) {
i++ // skip `/`
}
}
if lastWildEnd < 0 {
return p
}
if lastWildEnd >= len(p) {
return ""
}
// lastWildEnd points at the `/` after the wildcard segment. Keep
// the slash so callers querying with leading-slash suffixes match
// correctly (every concrete realisation has that slash too).
return p[lastWildEnd:]
}

// patternConcretePrefix is the mirror of patternConcreteSuffix —
// returns the literal text at the HEAD of the pattern up to (but not
// including) the first wildcard segment. Returns the input unchanged
// when no wildcard segments are present, or "" when the pattern starts
// with a wildcard segment.
//
// Matthias upstream PR #811 review.
func patternConcretePrefix(p string) string {
i := 0
for i < len(p) {
segStart := i
for i < len(p) && p[i] != '/' {
i++
}
seg := p[segStart:i]
if seg == "*" || seg == dynamicpathdetector.DynamicIdentifier {
if segStart == 0 {
return ""
}
// segStart is at the wildcard segment; the byte BEFORE it
// is the `/` separator. Keep the slash in the returned
// prefix so callers querying with trailing-slash prefixes
// match (every concrete realisation has that slash too).
return p[:segStart]
}
if i < len(p) {
i++ // skip `/`
}
}
return p
}

129 changes: 129 additions & 0 deletions pkg/rulemanager/cel/libraries/applicationprofile/open_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package applicationprofile

import (
"strconv"
"testing"

"github.com/google/cel-go/common/types"
"github.com/kubescape/node-agent/pkg/objectcache"
)

// BenchmarkWasPathOpenedWithSuffix_AllMode exercises the pass-through
// (Opens.All == true) suffix path under three representative profile
// shapes:
//
// - values_only: 50 concrete entries, no Patterns
// - patterns_concrete: 50 concrete entries + 10 Patterns whose tail
// is literal (the typical /var/log/⋯/foo.log shape)
// - patterns_wildcard: 50 concrete entries + 10 Patterns ending in a
// wildcard segment (the permissive-arm shape)
//
// Captures Matthias's upstream PR #811 contract numbers for the PR
// description.
func BenchmarkWasPathOpenedWithSuffix_AllMode(b *testing.B) {
shapes := []struct {
name string
values int
patterns []string
}{
{"values_only", 50, nil},
{"patterns_concrete", 50, []string{
"/var/log/⋯/access.log", "/var/log/⋯/error.log", "/opt/⋯/server.log",
"/etc/⋯/audit.log", "/var/run/⋯/state.log", "/srv/⋯/app.log",
"/var/cache/⋯/tmp.log", "/usr/share/⋯/data.log", "/home/⋯/user.log",
"/proc/⋯/status.log",
}},
{"patterns_wildcard", 50, []string{
"/var/log/pods/*", "/var/log/containers/*", "/etc/cron.d/*",
"/opt/⋯", "/srv/*", "/var/run/*",
"/usr/local/⋯", "/home/⋯", "/tmp/⋯", "/run/⋯",
}},
}
for _, sh := range shapes {
b.Run(sh.name, func(b *testing.B) {
values := make(map[string]struct{}, sh.values)
for i := 0; i < sh.values; i++ {
values["/usr/lib/x86_64-linux-gnu/libcrypto.so."+strconv.Itoa(i)] = struct{}{}
}
pcp := &objectcache.ProjectedContainerProfile{
Opens: objectcache.ProjectedField{
All: true,
Values: values,
Patterns: sh.patterns,
},
}
lib := &apLibrary{objectCache: &mockObjectCacheForPattern{pcp: pcp}}
suffix := types.String(".log")
cid := types.String("bench-cid")
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = lib.wasPathOpenedWithSuffix(cid, suffix)
}
})
}
}

// BenchmarkWasPathOpenedWithPrefix_AllMode mirrors the suffix bench
// for the prefix path.
func BenchmarkWasPathOpenedWithPrefix_AllMode(b *testing.B) {
shapes := []struct {
name string
values int
patterns []string
}{
{"values_only", 50, nil},
{"patterns_concrete", 50, []string{
"/var/log/⋯/access.log", "/var/log/⋯/error.log", "/opt/⋯/server.log",
"/etc/⋯/audit.log", "/var/run/⋯/state.log",
}},
{"patterns_wildcard", 50, []string{
"*/run", "*/log", "*/cache",
"⋯", "*",
}},
}
for _, sh := range shapes {
b.Run(sh.name, func(b *testing.B) {
values := make(map[string]struct{}, sh.values)
for i := 0; i < sh.values; i++ {
values["/usr/lib/x86_64-linux-gnu/libcrypto.so."+strconv.Itoa(i)] = struct{}{}
}
pcp := &objectcache.ProjectedContainerProfile{
Opens: objectcache.ProjectedField{
All: true,
Values: values,
Patterns: sh.patterns,
},
}
lib := &apLibrary{objectCache: &mockObjectCacheForPattern{pcp: pcp}}
prefix := types.String("/var/")
cid := types.String("bench-cid")
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = lib.wasPathOpenedWithPrefix(cid, prefix)
}
})
}
}

// BenchmarkPatternConcreteSuffix isolates the helper to confirm zero
// allocation regardless of pattern shape.
func BenchmarkPatternConcreteSuffix(b *testing.B) {
cases := []string{
"/var/log/⋯/foo.log",
"/var/log/pods/*",
"/var/log/foo.log",
"*",
"/var/⋯/log/⋯/foo.log",
}
for _, c := range cases {
b.Run(c, func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = patternConcreteSuffix(c)
}
})
}
}
Loading