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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.github/** merge=ours
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.

indeed to remove :)

10 changes: 4 additions & 6 deletions pkg/registry/file/applicationprofile_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,8 @@ import (
"k8s.io/apimachinery/pkg/runtime"
)

const (
OpenDynamicThreshold = 50
EndpointDynamicThreshold = 100
)
// Thresholds are defined in dynamicpathdetector.OpenDynamicThreshold and
// dynamicpathdetector.EndpointDynamicThreshold (single source of truth).

type ApplicationProfileProcessor struct {
defaultNamespace string
Expand Down Expand Up @@ -109,12 +107,12 @@ func (a *ApplicationProfileProcessor) SetStorage(containerProfileStorage Contain
}

func deflateApplicationProfileContainer(container softwarecomposition.ApplicationProfileContainer, sbomSet mapset.Set[string]) softwarecomposition.ApplicationProfileContainer {
opens, err := dynamicpathdetector.AnalyzeOpens(container.Opens, dynamicpathdetector.NewPathAnalyzer(OpenDynamicThreshold), sbomSet)
opens, err := dynamicpathdetector.AnalyzeOpens(container.Opens, dynamicpathdetector.NewPathAnalyzerWithConfigs(dynamicpathdetector.OpenDynamicThreshold, dynamicpathdetector.DefaultCollapseConfigs), sbomSet)
if err != nil {
logger.L().Debug("falling back to DeflateStringer for opens", loggerhelpers.Error(err))
opens = DeflateStringer(container.Opens)
}
endpoints := dynamicpathdetector.AnalyzeEndpoints(&container.Endpoints, dynamicpathdetector.NewPathAnalyzer(EndpointDynamicThreshold))
endpoints := dynamicpathdetector.AnalyzeEndpoints(&container.Endpoints, dynamicpathdetector.NewPathAnalyzerWithConfigs(dynamicpathdetector.EndpointDynamicThreshold, nil))
identifiedCallStacks := callstack.UnifyIdentifiedCallStacks(container.IdentifiedCallStacks)

return softwarecomposition.ApplicationProfileContainer{
Expand Down
190 changes: 190 additions & 0 deletions pkg/registry/file/applicationprofile_processor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,26 @@ import (
"context"
"fmt"
"slices"
"strings"
"testing"

mapset "github.com/deckarep/golang-set/v2"
"github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
"github.com/kubescape/storage/pkg/apis/softwarecomposition"
"github.com/kubescape/storage/pkg/apis/softwarecomposition/consts"
"github.com/kubescape/storage/pkg/config"
"github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector"
"github.com/stretchr/testify/assert"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)

// openThreshold returns the collapse threshold used by deflateApplicationProfileContainer
// for file-open paths. NewPathAnalyzerWithConfigs uses OpenDynamicThreshold as the default.
func openThreshold() int {
return dynamicpathdetector.OpenDynamicThreshold
}

var ap = softwarecomposition.ApplicationProfile{
ObjectMeta: v1.ObjectMeta{
Annotations: map[string]string{},
Expand Down Expand Up @@ -247,3 +256,184 @@ func TestDeflateRulePolicies(t *testing.T) {
})
}
}

// generateSOOpens creates N unique .so OpenCalls under /usr/lib/x86_64-linux-gnu/
func generateSOOpens(n int) []softwarecomposition.OpenCalls {
opens := make([]softwarecomposition.OpenCalls, n)
for i := 0; i < n; i++ {
opens[i] = softwarecomposition.OpenCalls{
Path: fmt.Sprintf("/usr/lib/x86_64-linux-gnu/lib%d.so.%d", i, i%5),
Flags: []string{"O_RDONLY", "O_CLOEXEC"},
}
}
return opens
}

func TestDeflateApplicationProfileContainer_CollapsesManyOpens(t *testing.T) {
// Generate enough opens to exceed the default threshold used by NewPathAnalyzerWithConfigs
numOpens := openThreshold() + 1
opens := generateSOOpens(numOpens)

container := softwarecomposition.ApplicationProfileContainer{
Name: "test-container",
Opens: opens,
}

result := deflateApplicationProfileContainer(container, nil)

assert.Less(t, len(result.Opens), numOpens,
"%d .so files should be collapsed, got %d opens", numOpens, len(result.Opens))

// Verify collapsed paths contain dynamic or wildcard segments
for _, open := range result.Opens {
if strings.HasPrefix(open.Path, "/usr/lib/x86_64-linux-gnu/") {
assert.True(t,
strings.Contains(open.Path, "\u22ef") || strings.Contains(open.Path, "*"),
"path %q should contain a dynamic or wildcard segment", open.Path)
}
}

// Flags should be preserved and merged
for _, open := range result.Opens {
assert.NotEmpty(t, open.Flags, "flags should be preserved after collapse")
}
}

func TestDeflateApplicationProfileContainer_CollapsesWithSbomSet(t *testing.T) {
numOpens := openThreshold() + 1
opens := generateSOOpens(numOpens)

// Build sbomSet containing ALL the .so paths (realistic scenario)
sbomSet := mapset.NewSet[string]()
for _, open := range opens {
sbomSet.Add(open.Path)
}

container := softwarecomposition.ApplicationProfileContainer{
Name: "test-container",
Opens: opens,
}

result := deflateApplicationProfileContainer(container, sbomSet)

// Even though all paths are in SBOM, they should still be collapsed
assert.Less(t, len(result.Opens), numOpens,
"SBOM paths should be collapsed too, got %d opens", len(result.Opens))
}

func TestDeflateApplicationProfileContainer_MixedPathsCollapse(t *testing.T) {
var opens []softwarecomposition.OpenCalls

// /usr/lib uses the default threshold from NewPathAnalyzerWithConfigs(OpenDynamicThreshold, ...)
usrLibThreshold := openThreshold()
for i := 0; i < usrLibThreshold+1; i++ {
opens = append(opens, softwarecomposition.OpenCalls{
Path: fmt.Sprintf("/usr/lib/lib%d.so", i),
Flags: []string{"O_RDONLY"},
})
}

// /etc uses the /etc config threshold from DefaultCollapseConfigs (100)
etcThreshold := 100
for i := 0; i < etcThreshold+1; i++ {
opens = append(opens, softwarecomposition.OpenCalls{
Path: fmt.Sprintf("/etc/conf%d.cfg", i),
Flags: []string{"O_RDONLY"},
})
}

opens = append(opens,
softwarecomposition.OpenCalls{Path: "/tmp/file1.txt", Flags: []string{"O_RDWR"}},
softwarecomposition.OpenCalls{Path: "/tmp/file2.txt", Flags: []string{"O_RDWR"}},
)

container := softwarecomposition.ApplicationProfileContainer{
Name: "test-container",
Opens: opens,
}

result := deflateApplicationProfileContainer(container, nil)

// Count paths by prefix
var usrLibPaths, etcPaths, tmpPaths int
for _, open := range result.Opens {
switch {
case strings.HasPrefix(open.Path, "/usr/lib/"):
usrLibPaths++
case strings.HasPrefix(open.Path, "/etc/"):
etcPaths++
case strings.HasPrefix(open.Path, "/tmp/"):
tmpPaths++
}
}

assert.LessOrEqual(t, usrLibPaths, 1, "/usr/lib/ paths should collapse to 1, got %d", usrLibPaths)
assert.LessOrEqual(t, etcPaths, 1, "/etc/ paths should collapse to 1, got %d", etcPaths)
assert.Equal(t, 2, tmpPaths, "/tmp/ paths should remain individual (below threshold)")
}

// TestDeflateApplicationProfileContainer_NilSbomNoError verifies that nil sbomSet
// with a small number of opens (below threshold) works without error.
func TestDeflateApplicationProfileContainer_NilSbomNoError(t *testing.T) {
container := softwarecomposition.ApplicationProfileContainer{
Name: "test-container",
Opens: []softwarecomposition.OpenCalls{
{Path: "/etc/hosts", Flags: []string{"O_RDONLY"}},
{Path: "/etc/resolv.conf", Flags: []string{"O_RDONLY"}},
{Path: "/usr/lib/libc.so.6", Flags: []string{"O_RDONLY", "O_CLOEXEC"}},
},
}

result := deflateApplicationProfileContainer(container, nil)

// All 3 paths should remain (below any threshold)
assert.Equal(t, 3, len(result.Opens), "paths below threshold should not collapse")
// Paths should be sorted
for i := 1; i < len(result.Opens); i++ {
assert.True(t, result.Opens[i-1].Path <= result.Opens[i].Path,
"opens should be sorted, got %q before %q", result.Opens[i-1].Path, result.Opens[i].Path)
}
}

// TestDeflateApplicationProfileContainer_PreSaveEndToEnd verifies the full
// PreSave flow with an ApplicationProfile containing many opens that should collapse.
func TestDeflateApplicationProfileContainer_PreSaveEndToEnd(t *testing.T) {
numOpens := openThreshold() + 1
opens := generateSOOpens(numOpens)

profile := &softwarecomposition.ApplicationProfile{
ObjectMeta: v1.ObjectMeta{
Annotations: map[string]string{},
},
Spec: softwarecomposition.ApplicationProfileSpec{
Containers: []softwarecomposition.ApplicationProfileContainer{
{
Name: "main",
Opens: opens,
},
},
},
}

processor := NewApplicationProfileProcessor(config.Config{
DefaultNamespace: "kubescape",
MaxApplicationProfileSize: 100000,
})

err := processor.PreSave(context.TODO(), profile)
assert.NoError(t, err)

resultOpens := profile.Spec.Containers[0].Opens
assert.Less(t, len(resultOpens), numOpens,
"PreSave should collapse %d .so files, got %d opens", numOpens, len(resultOpens))

// The collapsed path should contain dynamic or wildcard segments
hasCollapsed := false
for _, open := range resultOpens {
if strings.Contains(open.Path, "\u22ef") || strings.Contains(open.Path, "*") {
hasCollapsed = true
break
}
}
assert.True(t, hasCollapsed, "at least one path should contain a dynamic/wildcard segment after PreSave")
}
5 changes: 5 additions & 0 deletions pkg/registry/file/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,11 @@ func (h *ResourcesCleanupHandler) cleanupNamespace(ctx context.Context, ns strin
return nil
}

// Skip user-managed resources (e.g., user-defined profiles)
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.

we can skip before reading the metadata

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.

and good catch, without this we'd delete the user-defined profile

if metadata.Labels[helpersv1.ManagedByMetadataKey] == helpersv1.ManagedByUserValue {
return nil
}

// either run single handler, or perform OR operation on multiple handlers
var toDelete bool
if len(handlers) == 1 {
Expand Down
4 changes: 2 additions & 2 deletions pkg/registry/file/containerprofile_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -707,12 +707,12 @@ func (a *ContainerProfileProcessor) getAggregatedData(ctx context.Context, key s
}

func DeflateContainerProfileSpec(container softwarecomposition.ContainerProfileSpec, sbomSet mapset.Set[string]) softwarecomposition.ContainerProfileSpec {
opens, err := dynamicpathdetector.AnalyzeOpens(container.Opens, dynamicpathdetector.NewPathAnalyzer(OpenDynamicThreshold), sbomSet)
opens, err := dynamicpathdetector.AnalyzeOpens(container.Opens, dynamicpathdetector.NewPathAnalyzerWithConfigs(dynamicpathdetector.OpenDynamicThreshold, dynamicpathdetector.DefaultCollapseConfigs), sbomSet)
if err != nil {
logger.L().Debug("ContainerProfileProcessor.deflateContainerProfileSpec - falling back to DeflateStringer for opens", loggerhelpers.Error(err))
opens = DeflateStringer(container.Opens)
}
endpoints := dynamicpathdetector.AnalyzeEndpoints(&container.Endpoints, dynamicpathdetector.NewPathAnalyzer(EndpointDynamicThreshold))
endpoints := dynamicpathdetector.AnalyzeEndpoints(&container.Endpoints, dynamicpathdetector.NewPathAnalyzerWithConfigs(dynamicpathdetector.EndpointDynamicThreshold, nil))
identifiedCallStacks := callstack.UnifyIdentifiedCallStacks(container.IdentifiedCallStacks)

return softwarecomposition.ContainerProfileSpec{
Expand Down
66 changes: 57 additions & 9 deletions pkg/registry/file/dynamicpathdetector/analyze_endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,51 @@ import (
types "github.com/kubescape/storage/pkg/apis/softwarecomposition"
)

func isWildcardPort(port string) bool {
return port == "0"
}

func rewritePort(endpoint, wildcardPort string) string {
if wildcardPort == "" {
return endpoint
}
port, pathPart := splitEndpointPortAndPath(endpoint)
if !isWildcardPort(port) {
return ":" + wildcardPort + pathPart
}
return endpoint
}

func AnalyzeEndpoints(endpoints *[]types.HTTPEndpoint, analyzer *PathAnalyzer) []types.HTTPEndpoint {
if len(*endpoints) == 0 {
return nil
}

var newEndpoints []*types.HTTPEndpoint
// Detect wildcard port in input (port 0 means any port)
wildcardPort := ""
for _, ep := range *endpoints {
port, _ := splitEndpointPortAndPath(ep.Endpoint)
if isWildcardPort(port) {
wildcardPort = port
break
}
}

// First pass: build tree, redirecting to wildcard port if needed
for _, endpoint := range *endpoints {
_, _ = AnalyzeURL(endpoint.Endpoint, analyzer)
_, _ = AnalyzeURL(rewritePort(endpoint.Endpoint, wildcardPort), analyzer)
}

// Second pass: process endpoints
var newEndpoints []*types.HTTPEndpoint
for _, endpoint := range *endpoints {
processedEndpoint, err := ProcessEndpoint(&endpoint, analyzer, newEndpoints)
ep := endpoint
ep.Endpoint = rewritePort(ep.Endpoint, wildcardPort)
processedEndpoint, err := ProcessEndpoint(&ep, analyzer, newEndpoints)
if processedEndpoint == nil && err == nil || err != nil {
continue
} else {
newEndpoints = append(newEndpoints, processedEndpoint)
}
newEndpoints = append(newEndpoints, processedEndpoint)
}

newEndpoints = MergeDuplicateEndpoints(newEndpoints)
Expand Down Expand Up @@ -88,6 +116,15 @@ func AnalyzeURL(urlString string, analyzer *PathAnalyzer) (string, error) {
return ":" + port + path, nil
}

func splitEndpointPortAndPath(endpoint string) (string, string) {
s := strings.TrimPrefix(endpoint, ":")
idx := strings.Index(s, "/")
if idx == -1 {
return s, "/"
}
return s[:idx], s[idx:]
}

func MergeDuplicateEndpoints(endpoints []*types.HTTPEndpoint) []*types.HTTPEndpoint {
seen := make(map[string]*types.HTTPEndpoint)
var newEndpoints []*types.HTTPEndpoint
Expand All @@ -97,10 +134,22 @@ func MergeDuplicateEndpoints(endpoints []*types.HTTPEndpoint) []*types.HTTPEndpo
if existing, found := seen[key]; found {
existing.Methods = MergeStrings(existing.Methods, endpoint.Methods)
mergeHeaders(existing, endpoint)
} else {
seen[key] = endpoint
newEndpoints = append(newEndpoints, endpoint)
continue
}

// Check if a wildcard port variant already exists (port 0 means any port)
port, pathPart := splitEndpointPortAndPath(endpoint.Endpoint)
if !isWildcardPort(port) {
wildcardKey := fmt.Sprintf(":%s%s|%s", "0", pathPart, endpoint.Direction)
if existing, found := seen[wildcardKey]; found {
existing.Methods = MergeStrings(existing.Methods, endpoint.Methods)
mergeHeaders(existing, endpoint)
continue
}
}

seen[key] = endpoint
newEndpoints = append(newEndpoints, endpoint)
}

return newEndpoints
Expand All @@ -111,7 +160,6 @@ func getEndpointKey(endpoint *types.HTTPEndpoint) string {
}

func mergeHeaders(existing, new *types.HTTPEndpoint) {
// TODO: Find a better way to unmashal the headers
existingHeaders, err := existing.GetHeaders()
if err != nil {
return
Expand Down
Loading
Loading