Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ go 1.25.8

require (
github.com/SergJa/jsonhash v0.0.0-20210531165746-fc45f346aa74
github.com/anchore/syft v1.42.3
github.com/anchore/syft v1.32.0
Comment thread
matthyx marked this conversation as resolved.
github.com/armosec/armoapi-go v0.0.696
github.com/armosec/utils-k8s-go v0.0.30
github.com/containers/common v0.63.0
Expand Down Expand Up @@ -50,7 +50,7 @@ require (
github.com/acobaugh/osrelease v0.1.0 // indirect
github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 // indirect
github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 // indirect
github.com/anchore/stereoscope v0.1.22 // indirect
github.com/anchore/stereoscope v0.1.9 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/armosec/gojay v1.2.17 // indirect
github.com/armosec/utils-go v0.0.58 // indirect
Expand Down Expand Up @@ -132,7 +132,7 @@ require (
github.com/oklog/ulid v1.3.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/runtime-spec v1.3.0 // indirect
github.com/opencontainers/runtime-spec v1.2.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 h1:2SqmFgE7h+Ql4
github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722/go.mod h1:oFuE8YuTCM+spgMXhePGzk3asS94yO9biUfDzVTFqNw=
github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 h1:ZyRCmiEjnoGJZ1+Ah0ZZ/mKKqNhGcUZBl0s7PTTDzvY=
github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115/go.mod h1:KoYIv7tdP5+CC9VGkeZV4/vGCKsY55VvoG+5dadg4YI=
github.com/anchore/stereoscope v0.1.22 h1:L807G/kk0WZzOCGuRGF7knxMKzwW2PGdbPVRystryd8=
github.com/anchore/stereoscope v0.1.22/go.mod h1:FikPtAb/WnbqwgLHAvQA9O+fWez0K4pbjxzghz++iy4=
github.com/anchore/syft v1.42.3 h1:eIeeGyqfXm/C8wpBWU50xFbOjdL37VbLatMj9nEJ6n4=
github.com/anchore/syft v1.42.3/go.mod h1:i2PZ+276IdPcnd/n32aeIv849iO/QqdjRknbIc39yL0=
github.com/anchore/stereoscope v0.1.9 h1:Nhvk8g6PRx9ubaJU4asAhD3fGcY5HKXZCDGkxI2e0sI=
github.com/anchore/stereoscope v0.1.9/go.mod h1:YkrCtDgz7A+w6Ggd0yxU9q58CerqQFwYARS+F2RvLQQ=
github.com/anchore/syft v1.32.0 h1:JcX9W+P/Xjv5DNg3TNBtwiEyZommuTaP16/NC9r0Yfo=
github.com/anchore/syft v1.32.0/go.mod h1:E6Kd4iBM2ljUOUQvSt7hVK6vBwaHkMXwcvBZmGMSY5o=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
Expand Down Expand Up @@ -564,8 +564,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg=
github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww=
github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
github.com/opencontainers/runtime-tools v0.9.1-0.20250303011046-260e151b8552 h1:CkXngT0nixZqQUPDVfwVs3GiuhfTqCMk0V+OoHpxIvA=
github.com/opencontainers/runtime-tools v0.9.1-0.20250303011046-260e151b8552/go.mod h1:T487Kf80NeF2i0OyVXHiylg217e0buz8pQsa0T791RA=
github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8=
Expand Down
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
201 changes: 201 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,195 @@ 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_SbomPathsPreserved(t *testing.T) {
numOpens := openThreshold() + 1
opens := generateSOOpens(numOpens)

// Build sbomSet containing ALL the .so paths (realistic scenario:
// these are library files referenced by the SBOM for vulnerability scanning)
sbomSet := mapset.NewSet[string]()
for _, open := range opens {
sbomSet.Add(open.Path)
}

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

result := deflateApplicationProfileContainer(container, sbomSet)

// SBOM paths must NEVER be collapsed — they map to specific library files
// used for vulnerability scanning. Collapsing them makes vuln results
// non-reproducible.
assert.Equal(t, numOpens, len(result.Opens),
"SBOM paths must be preserved verbatim, got %d opens (expected %d)", len(result.Opens), numOpens)
resultPaths := make(map[string]bool)
for _, r := range result.Opens {
resultPaths[r.Path] = true
}
for _, open := range opens {
assert.True(t, resultPaths[open.Path],
"SBOM path %q must be preserved in output", open.Path)
}
}

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")
}
18 changes: 18 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).
if isUserManaged(metadata) {
return nil
}

// either run single handler, or perform OR operation on multiple handlers
var toDelete bool
if len(handlers) == 1 {
Expand Down Expand Up @@ -212,6 +217,19 @@ func (h *ResourcesCleanupHandler) cleanupNamespace(ctx context.Context, ns strin
return nil
}

// isUserManaged reports whether the given resource metadata carries the
// "user-managed" marker. The marker lives on Annotations by codebase
// convention (see pkg/apis/softwarecomposition/networkpolicy/v2/
// networkpolicy.go for the canonical read-site) — NOT on Labels.
// Reading from Labels would silently miss every user-managed resource
// and defeat the cleanup skip entirely.
func isUserManaged(metadata *metav1.ObjectMeta) bool {
if metadata == nil {
return false
}
return metadata.Annotations[helpersv1.ManagedByMetadataKey] == helpersv1.ManagedByUserValue
}

func or(funcs []TypeCleanupHandlerFunc, kind, path string, metadata *metav1.ObjectMeta, resourceMaps ResourceMaps) bool {
for _, f := range funcs {
if f(kind, path, metadata, resourceMaps) {
Expand Down
Loading