Ctrl K
ComunidadGuías…

pkg/policyreports/index.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}