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
80 changes: 67 additions & 13 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,23 +191,71 @@ These environment variables are read directly (not through config file):
{
"exporters": {
"httpExporterConfig": {
"url": "https://api.example.com/v1/runtimealerts",
"headers": {
"Authorization": "Bearer <token>"
},
"url": "https://api.example.com",
"headers": [
{"key": "Authorization", "value": "Bearer <token>"}
],
"timeoutSeconds": 5,
"method": "POST"
"method": "POST",
"maxAlertsPerMinute": 100,
"eventFieldFilter": {
"denyList": ["spec.processTree", "spec.cloudMetadata"]
}
}
}
}
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `exporters::httpExporterConfig::url` | string | - | HTTP endpoint URL |
| `exporters::httpExporterConfig::headers` | map | `{}` | HTTP headers |
| `exporters::httpExporterConfig::timeoutSeconds` | int | `5` | Request timeout |
| `exporters::httpExporterConfig::method` | string | `POST` | HTTP method |
| `exporters::httpExporterConfig::url` | string | - | HTTP endpoint URL (required) |
| `exporters::httpExporterConfig::path` | string | `/v1/runtimealerts` | Path appended to URL |
| `exporters::httpExporterConfig::headers` | []`{key, value}` | `[]` | HTTP request headers |
| `exporters::httpExporterConfig::queryParams` | []`{key, value}` | `[]` | URL query parameters; set value to `"<env>"` to read from env var |
| `exporters::httpExporterConfig::timeoutSeconds` | int | `5` | Request timeout in seconds |
| `exporters::httpExporterConfig::method` | string | `POST` | HTTP method (`POST` or `PUT`) |
| `exporters::httpExporterConfig::maxAlertsPerMinute` | int | `100` | Rate limit; sends a limit-reached alert when exceeded |
| `exporters::httpExporterConfig::eventFieldFilter` | object | - | Field-level filter applied to every outgoing payload (see below) |

##### Event Field Filter

Controls which fields are included in the JSON payload sent to the HTTP endpoint. Useful for reducing payload size or stripping sensitive data before it leaves the cluster.

> **Scope:** the filter is applied at the HTTP transport layer and affects all payloads sent through this exporter, including runtime alerts, malware alerts, and FIM events. If you need different filtering per payload type, use separate exporter instances with different configs.

```json
{
"exporters": {
"httpExporterConfig": {
"url": "https://api.example.com",
"eventFieldFilter": {
"denyList": ["spec.processTree", "spec.cloudMetadata"]
}
}
}
}
```

| Key | Type | Description |
|-----|------|-------------|
| `eventFieldFilter::allowList` | []string | Keep only these fields; all others are dropped. Takes precedence over `denyList`. |
| `eventFieldFilter::denyList` | []string | Remove these fields; all others are kept. Ignored if `allowList` is set. |

Both lists support dot notation for nested fields (e.g. `spec.processTree`) and fields inside slices (e.g. `spec.alerts.severity`). If neither list is set, the filter is disabled and the full payload is sent.

**DenyList example** — strip process tree and cloud metadata to reduce payload size:
```json
"eventFieldFilter": {
"denyList": ["spec.processTree", "spec.cloudMetadata"]
}
```

**AllowList example** — send only the fields your backend needs:
```json
"eventFieldFilter": {
"allowList": ["kind", "apiVersion", "metadata", "spec.alerts"]
}
```

#### AlertManager Exporter

Expand Down Expand Up @@ -395,7 +443,10 @@ These flags disable specific tracers (useful for debugging):
"networkServiceEnabled": true,
"exporters": {
"httpExporterConfig": {
"url": "https://kubescape-backend.example.com/v1/runtimealerts"
"url": "https://kubescape-backend.example.com",
"eventFieldFilter": {
"denyList": ["spec.processTree", "spec.cloudMetadata"]
}
}
}
}
Expand Down Expand Up @@ -425,10 +476,13 @@ These flags disable specific tracers (useful for debugging):
},
"exporters": {
"httpExporterConfig": {
"url": "https://api.example.com/v1/runtimealerts",
"url": "https://api.example.com",
"enableAlertBulking": true,
"bulkMaxAlerts": 100,
"bulkTimeoutSeconds": 5
"bulkTimeoutSeconds": 5,
"eventFieldFilter": {
"denyList": ["spec.processTree", "spec.cloudMetadata"]
}
},
"alertManagerExporterUrls": [
"alertmanager.monitoring.svc.cluster.local:9093"
Expand Down Expand Up @@ -475,7 +529,7 @@ These flags disable specific tracers (useful for debugging):
},
"exporters": {
"httpExporterConfig": {
"url": "https://api.example.com/v1/runtimealerts",
"url": "https://api.example.com",
"enableAlertBulking": true,
"bulkMaxAlerts": 200,
"bulkTimeoutSeconds": 3,
Expand Down
151 changes: 151 additions & 0 deletions pkg/exporters/field_filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package exporters

import (
"bytes"
"encoding/json"
"fmt"
"strings"
)

// EventFieldFilterConfig configures field-level filtering for exported alert events.
// AllowList takes precedence over DenyList. Both support dot notation (e.g. "spec.processTree").
type EventFieldFilterConfig struct {
AllowList []string `json:"allowList,omitempty" mapstructure:"allowList"`
DenyList []string `json:"denyList,omitempty" mapstructure:"denyList"`
}

// EventFieldFilter applies allow/deny list filtering to JSON payloads.
type EventFieldFilter struct {
allowSet map[string]struct{}
denySet map[string]struct{}
useAllow bool
}

// NewEventFieldFilter creates a filter from config. Returns nil if no fields are configured.
func NewEventFieldFilter(config *EventFieldFilterConfig) *EventFieldFilter {
if config == nil {
return nil
}
if len(config.AllowList) == 0 && len(config.DenyList) == 0 {
return nil
}

f := &EventFieldFilter{}
if len(config.AllowList) > 0 {
f.useAllow = true
f.allowSet = make(map[string]struct{}, len(config.AllowList))
for _, field := range config.AllowList {
f.allowSet[field] = struct{}{}
}
} else {
f.denySet = make(map[string]struct{}, len(config.DenyList))
for _, field := range config.DenyList {
f.denySet[field] = struct{}{}
}
}
return f
}

// FilterJSON applies the field filter to marshaled JSON bytes and returns filtered bytes.
func (f *EventFieldFilter) FilterJSON(data []byte) ([]byte, error) {
var m map[string]any
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
if err := dec.Decode(&m); err != nil {
return nil, fmt.Errorf("field filter: failed to unmarshal: %w", err)
}

if f.useAllow {
m = applyAllowList(m, f.allowSet)
} else {
for key := range f.denySet {
parts := strings.SplitN(key, ".", 2)
if len(parts) == 1 {
delete(m, key)
} else {
removePath(m, parts[0], parts[1])
}
}
}

var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(m); err != nil {
return nil, fmt.Errorf("field filter: failed to marshal: %w", err)
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}

func applyAllowList(m map[string]any, allowSet map[string]struct{}) map[string]any {
groups := make(map[string]map[string]struct{})
for path := range allowSet {
parts := strings.SplitN(path, ".", 2)
topKey := parts[0]
if _, ok := groups[topKey]; !ok {
groups[topKey] = make(map[string]struct{})
}
if len(parts) > 1 {
groups[topKey][parts[1]] = struct{}{}
}
}

result := make(map[string]any)
for topKey, subSet := range groups {
val, exists := m[topKey]
if !exists {
continue
}
if _, ok := allowSet[topKey]; ok {
result[topKey] = val
continue
}
if len(subSet) > 0 {
if nested, ok := val.(map[string]any); ok {
result[topKey] = applyAllowList(nested, subSet)
} else if slice, ok := val.([]any); ok {
newSlice := make([]any, 0, len(slice))
for _, item := range slice {
if itemMap, ok := item.(map[string]any); ok {
filtered := applyAllowList(itemMap, subSet)
if len(filtered) > 0 {
newSlice = append(newSlice, filtered)
}
} else {
newSlice = append(newSlice, item)
}
}
result[topKey] = newSlice
} else {
// scalar value (string, number, bool) — keep it as-is
result[topKey] = val
}
Comment thread
RohanKaran marked this conversation as resolved.
}
}
return result
}

func removePath(m map[string]any, topKey, rest string) {
val, exists := m[topKey]
if !exists {
return
}
parts := strings.SplitN(rest, ".", 2)
if nested, ok := val.(map[string]any); ok {
if len(parts) == 1 {
delete(nested, rest)
} else {
removePath(nested, parts[0], parts[1])
}
} else if slice, ok := val.([]any); ok {
for _, item := range slice {
if itemMap, ok := item.(map[string]any); ok {
if len(parts) == 1 {
delete(itemMap, rest)
} else {
removePath(itemMap, parts[0], parts[1])
}
}
}
}
}
Loading