Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4b4bfe6
feat(policy): add proto fields for audit log custom resources
Sep 30, 2026
248220b
feat(compliance): collect audit events for requested API resources
Sep 30, 2026
141f0e3
feat(policy): add Kubernetes API Resource audit log policy criterion
Sep 30, 2026
c8addb7
feat(sensor): send API resources from audit log policies to compliance
Sep 30, 2026
114d49f
feat(alerts): raise CUSTOM resource alerts for API resource audit events
Sep 30, 2026
4880f33
feat(ui): add Kubernetes API resource audit log policy criterion
Sep 30, 2026
af4f865
feat(qa): e2e test for audit log policies on API resources
Sep 30, 2026
46b38ce
feat(ui): clarify plural resource names for Kubernetes API resource
Sep 30, 2026
ab1d154
fix(ui): name API resource in audit log missing-resource error
Sep 30, 2026
6e4512f
docs: add doc comments to functions touched for API resource audit po…
Sep 30, 2026
d0e0552
Merge branch 'master' into mzwennes/audit-log-custom-resources
zwennesm Sep 30, 2026
4d12333
fix: surface API resource in notifiers and dashboard, reject empty value
Oct 1, 2026
4e4d2aa
fix(ui): drop singular-name note from API resource helper text
Oct 6, 2026
8589bfa
Merge remote-tracking branch 'fork/mzwennes/audit-log-custom-resource…
Oct 6, 2026
3dcd3cc
Merge branch 'master' into mzwennes/audit-log-custom-resources
zwennesm Oct 6, 2026
447d76c
fix(ui): reject whitespace in Kubernetes API resource
Oct 6, 2026
bd15a47
Merge remote-tracking branch 'fork/mzwennes/audit-log-custom-resource…
Oct 6, 2026
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
Prev Previous commit
Next Next commit
feat(policy): add Kubernetes API Resource audit log policy criterion
Audit log sections need either Kubernetes Resource or Kubernetes API
Resource, not both. Built-in resources, regexes and negation are
rejected. Central rejects the field while the flag is disabled.
  • Loading branch information
Martijn Zwennes
Martijn Zwennes committed Sep 30, 2026
commit 141f0e3c0108c88bcaf393b12a502cd118e39b7d
5 changes: 5 additions & 0 deletions central/policy/service/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ func (s *policyValidator) validateEventSource(policy *storage.Policy) error {
return errors.New("event source must not be set for build or deploy time policies")
}

if !features.AuditLogCustomResources.Enabled() && booleanpolicy.ContainsValueWithFieldName(policy, fieldnames.KubeAPIResource) {
// Such a policy can still be provided via the API (JSON import or CR) with the feature flag disabled.
return fmt.Errorf("%s is disabled, policy criteria %q is unavailable", features.AuditLogCustomResources.EnvVar(), fieldnames.KubeAPIResource)
}

if s.isAuditEventPolicy(policy) {
if len(policy.GetEnforcementActions()) != 0 {
return errors.New("enforcement actions are not applicable for runtime policies with audit log as the event source")
Expand Down
19 changes: 19 additions & 0 deletions central/policy/service/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1445,3 +1445,22 @@ func (s *PolicyValidatorTestSuite) TestValidateEvaluationFilter() {
})
}
}

func (s *PolicyValidatorTestSuite) TestValidateAuditEventSourceAPIResourceFeatureFlag() {
policy := booleanPolicyWithFields(storage.LifecycleStage_RUNTIME, storage.EventSource_AUDIT_LOG_EVENT, map[string]string{
fieldnames.KubeAPIResource: "applications.argoproj.io",
fieldnames.KubeAPIVerb: "PATCH",
})
enumPolicy := booleanPolicyWithFields(storage.LifecycleStage_RUNTIME, storage.EventSource_AUDIT_LOG_EVENT, map[string]string{
fieldnames.KubeResource: "SECRETS",
fieldnames.KubeAPIVerb: "PATCH",
})

s.T().Setenv(features.AuditLogCustomResources.EnvVar(), "false")
s.Error(s.validator.validateEventSource(policy))
s.NoError(s.validator.validateEventSource(enumPolicy))

s.T().Setenv(features.AuditLogCustomResources.EnvVar(), "true")
s.NoError(s.validator.validateEventSource(policy))
s.NoError(s.validator.validateEventSource(enumPolicy))
}
1 change: 1 addition & 0 deletions pkg/booleanpolicy/augmentedobjs/custom_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const (
HasEgressPolicyCustomTag = "Has Egress Network Policy"
NotInNetworkBaselineCustomTag = "Not In Network Baseline"
NotInProcessBaselineCustomTag = "Not In Baseline"
KubernetesAPIResourceCustomTag = "Kubernetes API Resource"
KubernetesAPIVerbCustomTag = "Kubernetes API Verb"
KubernetesResourceCustomTag = "Kubernetes Resource"
KubernetesResourceNameCustomTag = "Kubernetes Resource Name"
Expand Down
9 changes: 9 additions & 0 deletions pkg/booleanpolicy/field_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,15 @@ func initializeFieldMetadata() FieldMetadata {
negationForbidden,
)

f.registerFieldMetadata(fieldnames.KubeAPIResource,
querybuilders.ForFieldLabel(augmentedobjs.KubernetesAPIResourceCustomTag),
nil,
validateAuditEventAPIResource,
[]storage.EventSource{storage.EventSource_AUDIT_LOG_EVENT},
[]RuntimeFieldType{AuditLogEvent},
negationForbidden,
)

f.registerFieldMetadataRegex(fieldnames.KubeResource,
querybuilders.ForFieldLabel(augmentedobjs.KubernetesResourceCustomTag),
nil,
Expand Down
1 change: 1 addition & 0 deletions pkg/booleanpolicy/fieldnames/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ var (
ImageUser = newFieldName("Image User")
IsImpersonatedUser = newFieldName("Is Impersonated User")
KubeResource = newFieldName("Kubernetes Resource")
KubeAPIResource = newFieldName("Kubernetes API Resource")
KubeAPIVerb = newFieldName("Kubernetes API Verb")
KubeResourceName = newFieldName("Kubernetes Resource Name")
KubeUserName = newFieldName("Kubernetes User Name")
Expand Down
84 changes: 69 additions & 15 deletions pkg/booleanpolicy/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,50 @@ var (
),
fieldnames.KubeUserName: set.NewStringSet(
fieldnames.KubeResource,
fieldnames.KubeAPIResource,
),
fieldnames.KubeUserGroups: set.NewStringSet(
fieldnames.KubeResource,
fieldnames.KubeAPIResource,
),
}

// eventSourceRequirements defines the minimum required fields for a
// given event source.
eventSourceRequirements = map[storage.EventSource]set.StringSet{
storage.EventSource_AUDIT_LOG_EVENT: set.NewStringSet(
fieldnames.KubeResource,
fieldnames.KubeAPIVerb,
),
// given event source. Each entry is a set of alternatives of which at
// least one field must be present.
eventSourceRequirements = map[storage.EventSource][]set.StringSet{
storage.EventSource_AUDIT_LOG_EVENT: {
set.NewStringSet(fieldnames.KubeResource, fieldnames.KubeAPIResource),
set.NewStringSet(fieldnames.KubeAPIVerb),
},
// FileAccess fields are currently the only ones supported for
// node events. In the future, when more node events are supported,
// this constraint can be relaxed.
storage.EventSource_NODE_EVENT: set.NewStringSet(
fieldnames.FilePath,
),
storage.EventSource_NODE_EVENT: {
set.NewStringSet(fieldnames.FilePath),
},
}

// mutuallyExclusiveFields defines fields that cannot be used together
// in the same policy section.
mutuallyExclusiveFields = [][2]string{
// An audit event either refers to a built-in resource or to an API
// resource, so a section with both could never match.
{fieldnames.KubeResource, fieldnames.KubeAPIResource},
}

// builtInAuditLogResources are the plural resource names covered by the
// Kubernetes Resource field for audit log events. The Kubernetes API
// Resource field cannot refer to these.
builtInAuditLogResources = set.NewFrozenStringSet(
"secrets",
"configmaps",
"clusterroles",
"clusterrolebindings",
"networkpolicies",
"securitycontextconstraints",
"egressfirewalls",
)
)

type validateConfiguration struct {
Expand Down Expand Up @@ -186,6 +210,10 @@ func validatePolicySection(s *storage.PolicySection, configuration *validateConf
errorList.AddError(err)
}

if err := validateMutuallyExclusiveFields(s, &seenFields); err != nil {
errorList.AddError(err)
}

return errorList.ToError()
}

Expand All @@ -208,17 +236,43 @@ func validateFieldDependencies(s *storage.PolicySection, seenFields *set.StringS
func validateEventSourceRequirements(s *storage.PolicySection, seenFields *set.StringSet, eventSource storage.EventSource) error {
errorList := errorhelpers.NewErrorList(fmt.Sprintf("validating event source requirements for %s", s.GetSectionName()))

for es, requiredFields := range eventSourceRequirements {
if eventSource != es {
for _, alternatives := range eventSourceRequirements[eventSource] {
if alternatives.Intersects(*seenFields) {
continue
}
if alternatives.Cardinality() == 1 {
errorList.AddStringf("%q policies require field %q", eventSource, alternatives.GetArbitraryElem())
} else {
errorList.AddStringf("%q policies require one of fields %q", eventSource, alternatives.AsSortedSlice(func(a, b string) bool { return a < b }))
}
}

for required := range requiredFields {
if !seenFields.Contains(required) {
errorList.AddStringf("%q policies require field %q", eventSource, required)
}
return errorList.ToError()
}

// validateMutuallyExclusiveFields validates that a policy section does not
// contain fields that cannot be used together.
func validateMutuallyExclusiveFields(s *storage.PolicySection, seenFields *set.StringSet) error {
errorList := errorhelpers.NewErrorList(fmt.Sprintf("validating mutually exclusive fields for %q", s.GetSectionName()))

for _, fields := range mutuallyExclusiveFields {
if seenFields.Contains(fields[0]) && seenFields.Contains(fields[1]) {
errorList.AddStringf("policy sections cannot contain both %q and %q", fields[0], fields[1])
}
}

return errorList.ToError()
}

// validateAuditEventAPIResource validates a "<plural>[.<group>]" value of the
// Kubernetes API Resource field.
func validateAuditEventAPIResource(_ *validateConfiguration, value string) (bool, error) {
if !auditEventAPIResourceValueRegex.MatchString(value) {
return false, fmt.Errorf("must be of the form <plural>[.<group>] and match %q", auditEventAPIResourceValueRegex.String())
}
plural, _, _ := strings.Cut(value, ".")
if builtInAuditLogResources.Contains(plural) {
return false, fmt.Errorf("%q is covered by the %q field", plural, fieldnames.KubeResource)
}
return true, nil
}
113 changes: 113 additions & 0 deletions pkg/booleanpolicy/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -759,3 +759,116 @@ func (s *PolicyValueValidator) TestValidateFilePath() {
})
}
}

func auditLogPolicyWithGroups(groups ...*storage.PolicyGroup) *storage.Policy {
return &storage.Policy{
Name: "audit-log-policy",
LifecycleStages: []storage.LifecycleStage{storage.LifecycleStage_RUNTIME},
EventSource: storage.EventSource_AUDIT_LOG_EVENT,
PolicyVersion: policyversion.CurrentVersion().String(),
PolicySections: []*storage.PolicySection{{PolicyGroups: groups}},
}
}

func policyGroup(fieldName string, values ...string) *storage.PolicyGroup {
group := &storage.PolicyGroup{FieldName: fieldName}
for _, v := range values {
group.Values = append(group.Values, &storage.PolicyValue{Value: v})
}
return group
}

func (s *PolicyValueValidator) TestValidateKubeAPIResourceForAuditEventSource() {
cases := map[string]struct {
groups []*storage.PolicyGroup
errExpected bool
}{
"API resource with group and verb is valid": {
groups: []*storage.PolicyGroup{
policyGroup(fieldnames.KubeAPIResource, "applications.argoproj.io"),
policyGroup(fieldnames.KubeAPIVerb, "PATCH", "UPDATE"),
},
},
"core API resource is valid": {
groups: []*storage.PolicyGroup{
policyGroup(fieldnames.KubeAPIResource, "limitranges"),
policyGroup(fieldnames.KubeAPIVerb, "DELETE"),
},
},
"API resource satisfies dependency of user name": {
groups: []*storage.PolicyGroup{
policyGroup(fieldnames.KubeAPIResource, "applications.argoproj.io"),
policyGroup(fieldnames.KubeAPIVerb, "PATCH"),
{FieldName: fieldnames.KubeUserName, Negate: true, Values: []*storage.PolicyValue{{Value: "system:serviceaccount:openshift-gitops:openshift-gitops-argocd-application-controller"}}},
},
},
"API resource without verb is invalid": {
groups: []*storage.PolicyGroup{
policyGroup(fieldnames.KubeAPIResource, "applications.argoproj.io"),
},
errExpected: true,
},
"API resource together with Kubernetes Resource is invalid": {
groups: []*storage.PolicyGroup{
policyGroup(fieldnames.KubeResource, "SECRETS"),
policyGroup(fieldnames.KubeAPIResource, "applications.argoproj.io"),
policyGroup(fieldnames.KubeAPIVerb, "PATCH"),
},
errExpected: true,
},
"built-in resource as API resource is invalid": {
groups: []*storage.PolicyGroup{
policyGroup(fieldnames.KubeAPIResource, "secrets"),
policyGroup(fieldnames.KubeAPIVerb, "PATCH"),
},
errExpected: true,
},
"built-in resource with group as API resource is invalid": {
groups: []*storage.PolicyGroup{
policyGroup(fieldnames.KubeAPIResource, "clusterroles.rbac.authorization.k8s.io"),
policyGroup(fieldnames.KubeAPIVerb, "PATCH"),
},
errExpected: true,
},
"regex API resource is invalid": {
groups: []*storage.PolicyGroup{
policyGroup(fieldnames.KubeAPIResource, "r/.*"),
policyGroup(fieldnames.KubeAPIVerb, "PATCH"),
},
errExpected: true,
},
"upper case API resource is invalid": {
groups: []*storage.PolicyGroup{
policyGroup(fieldnames.KubeAPIResource, "Applications.argoproj.io"),
policyGroup(fieldnames.KubeAPIVerb, "PATCH"),
},
errExpected: true,
},
"negated API resource is invalid": {
groups: []*storage.PolicyGroup{
{FieldName: fieldnames.KubeAPIResource, Negate: true, Values: []*storage.PolicyValue{{Value: "applications.argoproj.io"}}},
policyGroup(fieldnames.KubeAPIVerb, "PATCH"),
},
errExpected: true,
},
}
for name, c := range cases {
s.Run(name, func() {
err := Validate(auditLogPolicyWithGroups(c.groups...), ValidateSourceIsAuditLogEvents())
if c.errExpected {
s.Error(err)
} else {
s.NoError(err)
}
})
}
}

func (s *PolicyValueValidator) TestValidateKubeAPIResourceNotSupportedForDeploymentEventSource() {
policy := auditLogPolicyWithGroups(
policyGroup(fieldnames.KubeAPIResource, "applications.argoproj.io"),
policyGroup(fieldnames.KubeAPIVerb, "PATCH"),
)
policy.EventSource = storage.EventSource_DEPLOYMENT_EVENT
s.Error(Validate(policy))
}
10 changes: 6 additions & 4 deletions pkg/booleanpolicy/value_regex.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ var (
severityValueRegex = createRegex(`(<|>|<=|>=)?[[:space:]]*(?i:UNKNOWN|LOW|MODERATE|IMPORTANT|CRITICAL)`)
auditEventAPIVerbValueRegex = createRegex(`(?i:CREATE|DELETE|GET|PATCH|UPDATE)`)
auditEventResourceValueRegex = createRegex(`(?i:SECRETS|CONFIGMAPS|CLUSTER_ROLES|CLUSTER_ROLE_BINDINGS|NETWORK_POLICIES|SECURITY_CONTEXT_CONSTRAINTS|EGRESS_FIREWALLS)`)
kubernetesNameRegex = createRegex(`(?i:[a-z0-9])(?i:[-:a-z0-9]*[a-z0-9])?`)
ipAddressValueRegex = createRegex(fmt.Sprintf(`(%s)|(%s)`, ipv4Regex, ipv6Regex))
signatureIntegrationIDValueRegex = createRegex(regexp.QuoteMeta(signatures.SignatureIntegrationIDPrefix) + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
fileOperationRegex = createRegex(`(?i:OPEN|CREATE|RENAME|UNLINK|OWNERSHIP_CHANGE|PERMISSION_CHANGE|XATTR_CHANGE)`)
// auditEventAPIResourceValueRegex matches "<plural>[.<group>]", e.g. "limitranges" or "applications.argoproj.io".
auditEventAPIResourceValueRegex = createRegex(`[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*`)
kubernetesNameRegex = createRegex(`(?i:[a-z0-9])(?i:[-:a-z0-9]*[a-z0-9])?`)
ipAddressValueRegex = createRegex(fmt.Sprintf(`(%s)|(%s)`, ipv4Regex, ipv6Regex))
signatureIntegrationIDValueRegex = createRegex(regexp.QuoteMeta(signatures.SignatureIntegrationIDPrefix) + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
fileOperationRegex = createRegex(`(?i:OPEN|CREATE|RENAME|UNLINK|OWNERSHIP_CHANGE|PERMISSION_CHANGE|XATTR_CHANGE)`)
)

func createRegex(s string) *regexp.Regexp {
Expand Down
62 changes: 62 additions & 0 deletions pkg/detection/runtime/detector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,65 @@ func (s *RuntimeDetectorTestSuite) getCreateConfigmapPolicy() *storage.Policy {
EventSource: storage.EventSource_AUDIT_LOG_EVENT,
}
}

func (s *RuntimeDetectorTestSuite) TestAPIResourcePolicy() {
policySet := detection.NewPolicySet(nil, nil)

policy := &storage.Policy{
Id: "7b0a2f3e-1c3f-4f5a-9d0b-6a1e2f3c4d5e",
PolicyVersion: "1.1",
Name: "ArgoCD Application changed outside of GitOps",
Severity: storage.Severity_HIGH_SEVERITY,
Categories: []string{"Kubernetes Events"},
PolicySections: []*storage.PolicySection{
{
SectionName: "section 1",
PolicyGroups: []*storage.PolicyGroup{
{
FieldName: "Kubernetes API Resource",
Values: []*storage.PolicyValue{{Value: "applications.argoproj.io"}},
},
{
FieldName: "Kubernetes API Verb",
Values: []*storage.PolicyValue{{Value: "PATCH"}, {Value: "UPDATE"}},
},
{
FieldName: "Kubernetes User Name",
Negate: true,
Values: []*storage.PolicyValue{{Value: "system:serviceaccount:openshift-gitops:gitops-controller"}},
},
},
},
},
LifecycleStages: []storage.LifecycleStage{storage.LifecycleStage_RUNTIME},
EventSource: storage.EventSource_AUDIT_LOG_EVENT,
}
s.NoError(policySet.UpsertPolicy(policy), "upsert policy should succeed")

d := NewDetector(policySet)

kubeEvent := s.getKubeEvent(storage.KubernetesEvent_Object_UNKNOWN, storage.KubernetesEvent_PATCH, "cluster-id", "openshift-gitops", "my-app", false)
kubeEvent.Object.ApiGroup = "argoproj.io"
kubeEvent.Object.ApiResource = "applications.argoproj.io"

alerts, err := d.DetectForAuditEvents(context.Background(), []*storage.KubernetesEvent{kubeEvent})
s.NoError(err)
s.Len(alerts, 1, "change by a user should alert")

kubeEvent.User.Username = "system:serviceaccount:openshift-gitops:gitops-controller"
alerts, err = d.DetectForAuditEvents(context.Background(), []*storage.KubernetesEvent{kubeEvent})
s.NoError(err)
s.Empty(alerts, "change by the GitOps controller should not alert")

kubeEvent.User.Username = "username"
kubeEvent.Object.ApiGroup = "app.k8s.io"
kubeEvent.Object.ApiResource = "applications.app.k8s.io"
alerts, err = d.DetectForAuditEvents(context.Background(), []*storage.KubernetesEvent{kubeEvent})
s.NoError(err)
s.Empty(alerts, "resource in another API group should not alert")

kubeEvent = s.getKubeEvent(storage.KubernetesEvent_Object_CONFIGMAPS, storage.KubernetesEvent_PATCH, "cluster-id", "openshift-gitops", "my-app", false)
alerts, err = d.DetectForAuditEvents(context.Background(), []*storage.KubernetesEvent{kubeEvent})
s.NoError(err)
s.Empty(alerts, "built-in resource should not alert")
}