Skip to content

Commit 80426b3

Browse files
authored
Merge pull request #169 from Tomgrinds777/fix/container-level-exceptions
feat(exceptions): support container-level exceptions via containerName attribute
2 parents be0b5a6 + 020001e commit 80426b3

4 files changed

Lines changed: 545 additions & 15 deletions

File tree

exceptions/comparator.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,41 @@ func (c *comparator) compareCluster(designatorCluster, clusterName string) bool
104104
return designatorCluster != "" && c.regexCompare(designatorCluster, clusterName)
105105
}
106106

107+
// compareContainerName reports whether containerName matches any of the
108+
// failing containers. If failingNames is provided, only those containers are
109+
// checked; otherwise every container and init-container in the workload is
110+
// inspected (used by callers that have no FailedPaths context).
111+
func (c *comparator) compareContainerName(workload workloadinterface.IMetadata, containerName string, failingNames []string) bool {
112+
if len(failingNames) > 0 {
113+
for _, name := range failingNames {
114+
if c.regexCompare(containerName, name) {
115+
return true
116+
}
117+
}
118+
return false
119+
}
120+
121+
wl := workloadinterface.NewWorkloadObj(workload.GetObject())
122+
123+
if containers, err := wl.GetContainers(); err == nil {
124+
for i := range containers {
125+
if c.regexCompare(containerName, containers[i].Name) {
126+
return true
127+
}
128+
}
129+
}
130+
131+
if initContainers, err := wl.GetInitContainers(); err == nil {
132+
for i := range initContainers {
133+
if c.regexCompare(containerName, initContainers[i].Name) {
134+
return true
135+
}
136+
}
137+
}
138+
139+
return false
140+
}
141+
107142
// regexpCompareI performs a case-insensitive regexp match
108143
func (c *comparator) regexCompareI(reg, name string) bool {
109144
var (

exceptions/comparator_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,88 @@ func TestComparator_comparePath(t *testing.T) {
256256
}
257257
}
258258

259+
func podObject(containers, initContainers []string) map[string]interface{} {
260+
cs := make([]interface{}, len(containers))
261+
for i, name := range containers {
262+
cs[i] = map[string]interface{}{"name": name}
263+
}
264+
ics := make([]interface{}, len(initContainers))
265+
for i, name := range initContainers {
266+
ics[i] = map[string]interface{}{"name": name}
267+
}
268+
return map[string]interface{}{
269+
"apiVersion": "v1",
270+
"kind": "Pod",
271+
"metadata": map[string]interface{}{"name": "test-pod", "namespace": "default"},
272+
"spec": map[string]interface{}{
273+
"containers": cs,
274+
"initContainers": ics,
275+
},
276+
}
277+
}
278+
279+
func TestComparator_compareContainerName(t *testing.T) {
280+
c := &comparator{}
281+
282+
tests := []struct {
283+
name string
284+
workload workloadinterface.IMetadata
285+
containerName string
286+
expected bool
287+
}{
288+
{
289+
name: "exact match on regular container",
290+
workload: workloadinterface.NewWorkloadObj(podObject([]string{"app", "sidecar"}, nil)),
291+
containerName: "app",
292+
expected: true,
293+
},
294+
{
295+
name: "exact match on init container",
296+
workload: workloadinterface.NewWorkloadObj(podObject([]string{"app"}, []string{"init-setup"})),
297+
containerName: "init-setup",
298+
expected: true,
299+
},
300+
{
301+
name: "regex wildcard matches container",
302+
workload: workloadinterface.NewWorkloadObj(podObject([]string{"proxy-envoy"}, nil)),
303+
containerName: "proxy-.*",
304+
expected: true,
305+
},
306+
{
307+
name: "no container name match",
308+
workload: workloadinterface.NewWorkloadObj(podObject([]string{"app"}, nil)),
309+
containerName: "other",
310+
expected: false,
311+
},
312+
{
313+
name: "no containers at all",
314+
workload: workloadinterface.NewWorkloadObj(podObject(nil, nil)),
315+
containerName: "app",
316+
expected: false,
317+
},
318+
}
319+
320+
for _, tt := range tests {
321+
t.Run(tt.name, func(t *testing.T) {
322+
// nil failingNames → full workload scan (backward-compat path)
323+
assert.Equal(t, tt.expected, c.compareContainerName(tt.workload, tt.containerName, nil))
324+
})
325+
}
326+
}
327+
328+
func TestComparator_compareContainerName_failingNamesFilter(t *testing.T) {
329+
c := &comparator{}
330+
wl := workloadinterface.NewWorkloadObj(podObject([]string{"app", "sidecar"}, nil))
331+
332+
// When failingNames is ["app"], only "app" is a valid match.
333+
assert.True(t, c.compareContainerName(wl, "app", []string{"app"}))
334+
assert.False(t, c.compareContainerName(wl, "sidecar", []string{"app"}))
335+
336+
// When failingNames is ["sidecar"], only "sidecar" is a valid match.
337+
assert.True(t, c.compareContainerName(wl, "sidecar", []string{"sidecar"}))
338+
assert.False(t, c.compareContainerName(wl, "app", []string{"sidecar"}))
339+
}
340+
259341
func TestIsTypeWorkload(t *testing.T) {
260342
tests := []struct {
261343
workload workloadinterface.IMetadata

exceptions/exceptionprocessor.go

Lines changed: 126 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package exceptions
22

33
import (
4+
"regexp"
5+
"strconv"
46
"strings"
57

68
"github.com/armosec/armoapi-go/identifiers"
@@ -12,6 +14,10 @@ import (
1214
"github.com/armosec/armoapi-go/armotypes"
1315
)
1416

17+
// rexContainerPath matches "containers[N]" and "initContainers[N]" in a
18+
// FailedPath so we can resolve the container index to a name.
19+
var rexContainerPath = regexp.MustCompile(`(initC|c)ontainers\[(\d+)\]`)
20+
1521
// Processor processes exceptions.
1622
type Processor struct {
1723
*comparator
@@ -59,7 +65,11 @@ func (p *Processor) SetRuleResponsExceptions(results []reporthandling.RuleRespon
5965
}
6066

6167
for w := range workloads {
62-
if exceptions := p.GetResourceExceptions(ruleExceptions, workloads[w], clusterName); len(exceptions) > 0 {
68+
// Resolve which containers actually produced the finding so that a
69+
// containerName exception is only applied when the excepted container
70+
// is the one that failed, not just any container in the pod.
71+
failingContainerNames := extractFailingContainerNames(results[i].FailedPaths, workloads[w])
72+
if exceptions := p.getResourceExceptions(ruleExceptions, workloads[w], clusterName, failingContainerNames); len(exceptions) > 0 {
6373
results[i].Exception = &exceptions[0]
6474
}
6575
}
@@ -135,15 +145,21 @@ func alertObjectToWorkloads(obj *reporthandling.AlertObject) []workloadinterface
135145
return resources[:len(resources):len(resources)]
136146
}
137147

138-
// GetResourceException get exceptions of single resource
148+
// GetResourceExceptions returns the exception policies that match workload.
149+
// It checks container membership across the whole workload; use
150+
// SetRuleResponsExceptions when FailedPaths are available for precise matching.
139151
func (p *Processor) GetResourceExceptions(ruleExceptions []armotypes.PostureExceptionPolicy, workload workloadinterface.IMetadata, clusterName string) []armotypes.PostureExceptionPolicy {
152+
return p.getResourceExceptions(ruleExceptions, workload, clusterName, nil)
153+
}
154+
155+
func (p *Processor) getResourceExceptions(ruleExceptions []armotypes.PostureExceptionPolicy, workload workloadinterface.IMetadata, clusterName string, failingContainerNames []string) []armotypes.PostureExceptionPolicy {
140156
// no pre-allocation since most of the time it's empty or has only one element
141157
var postureExceptionPolicy []armotypes.PostureExceptionPolicy
142158

143159
for _, ruleException := range ruleExceptions {
144160
for _, resourceToPin := range ruleException.Resources {
145161
resource := resourceToPin
146-
if p.hasException(clusterName, &resource, workload) {
162+
if p.hasException(clusterName, &resource, workload, failingContainerNames) {
147163
postureExceptionPolicy = append(postureExceptionPolicy, ruleException)
148164
}
149165
}
@@ -185,8 +201,7 @@ func (p *Processor) matchesCluster(attributes identifiers.AttributesDesignators,
185201
return p.compareCluster(cluster, clusterName)
186202
}
187203

188-
// compareMetadata - compare namespace and kind
189-
func (p *Processor) hasException(clusterName string, designator *identifiers.PortalDesignator, workload workloadinterface.IMetadata) bool {
204+
func (p *Processor) hasException(clusterName string, designator *identifiers.PortalDesignator, workload workloadinterface.IMetadata, failingContainerNames []string) bool {
190205
attributes := p.getAttributes(designator)
191206

192207
if attributes.GetCluster() == "" && attributes.GetNamespace() == "" && attributes.GetKind() == "" && attributes.GetName() == "" && attributes.GetResourceID() == "" && attributes.GetPath() == "" && len(attributes.GetLabels()) == 0 {
@@ -198,16 +213,23 @@ func (p *Processor) hasException(clusterName string, designator *identifiers.Por
198213
}
199214

200215
if isTypeRegoResponseVector(workload) {
201-
if p.iterateRegoResponseVector(workload, attributes) {
216+
if p.iterateRegoResponseVector(workload, attributes, failingContainerNames) {
202217
return true
203218
}
219+
// If containerName is in the designator, stop here: the base
220+
// RegoResponseVector object is not a workload, so container membership
221+
// cannot be verified on it. Falling through would silently skip the
222+
// container check and produce false positives.
223+
if _, ok := attributes.GetLabels()[identifiers.AttributeContainerName]; ok {
224+
return false
225+
}
204226
// otherwise, continue to check the base object
205227
}
206-
return p.metadataHasException(workload, attributes)
228+
return p.metadataHasException(workload, attributes, failingContainerNames)
207229

208230
}
209231

210-
func (p *Processor) metadataHasException(workload workloadinterface.IMetadata, attributes identifiers.AttributesDesignators) bool {
232+
func (p *Processor) metadataHasException(workload workloadinterface.IMetadata, attributes identifiers.AttributesDesignators, failingContainerNames []string) bool {
211233

212234
if attributes.GetNamespace() != "" && !p.compareNamespace(workload, attributes.GetNamespace()) {
213235
return false // namespaces do not match
@@ -229,20 +251,111 @@ func (p *Processor) metadataHasException(workload workloadinterface.IMetadata, a
229251
return false // paths do not match
230252
}
231253

232-
if isTypeWorkload(workload) && len(attributes.GetLabels()) > 0 {
233-
if !p.compareLabels(workload, attributes.GetLabels()) && !p.compareAnnotations(workload, attributes.GetLabels()) {
234-
return false // labels nor annotations do not match
254+
if isTypeWorkload(workload) {
255+
allLabels := attributes.GetLabels()
256+
containerName, hasContainerName := allLabels[identifiers.AttributeContainerName]
257+
258+
// Build a label map with containerName stripped out so it is not
259+
// treated as a Kubernetes label during label/annotation comparison.
260+
labelsWithoutContainer := allLabels
261+
if hasContainerName {
262+
labelsWithoutContainer = make(map[string]string, len(allLabels)-1)
263+
for k, v := range allLabels {
264+
if k != identifiers.AttributeContainerName {
265+
labelsWithoutContainer[k] = v
266+
}
267+
}
268+
}
269+
270+
if len(labelsWithoutContainer) > 0 {
271+
if !p.compareLabels(workload, labelsWithoutContainer) && !p.compareAnnotations(workload, labelsWithoutContainer) {
272+
return false // labels nor annotations do not match
273+
}
274+
}
275+
276+
if hasContainerName && !p.compareContainerName(workload, containerName, failingContainerNames) {
277+
return false // container name does not match
235278
}
236279
}
280+
237281
return true
238282
}
239283

240-
func (p *Processor) iterateRegoResponseVector(workload workloadinterface.IMetadata, attributes identifiers.AttributesDesignators) bool {
284+
func (p *Processor) iterateRegoResponseVector(workload workloadinterface.IMetadata, attributes identifiers.AttributesDesignators, failingContainerNames []string) bool {
241285
v := objectsenvelopes.NewRegoResponseVectorObject(workload.GetObject())
242286
for _, r := range v.GetRelatedObjects() {
243-
if p.metadataHasException(r, attributes) {
287+
if p.metadataHasException(r, attributes, failingContainerNames) {
244288
return true
245289
}
246290
}
247291
return false
248292
}
293+
294+
// extractFailingContainerNames parses paths like "spec.containers[0].…" or
295+
// "spec.template.spec.initContainers[1].…" to find which containers produced
296+
// the finding, then returns their names from the workload spec. When the
297+
// FailedPaths contain no container indices (e.g. pod-level findings) the
298+
// returned slice is nil and compareContainerName falls back to checking all
299+
// containers in the workload.
300+
//
301+
// For RegoResponseVector objects the vector itself carries no containers; the
302+
// containers live in the related objects. We recurse into each related workload
303+
// so that container-index resolution still works for vector-based findings.
304+
func extractFailingContainerNames(paths []string, workload workloadinterface.IMetadata) []string {
305+
if len(paths) == 0 {
306+
return nil
307+
}
308+
309+
if isTypeRegoResponseVector(workload) {
310+
v := objectsenvelopes.NewRegoResponseVectorObject(workload.GetObject())
311+
seen := make(map[string]struct{})
312+
for _, r := range v.GetRelatedObjects() {
313+
for _, name := range extractFailingContainerNames(paths, r) {
314+
seen[name] = struct{}{}
315+
}
316+
}
317+
if len(seen) == 0 {
318+
return nil
319+
}
320+
names := make([]string, 0, len(seen))
321+
for name := range seen {
322+
names = append(names, name)
323+
}
324+
return names
325+
}
326+
327+
wl := workloadinterface.NewWorkloadObj(workload.GetObject())
328+
containers, _ := wl.GetContainers()
329+
initContainers, _ := wl.GetInitContainers()
330+
if len(containers)+len(initContainers) == 0 {
331+
return nil
332+
}
333+
334+
seen := make(map[string]struct{})
335+
for _, path := range paths {
336+
for _, m := range rexContainerPath.FindAllStringSubmatch(path, -1) {
337+
idx, err := strconv.Atoi(m[2])
338+
if err != nil {
339+
continue
340+
}
341+
if m[1] == "initC" {
342+
if idx < len(initContainers) {
343+
seen[initContainers[idx].Name] = struct{}{}
344+
}
345+
} else {
346+
if idx < len(containers) {
347+
seen[containers[idx].Name] = struct{}{}
348+
}
349+
}
350+
}
351+
}
352+
353+
if len(seen) == 0 {
354+
return nil
355+
}
356+
names := make([]string, 0, len(seen))
357+
for name := range seen {
358+
names = append(names, name)
359+
}
360+
return names
361+
}

0 commit comments

Comments
 (0)