• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kubescape / opa-utils / 28532708076

01 Jul 2026 04:33PM UTC coverage: 69.228% (+0.1%) from 69.079%
28532708076

Pull #176

github

web-flow
Merge 8a464e3e0 into 24269e955
Pull Request #176: [ LFX 2026] feat(exceptions): evaluate PostureExceptionPolicy.ObjectSelector via labels.Selector

37 of 37 new or added lines in 1 file covered. (100.0%)

11 existing lines in 1 file now uncovered.

3316 of 4790 relevant lines covered (69.23%)

119.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.4
/exceptions/exceptionprocessor.go
1
package exceptions
2

3
import (
4
        "regexp"
5
        "strconv"
6
        "strings"
7

8
        "github.com/armosec/armoapi-go/identifiers"
9

10
        "github.com/kubescape/k8s-interface/workloadinterface"
11
        "github.com/kubescape/opa-utils/objectsenvelopes"
12
        "github.com/kubescape/opa-utils/reporthandling"
13

14
        "github.com/armosec/armoapi-go/armotypes"
15
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16
        "k8s.io/apimachinery/pkg/labels"
17
)
18

19
// rexContainerPath matches "containers[N]" and "initContainers[N]" in a
20
// FailedPath so we can resolve the container index to a name.
21
var rexContainerPath = regexp.MustCompile(`(initC|c)ontainers\[(\d+)\]`)
22

23
// Processor processes exceptions.
24
type Processor struct {
25
        *comparator
26
        designatorCache *designatorCache
27
}
28

29
func NewProcessor() *Processor {
14✔
30
        return &Processor{
14✔
31
                comparator:      newComparator(),
14✔
32
                designatorCache: newDesignatorCache(),
14✔
33
        }
14✔
34
}
14✔
35

36
// SetFrameworkExceptions add exceptions to framework report
37
func (p *Processor) SetFrameworkExceptions(frameworkReport *reporthandling.FrameworkReport, exceptionsPolicies []armotypes.PostureExceptionPolicy, clusterName string) {
×
38
        for c := range frameworkReport.ControlReports {
×
39
                p.SetControlExceptions(&frameworkReport.ControlReports[c], exceptionsPolicies, clusterName, frameworkReport.Name)
×
40
        }
×
41
}
42

43
// SetControlExceptions add exceptions to control report
44
func (p *Processor) SetControlExceptions(controlReport *reporthandling.ControlReport, exceptionsPolicies []armotypes.PostureExceptionPolicy, clusterName, frameworkName string) {
×
45
        for r := range controlReport.RuleReports {
×
46
                p.SetRuleExceptions(&controlReport.RuleReports[r], exceptionsPolicies, clusterName, frameworkName, controlReport.ControlID)
×
47
        }
×
48
}
49

50
// SetRuleExceptions add exceptions to rule report
51
func (p *Processor) SetRuleExceptions(ruleReport *reporthandling.RuleReport, exceptionsPolicies []armotypes.PostureExceptionPolicy, clusterName, frameworkName, controlID string) {
×
52
        // adding exceptions to the rules
×
53
        ruleExceptions := p.ListRuleExceptions(exceptionsPolicies, frameworkName, controlID, ruleReport.Name)
×
54
        p.SetRuleResponsExceptions(ruleReport.RuleResponses, ruleExceptions, clusterName)
×
55
}
×
56

57
// SetRuleExceptions add exceptions to rule respons structure
58
func (p *Processor) SetRuleResponsExceptions(results []reporthandling.RuleResponse, ruleExceptions []armotypes.PostureExceptionPolicy, clusterName string) {
8✔
59
        if len(ruleExceptions) == 0 {
8✔
60
                return
×
61
        }
×
62

63
        for i := range results {
16✔
64
                workloads := alertObjectToWorkloads(&results[i].AlertObject)
8✔
65
                if len(workloads) == 0 {
8✔
66
                        continue
×
67
                }
68

69
                for w := range workloads {
16✔
70
                        // Resolve which containers actually produced the finding so that a
8✔
71
                        // containerName exception is only applied when the excepted container
8✔
72
                        // is the one that failed, not just any container in the pod.
8✔
73
                        failingContainerNames := extractFailingContainerNames(results[i].FailedPaths, workloads[w])
8✔
74
                        if exceptions := p.getResourceExceptions(ruleExceptions, workloads[w], clusterName, failingContainerNames); len(exceptions) > 0 {
13✔
75
                                results[i].Exception = &exceptions[0]
5✔
76
                        }
5✔
77
                }
78

79
                results[i].RuleStatus = results[i].GetStatus()
8✔
80
        }
81
}
82

83
func (p *Processor) ListRuleExceptions(exceptionPolicies []armotypes.PostureExceptionPolicy, frameworkName, controlID, ruleName string) []armotypes.PostureExceptionPolicy {
11✔
84
        ruleExceptions := make([]armotypes.PostureExceptionPolicy, 0, len(exceptionPolicies))
11✔
85

11✔
86
        for i := range exceptionPolicies {
22✔
87
                if p.ruleHasExceptions(&exceptionPolicies[i], frameworkName, controlID, ruleName) {
19✔
88
                        ruleExceptions = append(ruleExceptions, exceptionPolicies[i])
8✔
89
                }
8✔
90
        }
91

92
        return ruleExceptions[:len(ruleExceptions):len(ruleExceptions)]
11✔
93

94
}
95

96
func (p *Processor) ruleHasExceptions(exceptionPolicy *armotypes.PostureExceptionPolicy, frameworkName, controlID, ruleName string) bool {
11✔
97
        if len(exceptionPolicy.PosturePolicies) == 0 {
12✔
98
                return true // empty policy -> apply all
1✔
99
        }
1✔
100

101
        for _, posturePolicy := range exceptionPolicy.PosturePolicies {
20✔
102
                if posturePolicy.FrameworkName == "" && posturePolicy.ControlID == "" && posturePolicy.RuleName == "" {
10✔
103
                        return true // empty policy -> apply all
×
104
                }
×
105
                if posturePolicy.FrameworkName != "" && frameworkName != "" && !(strings.EqualFold(posturePolicy.FrameworkName, frameworkName) || p.regexCompareI(posturePolicy.FrameworkName, frameworkName)) {
12✔
106
                        continue // policy does not match
2✔
107
                }
108
                if posturePolicy.ControlID != "" && controlID != "" && !(strings.EqualFold(posturePolicy.ControlID, controlID) || p.regexCompareI(posturePolicy.ControlID, controlID)) {
8✔
109
                        continue // policy does not match
×
110
                }
111
                if posturePolicy.RuleName != "" && ruleName != "" && !(strings.EqualFold(posturePolicy.RuleName, ruleName) || p.regexCompareI(posturePolicy.RuleName, ruleName)) {
9✔
112
                        continue // policy does not match
1✔
113
                }
114

115
                return true // policies match
7✔
116
        }
117

118
        return false
3✔
119

120
}
121

122
func alertObjectToWorkloads(obj *reporthandling.AlertObject) []workloadinterface.IMetadata {
8✔
123
        resources := make([]workloadinterface.IMetadata, 0, len(obj.K8SApiObjects)+1)
8✔
124

8✔
125
        for i := range obj.K8SApiObjects {
14✔
126
                r := objectsenvelopes.NewObject(obj.K8SApiObjects[i])
6✔
127
                if r == nil {
6✔
128
                        continue
×
129
                }
130

131
                resources = append(resources, r)
6✔
132
                /*
133
                        ns : = r.GetNamespace()
134
                        if ns != "" {
135
                                // TODO - handle empty namespace
136
                        }
137
                */
138
        }
139

140
        if obj.ExternalObjects != nil {
10✔
141
                if r := objectsenvelopes.NewObject(obj.ExternalObjects); r != nil {
4✔
142
                        // TODO - What about linked objects?
2✔
143
                        resources = append(resources, r)
2✔
144
                }
2✔
145
        }
146

147
        return resources[:len(resources):len(resources)]
8✔
148
}
149

150
// GetResourceExceptions returns the exception policies that match workload.
151
// It checks container membership across the whole workload; use
152
// SetRuleResponsExceptions when FailedPaths are available for precise matching.
153
func (p *Processor) GetResourceExceptions(ruleExceptions []armotypes.PostureExceptionPolicy, workload workloadinterface.IMetadata, clusterName string) []armotypes.PostureExceptionPolicy {
25✔
154
        return p.getResourceExceptions(ruleExceptions, workload, clusterName, nil)
25✔
155
}
25✔
156

157
func (p *Processor) getResourceExceptions(ruleExceptions []armotypes.PostureExceptionPolicy, workload workloadinterface.IMetadata, clusterName string, failingContainerNames []string) []armotypes.PostureExceptionPolicy {
33✔
158
        // no pre-allocation since most of the time it's empty or has only one element
33✔
159
        var postureExceptionPolicy []armotypes.PostureExceptionPolicy
33✔
160

33✔
161
        for _, ruleException := range ruleExceptions {
66✔
162
                // objectSelector is an additional, policy-level workload-matching axis. It is
33✔
163
                // parsed once per exception here and threaded down into metadataHasException so
33✔
164
                // it is evaluated against the *same* object as the designators — including each
33✔
165
                // related object of a RegoResponseVector — and ANDed with them. A single object
33✔
166
                // must satisfy both the designator and the selector for the exception to apply.
33✔
167
                selector, ok := parseObjectSelector(&ruleException)
33✔
168
                if !ok {
34✔
169
                        continue // malformed selector: the exception matches nothing
1✔
170
                }
171
                for _, resourceToPin := range ruleException.Resources {
64✔
172
                        resource := resourceToPin
32✔
173
                        if p.hasException(clusterName, &resource, workload, failingContainerNames, selector) {
50✔
174
                                postureExceptionPolicy = append(postureExceptionPolicy, ruleException)
18✔
175
                        }
18✔
176
                }
177
        }
178

179
        return postureExceptionPolicy
33✔
180
}
181

182
// parseObjectSelector converts an exception's ObjectSelector into a labels.Selector
183
// for evaluation against a workload's labels. It returns:
184
//
185
//   - (nil, true)      when there is no label constraint — a nil OR a non-nil but
186
//     empty selector. A nil result tells callers to skip the label axis entirely.
187
//   - (selector, true) for a valid, non-empty constraint.
188
//   - (nil, false)     for a malformed selector; the exception then matches nothing,
189
//     never match-all.
190
//
191
// The nil/empty guard is deliberate, and the nil case is load-bearing:
192
// metav1.LabelSelectorAsSelector(nil) yields labels.Nothing(), which would silently
193
// disable every selector-less exception (the common case — cloud exceptions and
194
// posture-only CRDs). An empty selector yields labels.Everything(); collapsing it to
195
// the same "no constraint" nil is equivalent under the AND with the designators, and
196
// keeps the intent explicit rather than relying on Everything() matching.
197
//
198
// A malformed selector is unreachable from a CRD (the apiserver/CEL validate the
199
// LabelSelector shape) and unset on cloud exceptions, so it is treated as a defensive
200
// match-nothing here rather than logged; the consumer that decodes the CRD is the
201
// right layer to surface a bad selector, once per resource instead of once per workload.
202
func parseObjectSelector(exceptionPolicy *armotypes.PostureExceptionPolicy) (labels.Selector, bool) {
35✔
203
        sel := exceptionPolicy.ObjectSelector.ToMetaV1()
35✔
204
        if sel == nil || (len(sel.MatchLabels) == 0 && len(sel.MatchExpressions) == 0) {
55✔
205
                return nil, true // no label constraint
20✔
206
        }
20✔
207

208
        selector, err := metav1.LabelSelectorAsSelector(sel)
15✔
209
        if err != nil {
16✔
210
                return nil, false // malformed selector: do not degrade into match-all
1✔
211
        }
1✔
212
        return selector, true
14✔
213
}
214

215
// RegexCompareControlID reports whether pattern case-insensitively matches target.
216
func (p *Processor) RegexCompareControlID(pattern, target string) bool {
×
217
        return p.regexCompareI(pattern, target)
×
218
}
×
219

220
// MatchesCluster reports whether the designator's cluster constraint matches clusterName.
221
// A nil designator or empty cluster field matches any cluster.
222
func (p *Processor) MatchesCluster(designator *identifiers.PortalDesignator, clusterName string) bool {
×
223
        if designator == nil {
×
224
                return true
×
225
        }
×
226
        return p.matchesCluster(p.getAttributes(designator), clusterName)
×
227
}
228

229
// getAttributes returns digested attributes, using the cache when available.
230
func (p *Processor) getAttributes(designator *identifiers.PortalDesignator) identifiers.AttributesDesignators {
66✔
231
        if attrs, ok := p.designatorCache.Get(designator); ok {
96✔
232
                return attrs
30✔
233
        }
30✔
234
        attrs := designator.DigestPortalDesignator()
36✔
235
        p.designatorCache.Set(designator, attrs)
36✔
236
        return attrs
36✔
237
}
238

239
// matchesCluster checks the cluster constraint against pre-digested attributes.
240
func (p *Processor) matchesCluster(attributes identifiers.AttributesDesignators, clusterName string) bool {
65✔
241
        cluster := attributes.GetCluster()
65✔
242
        if cluster == "" {
124✔
243
                return true
59✔
244
        }
59✔
245
        return p.compareCluster(cluster, clusterName)
6✔
246
}
247

248
func (p *Processor) hasException(clusterName string, designator *identifiers.PortalDesignator, workload workloadinterface.IMetadata, failingContainerNames []string, selector labels.Selector) bool {
66✔
249
        attributes := p.getAttributes(designator)
66✔
250

66✔
251
        if attributes.GetCluster() == "" && attributes.GetNamespace() == "" && attributes.GetKind() == "" && attributes.GetName() == "" && attributes.GetResourceID() == "" && attributes.GetPath() == "" && len(attributes.GetLabels()) == 0 {
67✔
252
                return false // if designators are empty
1✔
253
        }
1✔
254

255
        if !p.matchesCluster(attributes, clusterName) {
66✔
256
                return false // cluster name does not match
1✔
257
        }
1✔
258

259
        if isTypeRegoResponseVector(workload) {
80✔
260
                if p.iterateRegoResponseVector(workload, attributes, failingContainerNames, selector) {
21✔
261
                        return true
5✔
262
                }
5✔
263
                // If containerName is in the designator, stop here: the base
264
                // RegoResponseVector object is not a workload, so container membership
265
                // cannot be verified on it. Falling through would silently skip the
266
                // container check and produce false positives.
267
                if _, ok := attributes.GetLabels()[identifiers.AttributeContainerName]; ok {
14✔
268
                        return false
3✔
269
                }
3✔
270
                // otherwise, continue to check the base object. A non-empty objectSelector
271
                // will not match the label-less base envelope, so an exception whose selector
272
                // matched only a related object (never the base) is correctly not applied here
273
                // — consistent with the same-object AND enforced in metadataHasException.
274
        }
275
        return p.metadataHasException(workload, attributes, failingContainerNames, selector)
56✔
276

277
}
278

279
func (p *Processor) metadataHasException(workload workloadinterface.IMetadata, attributes identifiers.AttributesDesignators, failingContainerNames []string, selector labels.Selector) bool {
93✔
280

93✔
281
        if attributes.GetNamespace() != "" && !p.compareNamespace(workload, attributes.GetNamespace()) {
97✔
282
                return false // namespaces do not match
4✔
283
        }
4✔
284

285
        if attributes.GetKind() != "" && !p.compareKind(workload, attributes.GetKind()) {
96✔
286
                return false // kinds do not match
7✔
287
        }
7✔
288

289
        if attributes.GetName() != "" && !p.compareName(workload, attributes.GetName()) {
90✔
290
                return false // names do not match
8✔
291
        }
8✔
292

293
        if attributes.GetResourceID() != "" && !p.compareResourceID(workload, attributes.GetResourceID()) {
75✔
294
                return false // names do not match
1✔
295
        }
1✔
296

297
        if attributes.GetPath() != "" && !p.comparePath(workload, attributes.GetPath()) {
74✔
298
                return false // paths do not match
1✔
299
        }
1✔
300

301
        // objectSelector (when present) is ANDed with the designator and evaluated against
302
        // this exact object's labels — so for a RegoResponseVector it is checked per related
303
        // object, the same object the designator above is checked against. Unlike the regex
304
        // label path below, the selector matches labels only (not annotations), per the
305
        // SecurityException spec ("selects workloads by their labels").
306
        if selector != nil {
86✔
307
                objLabels := labels.Set(workloadinterface.NewWorkloadObj(workload.GetObject()).GetLabels())
14✔
308
                if !selector.Matches(objLabels) {
22✔
309
                        return false // objectSelector does not match this object's labels
8✔
310
                }
8✔
311
        }
312

313
        if isTypeWorkload(workload) {
117✔
314
                allLabels := attributes.GetLabels()
53✔
315
                containerName, hasContainerName := allLabels[identifiers.AttributeContainerName]
53✔
316

53✔
317
                // Build a label map with containerName stripped out so it is not
53✔
318
                // treated as a Kubernetes label during label/annotation comparison.
53✔
319
                labelsWithoutContainer := allLabels
53✔
320
                if hasContainerName {
69✔
321
                        labelsWithoutContainer = make(map[string]string, len(allLabels)-1)
16✔
322
                        for k, v := range allLabels {
32✔
323
                                if k != identifiers.AttributeContainerName {
16✔
UNCOV
324
                                        labelsWithoutContainer[k] = v
×
UNCOV
325
                                }
×
326
                        }
327
                }
328

329
                if len(labelsWithoutContainer) > 0 {
80✔
330
                        if !p.compareLabels(workload, labelsWithoutContainer) && !p.compareAnnotations(workload, labelsWithoutContainer) {
41✔
331
                                return false // labels nor annotations do not match
14✔
332
                        }
14✔
333
                }
334

335
                if hasContainerName && !p.compareContainerName(workload, containerName, failingContainerNames) {
45✔
336
                        return false // container name does not match
6✔
337
                }
6✔
338
        }
339

340
        return true
44✔
341
}
342

343
func (p *Processor) iterateRegoResponseVector(workload workloadinterface.IMetadata, attributes identifiers.AttributesDesignators, failingContainerNames []string, selector labels.Selector) bool {
22✔
344
        v := objectsenvelopes.NewRegoResponseVectorObject(workload.GetObject())
22✔
345
        for _, r := range v.GetRelatedObjects() {
53✔
346
                if p.metadataHasException(r, attributes, failingContainerNames, selector) {
40✔
347
                        return true
9✔
348
                }
9✔
349
        }
350
        return false
13✔
351
}
352

353
// extractFailingContainerNames parses paths like "spec.containers[0].…" or
354
// "spec.template.spec.initContainers[1].…" to find which containers produced
355
// the finding, then returns their names from the workload spec. When the
356
// FailedPaths contain no container indices (e.g. pod-level findings) the
357
// returned slice is nil and compareContainerName falls back to checking all
358
// containers in the workload.
359
//
360
// For RegoResponseVector objects the vector itself carries no containers; the
361
// containers live in the related objects. We recurse into each related workload
362
// so that container-index resolution still works for vector-based findings.
363
func extractFailingContainerNames(paths []string, workload workloadinterface.IMetadata) []string {
12✔
364
        if len(paths) == 0 {
14✔
365
                return nil
2✔
366
        }
2✔
367

368
        if isTypeRegoResponseVector(workload) {
14✔
369
                v := objectsenvelopes.NewRegoResponseVectorObject(workload.GetObject())
4✔
370
                seen := make(map[string]struct{})
4✔
371
                for _, r := range v.GetRelatedObjects() {
8✔
372
                        for _, name := range extractFailingContainerNames(paths, r) {
8✔
373
                                seen[name] = struct{}{}
4✔
374
                        }
4✔
375
                }
376
                if len(seen) == 0 {
4✔
UNCOV
377
                        return nil
×
UNCOV
378
                }
×
379
                names := make([]string, 0, len(seen))
4✔
380
                for name := range seen {
8✔
381
                        names = append(names, name)
4✔
382
                }
4✔
383
                return names
4✔
384
        }
385

386
        wl := workloadinterface.NewWorkloadObj(workload.GetObject())
6✔
387
        containers, _ := wl.GetContainers()
6✔
388
        initContainers, _ := wl.GetInitContainers()
6✔
389
        if len(containers)+len(initContainers) == 0 {
6✔
UNCOV
390
                return nil
×
UNCOV
391
        }
×
392

393
        seen := make(map[string]struct{})
6✔
394
        for _, path := range paths {
12✔
395
                for _, m := range rexContainerPath.FindAllStringSubmatch(path, -1) {
12✔
396
                        idx, err := strconv.Atoi(m[2])
6✔
397
                        if err != nil {
6✔
398
                                continue
×
399
                        }
400
                        if m[1] == "initC" {
6✔
UNCOV
401
                                if idx < len(initContainers) {
×
UNCOV
402
                                        seen[initContainers[idx].Name] = struct{}{}
×
UNCOV
403
                                }
×
404
                        } else {
6✔
405
                                if idx < len(containers) {
12✔
406
                                        seen[containers[idx].Name] = struct{}{}
6✔
407
                                }
6✔
408
                        }
409
                }
410
        }
411

412
        if len(seen) == 0 {
6✔
UNCOV
413
                return nil
×
UNCOV
414
        }
×
415
        names := make([]string, 0, len(seen))
6✔
416
        for name := range seen {
12✔
417
                names = append(names, name)
6✔
418
        }
6✔
419
        return names
6✔
420
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc