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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ go 1.25.8
require (
github.com/SergJa/jsonhash v0.0.0-20210531165746-fc45f346aa74
github.com/anchore/syft v1.42.3
github.com/armosec/armoapi-go v0.0.696
github.com/armosec/armoapi-go v0.0.719
github.com/armosec/utils-k8s-go v0.0.30
github.com/containers/common v0.63.0
github.com/deckarep/golang-set/v2 v2.7.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV
github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armosec/armoapi-go v0.0.696 h1:+0Ll7y4oWNaKEO47qbGDFIQLxkSJeKYzylS0FwI84XE=
github.com/armosec/armoapi-go v0.0.696/go.mod h1:9jAH0g8ZsryhiBDd/aNMX4+n10bGwTx/doWCyyjSxts=
github.com/armosec/armoapi-go v0.0.719 h1:eo35KTOPS0vM3asLfDONwNScZ+1FRhUprpNWs2czXCM=
github.com/armosec/armoapi-go v0.0.719/go.mod h1:9jAH0g8ZsryhiBDd/aNMX4+n10bGwTx/doWCyyjSxts=
github.com/armosec/gojay v1.2.17 h1:VSkLBQzD1c2V+FMtlGFKqWXNsdNvIKygTKJI9ysY8eM=
github.com/armosec/gojay v1.2.17/go.mod h1:vuvX3DlY0nbVrJ0qCklSS733AWMoQboq3cFyuQW9ybc=
github.com/armosec/utils-go v0.0.58 h1:g9RnRkxZAmzTfPe2ruMo2OXSYLwVSegQSkSavOfmaIE=
Expand Down
10 changes: 10 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,18 @@ func main() {
cleanupHandler := file.NewResourcesCleanupHandler(osFs, file.DefaultStorageRoot, pool, watchDispatcher, cfg.CleanupInterval, cfg.DefaultNamespace, kubernetesAPI, relevancyEnabled)
go cleanupHandler.RunCleanupTask(ctx)

// Shared open-protection store: seeded from static config and, when an
// operator-managed ConfigMap is configured, kept in sync by a reloader so
// rule-binding changes adjust the pinned sensitive prefixes without a restart.
openProtectionStore := file.NewOpenProtectionStore(cfg.ProtectedOpenMatchers)
if cfg.OpenProtectionConfigMapName != "" {
reloader := file.NewOpenProtectionReloader(client, cfg.DefaultNamespace, cfg.OpenProtectionConfigMapName, cfg.OpenProtectionRefreshInterval, openProtectionStore)
go reloader.Run(ctx)
}

// start the server
options := server.NewWardleServerOptions(os.Stdout, os.Stderr, osFs, pool, cfg, watchDispatcher, cleanupHandler)
options.OpenProtectionStore = openProtectionStore
cmd := server.NewCommandStartWardleServer(ctx, options, false)
logger.L().Info("APIServer starting")
code := cli.Run(cmd)
Expand Down
13 changes: 7 additions & 6 deletions pkg/apiserver/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,12 @@ func init() {

// ExtraConfig holds custom apiserver config
type ExtraConfig struct {
CleanupHandler *file.ResourcesCleanupHandler
OsFs afero.Fs
Pool *sqlitemigration.Pool
StorageConfig config.Config
WatchDispatcher *file.WatchDispatcher
CleanupHandler *file.ResourcesCleanupHandler
OsFs afero.Fs
Pool *sqlitemigration.Pool
StorageConfig config.Config
WatchDispatcher *file.WatchDispatcher
OpenProtectionStore *file.OpenProtectionStore
}

// Config defines the config for the apiserver
Expand Down Expand Up @@ -143,7 +144,7 @@ func (c completedConfig) New() (*WardleServer, error) {
storageImpl = file.NewStorageImpl(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme)

applicationProfileStorageImpl = file.NewApplicationProfileStorage(file.NewStorageImplWithCollector(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme, file.NewApplicationProfileProcessor(c.ExtraConfig.StorageConfig)))
containerProfileStorageImpl = file.NewContainerProfileRESTStorage(file.NewStorageImplWithCollector(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme, file.NewContainerProfileProcessor(c.ExtraConfig.StorageConfig, c.ExtraConfig.CleanupHandler)))
containerProfileStorageImpl = file.NewContainerProfileRESTStorage(file.NewStorageImplWithCollector(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme, file.NewContainerProfileProcessor(c.ExtraConfig.StorageConfig, c.ExtraConfig.CleanupHandler, c.ExtraConfig.OpenProtectionStore)))
networkNeighborhoodStorageImpl = file.NewNetworkNeighborhoodStorage(file.NewStorageImplWithCollector(c.ExtraConfig.OsFs, file.DefaultStorageRoot, c.ExtraConfig.Pool, c.ExtraConfig.WatchDispatcher, Scheme, file.NewNetworkNeighborhoodProcessor(c.ExtraConfig.StorageConfig)))
configScanStorageImpl = file.NewConfigurationScanSummaryStorage(storageImpl)
vulnerabilitySummaryStorage = file.NewVulnerabilitySummaryStorage(storageImpl)
Expand Down
22 changes: 12 additions & 10 deletions pkg/cmd/server/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,12 @@ type WardleServerOptions struct {

AlternateDNS []string

CleanupHandler *file.ResourcesCleanupHandler
OsFs afero.Fs
Pool *sqlitemigration.Pool
StorageConfig config.Config
WatchDispatcher *file.WatchDispatcher
CleanupHandler *file.ResourcesCleanupHandler
OsFs afero.Fs
Pool *sqlitemigration.Pool
StorageConfig config.Config
WatchDispatcher *file.WatchDispatcher
OpenProtectionStore *file.OpenProtectionStore
}

func WardleVersionToKubeVersion(ver *version.Version) *version.Version {
Expand Down Expand Up @@ -288,11 +289,12 @@ func (o *WardleServerOptions) Config() (*apiserver.Config, error) {
c := &apiserver.Config{
GenericConfig: serverConfig,
ExtraConfig: apiserver.ExtraConfig{
CleanupHandler: o.CleanupHandler,
OsFs: o.OsFs,
Pool: o.Pool,
StorageConfig: o.StorageConfig,
WatchDispatcher: o.WatchDispatcher,
CleanupHandler: o.CleanupHandler,
OsFs: o.OsFs,
Pool: o.Pool,
StorageConfig: o.StorageConfig,
WatchDispatcher: o.WatchDispatcher,
OpenProtectionStore: o.OpenProtectionStore,
},
}
return c, nil
Expand Down
19 changes: 19 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ type Config struct {
DefaultWorkerCount int `mapstructure:"defaultWorkerCount"`
DefaultMaxObjectSize int `mapstructure:"defaultMaxObjectSize"`

// ProtectedOpenMatchers is the union of sensitive open matchers
// (exact/prefix/suffix/contains) declared by the active rules'
// profileDataRequired.opens. The container-profile processor pins these
// (and their ancestors) to literal during deflation so anomaly rules such
// as R0010 keep working. In cluster this is populated by the operator/helm
// from the versioned rule library (armotypes.UnionOpenProtection); the
// zero value preserves legacy collapse behaviour.
ProtectedOpenMatchers armotypes.OpenMatchers `mapstructure:"protectedOpenMatchers"`

// OpenProtectionConfigMapName, when non-empty, names a ConfigMap (in
// DefaultNamespace) that the operator keeps in sync with the union of active
// rules' profileDataRequired.opens. The apiserver polls it every
// OpenProtectionRefreshInterval and refreshes the container-profile
// processor's protection, so rule-binding changes take effect without a
// restart. Empty disables the reader and falls back to ProtectedOpenMatchers.
OpenProtectionConfigMapName string `mapstructure:"openProtectionConfigMapName"`
OpenProtectionRefreshInterval time.Duration `mapstructure:"openProtectionRefreshInterval"`

// Debugging
QueueManagerEnabled bool `mapstructure:"queueManagerEnabled"`
QueueTimeoutPrint bool `mapstructure:"queueTimeoutPrint"`
Expand Down Expand Up @@ -66,6 +84,7 @@ func LoadConfig(path string) (Config, error) {
v.SetDefault("queueTimeoutPrint", false)
v.SetDefault("queueTimeout", 60)
v.SetDefault("queueProcessingStatsPrint", false)
v.SetDefault("openProtectionRefreshInterval", time.Minute)
v.SetDefault("kindQueues", map[string]KindQueueConfig{
"applicationprofiles": {
QueueLength: 50,
Expand Down
62 changes: 58 additions & 4 deletions pkg/registry/file/containerprofile_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,26 @@ type ContainerProfileProcessor struct {
MaxContainerProfileSize int
ContainerProfileStorage ContainerProfileStorage
ConsolidatedSlugChannel chan ConsolidatedSlugData
// protection holds the active union of sensitive open matchers (exact/prefix/
// suffix/contains) declared by active rules' profileDataRequired.opens. Matched
// prefixes (and their ancestors) are pinned to literal during deflation so
// rules like R0010 keep working. It is read on every PreSave via Get and
// refreshed out-of-band by an OpenProtectionReloader (in cluster, fed from the
// operator-published ConfigMap); the zero value preserves legacy collapse
// behaviour.
protection *OpenProtectionStore
}

func NewContainerProfileProcessor(cfg config.Config, cleanupHandler *ResourcesCleanupHandler) *ContainerProfileProcessor {
func NewContainerProfileProcessor(cfg config.Config, cleanupHandler *ResourcesCleanupHandler, protection *OpenProtectionStore) *ContainerProfileProcessor {
hostType := cfg.HostType
if hostType == "" {
hostType = armotypes.HostTypeKubernetes
}
if protection == nil {
// Seed from static config when no shared store is injected (e.g. backend
// callers and tests that don't run a reloader).
protection = NewOpenProtectionStore(cfg.ProtectedOpenMatchers)
}
return &ContainerProfileProcessor{
CleanupHandler: cleanupHandler,
CleanupInterval: cfg.CleanupInterval,
Expand All @@ -60,6 +73,28 @@ func NewContainerProfileProcessor(cfg config.Config, cleanupHandler *ResourcesCl
HostType: hostType,
Interval: 30 * time.Second,
MaxContainerProfileSize: cfg.MaxApplicationProfileSize,
protection: protection,
}
}

// ProtectionStore returns the processor's shared open-protection store so callers
// (e.g. main wiring a reloader) can refresh it after construction.
func (a *ContainerProfileProcessor) ProtectionStore() *OpenProtectionStore {
return a.protection
}

// OpenProtectionFromMatchers converts the shared armoapi-go matcher union into
// the analyzer's collapse-protection input. It is the single conversion point
// reused by every environment: in cluster (NewContainerProfileProcessor, from
// config) and the backend (postgres-connector, from rules it loads out of
// MongoDB via armotypes.UnionOpenProtection). Keeping it here means callers only
// need armotypes + this package, not the low-level dynamicpathdetector.
func OpenProtectionFromMatchers(m armotypes.OpenMatchers) dynamicpathdetector.OpenProtection {
return dynamicpathdetector.OpenProtection{
Exact: m.Exact,
Prefix: m.Prefix,
Suffix: m.Suffix,
Contains: m.Contains,
}
}

Expand Down Expand Up @@ -178,7 +213,7 @@ func (a *ContainerProfileProcessor) PreSave(ctx context.Context, object runtime.
} else {
logger.L().Debug("ContainerProfileProcessor.PreSave - failed to get sbom name", loggerhelpers.Error(err), loggerhelpers.String("imageTag", profile.Spec.ImageTag), loggerhelpers.String("imageID", profile.Spec.ImageID))
}
profile.Spec = DeflateContainerProfileSpec(profile.Spec, sbomSet)
profile.Spec = DeflateContainerProfileSpec(profile.Spec, sbomSet, a.protection.Get())
size += len(profile.Spec.Execs)
size += len(profile.Spec.Opens)
size += len(profile.Spec.Syscalls)
Expand Down Expand Up @@ -807,8 +842,27 @@ func (a *ContainerProfileProcessor) getAggregatedData(ctx context.Context, key s
return status, completion, hash
}

func DeflateContainerProfileSpec(container softwarecomposition.ContainerProfileSpec, sbomSet mapset.Set[string]) softwarecomposition.ContainerProfileSpec {
opens, err := dynamicpathdetector.AnalyzeOpens(container.Opens, dynamicpathdetector.NewPathAnalyzer(OpenDynamicThreshold), sbomSet)
// DeflateContainerProfileSpec generalises a profile's high-cardinality fields
// (opens, endpoints, …) so stored profiles stay bounded. openProtection is the
// union of sensitive open matchers that rules depend on (their
// profileDataRequired.opens: exact/prefix/suffix/contains). The open analyzer
// pins the matched prefixes and their ancestors to literal, so they are never
// folded into a wildcard such as /etc/⋯ or /⋯/⋯. That keeps anomaly rules like
// R0010 able to distinguish a never-before-seen sensitive path from a
// generalised one. The matcher set is sourced per-environment (rules CRD
// in-cluster, MongoDB in the backend) but applied here via the same shared
// strategy. Pass a zero OpenProtection to disable protection (legacy behaviour).
func DeflateContainerProfileSpec(container softwarecomposition.ContainerProfileSpec, sbomSet mapset.Set[string], openProtection dynamicpathdetector.OpenProtection) softwarecomposition.ContainerProfileSpec {
var protectedPrefixes []string
if !openProtection.Empty() {
openPaths := make([]string, len(container.Opens))
for i := range container.Opens {
openPaths[i] = container.Opens[i].Path
}
protectedPrefixes = openProtection.ProtectedPrefixes(openPaths)
}
openAnalyzer := dynamicpathdetector.NewPathAnalyzerWithConfigsAndProtection(OpenDynamicThreshold, nil, protectedPrefixes)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Blocker to confirm (profile-size blowback): pinning a sensitive prefix makes its entire ancestor directory uncollapsible, which can flip the profile to TooLarge.

Protecting e.g. /etc/shadow pins /etc to literal — so every distinct file ever opened under /etc now survives as its own entry instead of folding to /etc/⋯. On a chatty workload (or with several rule-protected top-level dirs), that growth feeds straight into the size accumulation in PreSave, and size > MaxContainerProfileSize sets helpers.TooLarge.

If a TooLarge profile is dropped from / not used by anomaly detection downstream, this is a self-defeating outcome: turning on protection for R0010 could disable detection for the whole container — strictly worse than the wildcard it was meant to fix. The Performance section measures time (+0.4 ms) but not the size delta, which is the dimension deflation exists to bound.

Please confirm what TooLarge does downstream, and add a worst-case size figure (e.g. /etc with N literals) next to the timing table. If TooLarge degrades detection, this needs a guard (cap pinned literals per protected dir, or fall back to collapse when a pinned subtree itself exceeds a budget).

opens, err := dynamicpathdetector.AnalyzeOpens(container.Opens, openAnalyzer, sbomSet)
if err != nil {
logger.L().Debug("ContainerProfileProcessor.deflateContainerProfileSpec - falling back to DeflateStringer for opens", loggerhelpers.Error(err))
opens = DeflateStringer(container.Opens)
Expand Down
Loading
Loading