package policyreports import ( "cmp" "slices" "sort" "strings" "sync" "github.com/skyhook-io/radar/pkg/resourceid" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) // Subject identifies the resource a set of Findings applies to. // // Group is the API group of the subject (e.g. "apps" for Deployment, // "" for core/v1 kinds like Pod). Without it, a CRD with the same Kind // as a built-in (or two CRDs sharing a Kind across different groups, // e.g. argoproj.io/Application vs unrelated.io/Application) would // collide on the same index entry and consumers couldn't apply group- // aware RBAC checks against the resulting Issues. type Subject struct { Group string Kind string Namespace string Name string } // SubjectFindings pairs a subject ref with the findings indexed against it. // Returned by Index.All so consumers can enumerate every indexed subject // without needing per-subject lookups. type SubjectFindings struct { Subject Subject Findings []Finding } // MaxIndexedReports is retained for source compatibility. It no longer limits // the index; every report supplied to BuildIndex or Replace is included. // // Deprecated: PolicyReport retention belongs to the cluster and report // producers, not the index. const MaxIndexedReports = 500 // Index maps subject keys ("Group/Kind/namespace/name", group-prefixed // so distinct CRDs sharing a Kind don't collide) to the policy findings // that apply to that subject. It is safe for concurrent read/write: // callers building the index from informer events may swap contents // while other callers serve `FindingsFor` lookups. // // The index is a pure projection of the input reports — it owns no // underlying state and does not refetch. // sourcedFinding is a Finding plus the report API groups it arrived in. // // Provenance is kept BESIDE the finding rather than inside it on purpose. // Finding is the dedup key in dedupeFindings, which exists to collapse the same // result arriving in both report families while a cluster migrates from // wgpolicyk8s.io to openreports.io. A group field inside Finding would change // that key, silently stop the collapse, and double-count every violation on // exactly the mid-migration clusters the dedup was written for. type sourcedFinding struct { Finding Finding // Groups are the report API groups this identical finding was seen in, // sorted and deduped. More than one means both families carry it. Groups []string } type Index struct { mu sync.RWMutex bySubject map[string][]sourcedFinding byPolicy map[string][]PolicyOutcome } // NewIndex returns an empty Index. func NewIndex() *Index { return &Index{ bySubject: make(map[string][]sourcedFinding), byPolicy: make(map[string][]PolicyOutcome), } } // PolicyOutcome pairs a subject with one finding a policy recorded against it. // It is the reverse of the bySubject projection: "which resources did this // policy examine, and what did it decide about each". type PolicyOutcome struct { Subject Subject Finding Finding // Groups are the report API groups this outcome was read from. A caller // authorizing per family filters on these; an outcome seen in both families // carries both, so a caller who can read either may see it. Groups []string } // BuildIndex constructs an Index from a slice of PolicyReport documents // (both namespaced PolicyReport and cluster-scoped ClusterPolicyReport). // Every supplied report is included; retention is owned by the cluster and // the report producers rather than this projection. // // For each report, every entry in `results[]` becomes one Finding per // resource in `results[].resources[]`. When a result has no `resources[]` // (single-target reports), the enclosing `report.scope` is used as the // subject. Reports with neither resources nor a scope contribute no // findings (there is no subject to index by). func BuildIndex(reports []*unstructured.Unstructured) *Index { idx := NewIndex() idx.Replace(reports) return idx } // Replace rebuilds the index in-place from the given reports. Existing // entries are discarded. Used by the live-update path: an informer event // handler re-lists the cache and calls Replace to keep the index fresh. func (i *Index) Replace(reports []*unstructured.Unstructured) { if i == nil { return } next := make(map[string][]sourcedFinding) for _, r := range reports { extractFindings(r, next) } dedupeFindings(next) nextByPolicy := buildPolicyIndex(next) i.mu.Lock() i.bySubject = next i.byPolicy = nextByPolicy i.mu.Unlock() } // buildPolicyIndex inverts the per-subject map into a per-policy one. // // Built here rather than projected per request: the caller reads this on every // policy drawer open and on every SSE-driven refresh, and walking the whole // subject map each time would copy every finding in the cluster to answer a // question about one policy. func buildPolicyIndex(bySubject map[string][]sourcedFinding) map[string][]PolicyOutcome { byPolicy := make(map[string][]PolicyOutcome) for key, findings := range bySubject { group, kind, ns, name, ok := parseSubjectKey(key) if !ok { continue } subject := Subject{Group: group, Kind: kind, Namespace: ns, Name: name} for _, sf := range findings { if sf.Finding.Policy == "" { continue } byPolicy[sf.Finding.Policy] = append(byPolicy[sf.Finding.Policy], PolicyOutcome{Subject: subject, Finding: sf.Finding, Groups: sf.Groups}) } } for policy, outcomes := range byPolicy { slices.SortFunc(outcomes, func(left, right PolicyOutcome) int { return cmp.Or( cmp.Compare(left.Subject.Namespace, right.Subject.Namespace), cmp.Compare(left.Subject.Kind, right.Subject.Kind), cmp.Compare(left.Subject.Name, right.Subject.Name), cmp.Compare(left.Finding.Rule, right.Finding.Rule), cmp.Compare(left.Finding.Result, right.Finding.Result), ) }) byPolicy[policy] = outcomes } return byPolicy } // OutcomesForPolicy returns every subject this policy recorded a finding // against, in stable order. `policy` must match the report's `results[].policy` // value verbatim — Kyverno writes a bare name for cluster-scoped policies and // `namespace/name` for namespaced ones, so callers holding a policy object // should try both shapes. // // The returned slice is a defensive copy. func (i *Index) OutcomesForPolicy(policy string) []PolicyOutcome { if i == nil || policy == "" { return nil } i.mu.RLock() defer i.mu.RUnlock() src := i.byPolicy[policy] if len(src) == 0 { return nil } out := make([]PolicyOutcome, len(src)) copy(out, src) return out } // autogenPrefixes are the rule-name prefixes Kyverno synthesises when it // expands a Pod rule to the controllers that own Pods. The generated rules are // not written by the user and must not be presented as if they were. var autogenPrefixes = []string{"autogen-cronjob-", "autogen-"} // BaseRule strips Kyverno's autogen prefix from a rule name, reporting whether // one was present. `autogen-validate-image-tag` and `validate-image-tag` are // the same authored rule applied at two levels; showing both as peers doubles // the rule list and invites the question of which one is real. func BaseRule(rule string) (base string, autogen bool) { for _, p := range autogenPrefixes { if strings.HasPrefix(rule, p) { return strings.TrimPrefix(rule, p), true } } return rule, false } // dedupeFindings collapses byte-identical findings within each subject. // // A cluster migrating between report APIs (wgpolicyk8s.io → openreports.io) // can serve both families at once, and the caller may legitimately watch both // when both hold data — stale reports in the old family otherwise disappear // from view entirely. The same (policy, rule, result, message, source) on the // same subject carries no extra information whichever report it arrived in, // and counting it twice would inflate every violation total. // Sorting by the complete finding value keeps downstream top-N summaries // stable even though informer stores do not promise report iteration order. func dedupeFindings(bySubject map[string][]sourcedFinding) { for key, findings := range bySubject { if len(findings) < 2 { continue } // Keyed on Finding exactly as before — the collapse must not weaken. // The groups of every duplicate are unioned onto the survivor so // provenance is preserved without entering the key. at := make(map[Finding]int, len(findings)) out := findings[:0] for _, sf := range findings { if idx, ok := at[sf.Finding]; ok { out[idx].Groups = mergeGroups(out[idx].Groups, sf.Groups) continue } at[sf.Finding] = len(out) out = append(out, sf) } slices.SortFunc(out, func(left, right sourcedFinding) int { return cmp.Or( cmp.Compare(left.Finding.Policy, right.Finding.Policy), cmp.Compare(left.Finding.Rule, right.Finding.Rule), cmp.Compare(left.Finding.Result, right.Finding.Result), cmp.Compare(left.Finding.Severity, right.Finding.Severity), cmp.Compare(left.Finding.Category, right.Finding.Category), cmp.Compare(left.Finding.Message, right.Finding.Message), cmp.Compare(left.Finding.Source, right.Finding.Source), ) }) bySubject[key] = out } } // mergeGroups unions two sorted-unique group lists. func mergeGroups(a, b []string) []string { for _, g := range b { if !slices.Contains(a, g) { a = append(a, g) } } slices.Sort(a) return a } // FindingsFor returns the findings indexed for the given subject. Returns // nil if no findings are recorded for that subject. // // Group is the subject's API group ("" for core kinds like Pod). It's // part of the index key so distinct CRDs sharing a Kind don't collide. // // The returned slice is a defensive copy: callers may freely sort, truncate, // or filter it without racing the index's own rebuild path. func (i *Index) FindingsFor(group, kind, namespace, name string) []Finding { if i == nil { return nil } key := subjectKey(group, kind, namespace, name) i.mu.RLock() defer i.mu.RUnlock() src := i.bySubject[key] if len(src) == 0 { return nil } out := make([]Finding, 0, len(src)) for _, sf := range src { out = append(out, sf.Finding) } return out } // SourcedFindingsFor returns the findings for a subject with the report // families each came from. // // The plain FindingsFor drops that provenance, and a caller who drops it cannot // filter on it — which is how the per-resource view came to show a caller // findings from a family they are not authorized to read while the per-policy // view filtered them out. Same index, same subject, two different answers. func (i *Index) SourcedFindingsFor(group, kind, namespace, name string) []PolicyOutcome { if i == nil { return nil } key := subjectKey(group, kind, namespace, name) i.mu.RLock() defer i.mu.RUnlock() src := i.bySubject[key] if len(src) == 0 { return nil } out := make([]PolicyOutcome, 0, len(src)) for _, sf := range src { out = append(out, PolicyOutcome{ Subject: Subject{Group: group, Kind: kind, Namespace: namespace, Name: name}, Finding: sf.Finding, Groups: append([]string(nil), sf.Groups...), }) } return out } // SourcedFindingsForAnyEngine is FindingsForAnyEngine with the provenance kept. // // A caller that both labels its result with an engine name AND authorizes per // report family needs the two filters together: engine alone over-counts once a // second producer is installed, family alone attributes another engine's // findings to Kyverno. The agent-facing surfaces need exactly that pair. func (i *Index) SourcedFindingsForAnyEngine(group, kind, namespace, name string, engines ...Engine) []PolicyOutcome { if len(engines) == 0 { return nil } all := i.SourcedFindingsFor(group, kind, namespace, name) if len(all) == 0 { return nil } want := make(map[Engine]bool, len(engines)) for _, e := range engines { want[e] = true } out := make([]PolicyOutcome, 0, len(all)) for _, o := range all { if want[o.Finding.Engine()] { out = append(out, o) } } return out } // FindingsForEngine returns the findings indexed for the given subject that // were produced by the given engine. Same contract as FindingsFor, // filtered. // // Filtering happens on the normalized engine rather than the raw Source // because one engine emits several source values — asking for // EngineKyverno must return legacy `kyverno` results and modern // `KyvernoValidatingPolicy` results alike. A caller that matched on the raw // string would silently see only part of what Kyverno reported. func (i *Index) FindingsForEngine(group, kind, namespace, name string, engine Engine) []Finding { return i.FindingsForAnyEngine(group, kind, namespace, name, engine) } // FindingsForAnyEngine returns the findings indexed for the given subject that // were produced by any of the given engines. // // Use this over FindingsFor wherever the result is labelled with an engine // name. The index is shared across producers — Kyverno, Trivy, Falco adapters // and VAP evaluation all write into the same report families — so an // unfiltered read presented under one engine's name over-counts the moment a // second engine is installed. // // Passing no engines returns nil. An empty filter means "nothing matches" // rather than "everything", so a caller that accidentally passes an empty set // gets an obviously empty result instead of silently unfiltered data. func (i *Index) FindingsForAnyEngine(group, kind, namespace, name string, engines ...Engine) []Finding { if len(engines) == 0 { return nil } all := i.FindingsFor(group, kind, namespace, name) if len(all) == 0 { return nil } want := make(map[Engine]bool, len(engines)) for _, e := range engines { want[e] = true } out := make([]Finding, 0, len(all)) for _, f := range all { if want[f.Engine()] { out = append(out, f) } } if len(out) == 0 { return nil } return out } // All returns every indexed subject together with its findings. Both the // outer slice and each per-subject Findings slice are defensive copies — // callers may freely sort, truncate, or filter them without racing the // index's rebuild path. Subjects appear in alphabetical key order so the // caller's downstream output is stable regardless of go's map-iteration // randomization. func (i *Index) All() []SubjectFindings { if i == nil { return nil } i.mu.RLock() defer i.mu.RUnlock() if len(i.bySubject) == 0 { return nil } keys := make([]string, 0, len(i.bySubject)) for k := range i.bySubject { keys = append(keys, k) } sort.Strings(keys) out := make([]SubjectFindings, 0, len(keys)) for _, k := range keys { src := i.bySubject[k] if len(src) == 0 { continue } group, kind, ns, name, ok := parseSubjectKey(k) if !ok { continue } fs := make([]Finding, 0, len(src)) for _, sf := range src { fs = append(fs, sf.Finding) } out = append(out, SubjectFindings{ Subject: Subject{Group: group, Kind: kind, Namespace: ns, Name: name}, Findings: fs, }) } return out } // subjectKey is the index key format: "group/Kind/namespace/name", with // an empty group segment for core kinds (Pod, Service, ...) and an empty // namespace segment for cluster-scoped subjects (Node, ClusterRole, ...). // Group must come first because both "group" and "namespace" can be // empty independently; encoding group last would make a cluster-scoped // CRD ("apps.example.io/Foo//bar") indistinguishable from a namespaced // core-group subject under a parse that allowed three-part keys. func subjectKey(group, kind, namespace, name string) string { return group + "/" + kind + "/" + namespace + "/" + name } // parseSubjectKey reverses subjectKey. Returns ok=false for any key that // does not have exactly four slash-delimited parts. K8s groups, names, // namespaces, and kinds can't contain '/' (DNS-label / DNS-subdomain // rules), so the SplitN is unambiguous in practice. func parseSubjectKey(key string) (group, kind, namespace, name string, ok bool) { parts := strings.SplitN(key, "/", 4) if len(parts) != 4 { return "", "", "", "", false } // Kind and name must be non-empty; group and namespace may be empty // (core-group + cluster-scoped subjects respectively). if parts[1] == "" || parts[3] == "" { return "", "", "", "", false } return parts[0], parts[1], parts[2], parts[3], true } // Size returns the number of distinct subjects with at least one indexed // finding. Useful for diagnostics and tests. func (i *Index) Size() int { if i == nil { return 0 } i.mu.RLock() defer i.mu.RUnlock() return len(i.bySubject) } // extractFindings appends one Finding per (result, resource) pair into the // destination map. The map's keys are subjectKey values; the helper // centralizes the shape so callers don't have to know about the // PolicyReport schema. // // Schema reference (https://github.com/kubernetes-sigs/wg-policy-prototypes): // // apiVersion: wgpolicyk8s.io/v1alpha2 // kind: PolicyReport // metadata: { ... } // scope: { apiVersion, kind, namespace, name, uid } # optional // results: // - policy: string // rule: string // result: pass|fail|warn|error|skip // severity: info|low|medium|high|critical // category: string // message: string // source: string # producer type, not engine // resources: // - apiVersion, kind, namespace, name, uid # optional, can be [] // ... func extractFindings(report *unstructured.Unstructured, dst map[string][]sourcedFinding) { if report == nil { return } scopeGroup, scopeKind, scopeNS, scopeName := reportScope(report) // The report's OWN group — which family it came from — not the subject's. reportGroup := resourceid.GroupFromAPIVersion(report.GetAPIVersion()) rawResults, found, err := unstructured.NestedFieldNoCopy(report.Object, "results") if err != nil || !found { return } results, ok := rawResults.([]any) if !ok { return } reportNamespace := report.GetNamespace() // for ClusterPolicyReport, "". for _, raw := range results { entry, ok := raw.(map[string]any) if !ok { continue } f := Finding{ Policy: stringField(entry, "policy"), Rule: stringField(entry, "rule"), Result: stringField(entry, "result"), Severity: stringField(entry, "severity"), Category: stringField(entry, "category"), Message: stringField(entry, "message"), Source: stringField(entry, "source"), } subjects, hasResources := resultResources(entry) // Pre-filter to subjects that can actually be indexed. A non-empty // resources[] slice of empty objects (e.g. `resources: [{}]` from // a malformed CRD) would otherwise skip scope fallback below AND // then get filtered to nothing in the index loop — silently // dropping a finding that the report's scope could have rescued. validSubjects := make([]subjectRef, 0, len(subjects)) for _, s := range subjects { if s.kind != "" && s.name != "" { validSubjects = append(validSubjects, s) } } if !hasResources || len(validSubjects) == 0 { // Scope-only report (or all subjects malformed): the report // itself is bound to one subject via `report.scope`. The // PolicyReport namespace overrides the scope's namespace when // scope.namespace is unset (some engines emit only kind/name // in scope for namespaced reports and rely on metadata.namespace). if scopeKind != "" && scopeName != "" { ns := scopeNS if ns == "" { ns = reportNamespace } key := subjectKey(scopeGroup, scopeKind, ns, scopeName) dst[key] = append(dst[key], sourcedFinding{Finding: f, Groups: []string{reportGroup}}) } continue } for _, s := range validSubjects { ns := s.namespace // Namespaced PolicyReports default subject namespace to the // report's namespace when not set on the resource ref — this // mirrors how Kyverno emits namespaced reports. if ns == "" { ns = reportNamespace } key := subjectKey(s.group, s.kind, ns, s.name) dst[key] = append(dst[key], sourcedFinding{Finding: f, Groups: []string{reportGroup}}) } } } // reportScope returns the `report.scope` subject as (group, kind, // namespace, name). All four are empty strings when scope is missing — // the caller treats that case as "no scope-only fallback available". // Group is derived from `scope.apiVersion` (the part before "/"; "" for // core kinds like Pod whose apiVersion is just "v1"). func reportScope(report *unstructured.Unstructured) (group, kind, namespace, name string) { rawScope, found, err := unstructured.NestedFieldNoCopy(report.Object, "scope") if err != nil || !found { return "", "", "", "" } scope, ok := rawScope.(map[string]any) if !ok { return "", "", "", "" } return resourceid.GroupFromAPIVersion(stringField(scope, "apiVersion")), stringField(scope, "kind"), stringField(scope, "namespace"), stringField(scope, "name") } type subjectRef struct { group string kind string namespace string name string } // resultResources reads `results[].resources[]` into subjectRefs. Returns // (refs, true) when the `resources` key was present at all (even if empty), // (nil, false) when the key was absent — the caller distinguishes // "explicitly no resources" (empty slice) from "scope-only report" (key // absent / single-target) so the scope fallback only fires in the latter. // // In practice both engines we've observed (Kyverno, Trivy) either emit // `resources` populated or omit it entirely when the report is scope-only, // so we treat empty-but-present the same as scope-only as well — this is // the more useful behavior and matches operator intent. func resultResources(entry map[string]any) ([]subjectRef, bool) { raw, ok := entry["resources"] if !ok || raw == nil { return nil, false } list, ok := raw.([]any) if !ok { return nil, false } refs := make([]subjectRef, 0, len(list)) for _, item := range list { m, ok := item.(map[string]any) if !ok { continue } refs = append(refs, subjectRef{ group: resourceid.GroupFromAPIVersion(stringField(m, "apiVersion")), kind: stringField(m, "kind"), namespace: stringField(m, "namespace"), name: stringField(m, "name"), }) } return refs, true } func stringField(m map[string]any, key string) string { v, ok := m[key] if !ok || v == nil { return "" } s, _ := v.(string) return s }