package checks import ( "context" "encoding/base64" "errors" "strings" "time" "k8s.io/pod-security-admission/policy" "github.com/sophotechlabs/spinoza/internal/api" ) var ErrNoSuchCheck = errors.New("no check goes by that name") const ( severityHigh = api.SeverityHigh severityMedium = api.SeverityMedium severityLow = api.SeverityLow categorySecurity = "security" categoryReliability = "reliability" categoryEfficiency = "efficiency" pssBaseline = "PSS baseline" pssRestricted = "PSS restricted" nsaCisa = "NSA/CISA" cisBenchmark = "CIS Kubernetes Benchmark" noUsage = "metrics-server did not answer, so usage is unknown" notEveryKind = "not audited: this decides what nothing references, " + "which is only true once every kind has been read" readsPart = "Nothing among the kinds this read names it. It read every custom resource, " + "the workload, config and storage kinds, and the built-in kinds that hold references to them " + "— not every built-in kind. Something outside the cluster still might name it, " + "which is the one thing this cannot see." readsEvery = "Nothing anywhere in the cluster names it. Every kind the cluster reports was read " + "to decide this. Something outside the cluster still might, which is the one thing this cannot see." findingsShown = 200 ) type scan struct { subjects []Subject usage map[string]api.ResourceUsage held *corpus facts Facts upstream *upstreamScan faults *ruleDiagnostics everyKind bool custom bool } func (sc scan) hasUsage() bool { return len(sc.usage) > 0 } type found struct { subject Subject container string detail string patch string convention string severity string unmatched bool } type objects struct { index map[string]int list []api.CheckObject } func newObjects() *objects { return &objects{index: map[string]int{}} } func (o *objects) ref(subject Subject) int { key := subject.Kind + "/" + subject.Ref.Namespace + "/" + subject.Ref.Name at, seen := o.index[key] if seen { return at } at = len(o.list) o.index[key] = at o.list = append(o.list, api.CheckObject{ Group: subject.Ref.Group, Version: subject.Ref.Version, Resource: subject.Ref.Resource, Namespace: subject.Ref.Namespace, Name: subject.Ref.Name, Kind: subject.Kind, Origin: subject.Origin, ManagedBy: subject.ManagedBy, }) return at } type finder func(scan) []found type containerRule func(Subject, Container) (string, string) type subjectRule func(Subject) (string, string) type usageRule func(Subject, map[string]api.ResourceUsage) (string, string) type factRule func(Subject, *corpus) (string, string) type check struct { id string upstream policy.CheckID presented func(Subject) bool enforced bool title string category string severity string frameworks []string wrong string wrongEvery string remedy string needsUsage bool needsEvery bool arguable bool needs []target imported *importOrigin find finder } func overContainers(rule containerRule) finder { return func(sc scan) []found { out := []found{} for _, subject := range sc.subjects { for _, container := range subject.Containers { detail, patch := rule(subject, container) if detail == "" { continue } out = append(out, found{ subject: subject, container: container.Name, detail: detail, patch: patch, }) } } return out } } func overSubjects(rule subjectRule) finder { return func(sc scan) []found { out := []found{} for _, subject := range sc.subjects { detail, patch := rule(subject) if detail == "" { continue } out = append(out, found{subject: subject, detail: detail, patch: patch}) } return out } } func overFacts(rule factRule) finder { return func(sc scan) []found { out := []found{} for _, subject := range sc.subjects { detail, patch := rule(subject, sc.held) if detail == "" { continue } out = append(out, found{subject: subject, detail: detail, patch: patch}) } return out } } func overUsage(rule usageRule) finder { return func(sc scan) []found { out := []found{} for _, subject := range sc.subjects { detail, patch := rule(subject, sc.usage) if detail == "" { continue } out = append(out, found{subject: subject, detail: detail, patch: patch}) } return out } } func findingKey(item found) string { return rankOf(item.severity) + "\x00" + subjectKey(item.subject) + "\x00" + item.container } func encodeCursor(key string) string { if key == "" { return "" } return base64.RawURLEncoding.EncodeToString([]byte(key)) } func decodeCursor(cursor string) string { if cursor == "" { return "" } raw, err := base64.RawURLEncoding.DecodeString(cursor) if err != nil { return "" } return string(raw) } type marked struct { found muted bool by Mute fresh bool byRule bool } type tally struct { found int total int muted int fresh int here map[string]bool } func (c check) matching(sc scan, keep Filter) ([]marked, tally) { all := c.ranked(c.findings(sc)) out := make([]marked, 0, len(all)) count := tally{} here := map[string]bool{} for _, item := range all { if !keep.keeps(item) { continue } count.found++ key := c.id + "\x00" + fingerprintOf(identityOf(c.id, item)) here[key] = true by, muted := keep.mutes(c.id, item) rule := false if !muted { by, muted = keep.silenced(c.id, item, sc.held) rule = muted } if !muted && item.convention != "" { by, muted = Mute{Check: c.id, Reason: item.convention}, true } if muted { count.muted++ if !keep.ShowMuted { continue } } fresh := c.comparable() && !keep.foreign() && keep.Base.covers(c.id) && !keep.Base.has(key) if fresh { count.fresh++ } if keep.OnlyNew && !fresh { continue } out = append(out, marked{found: item, muted: muted, by: by, fresh: fresh, byRule: rule}) } count.total = len(out) count.here = here return out, count } func mutedBy(item marked) string { if !item.muted { return "" } if item.byRule { return ScopeRule } if item.convention != "" { return ScopeConvention } return scopeOf(item.by) } func page(all []marked, objs *objects, after string, limit int) ([]api.CheckFinding, string) { out := make([]api.CheckFinding, 0, min(limit, len(all))) last := "" for _, item := range all { key := findingKey(item.found) if key <= after { continue } if len(out) == limit { return out, encodeCursor(last) } out = append(out, api.CheckFinding{ Ref: objs.ref(item.subject), Container: item.container, Detail: item.detail, Patch: item.patch, Severity: item.severity, New: item.fresh, Muted: item.muted, MutedBy: mutedBy(item), Reason: item.by.Reason, Unmatched: item.unmatched, }) last = key } return out, "" } func wantsCorpus(keep Filter) bool { for _, entry := range keep.chosen(keep.checks()) { if entry.needsEvery { return true } } return false } func (c check) comparable() bool { return !c.needsUsage } func (c check) standsDown(sc scan) string { if c.needsUsage && !sc.hasUsage() { return noUsage } if c.needsEvery { if !sc.custom { return notEveryKind } if refused := sc.held.refused(); refused != "" { return "not audited: the cluster would not let this read " + refused + ", and what nothing references cannot be decided from part of it" } } if missing := missingResources(c.needs, sc.held); len(missing) > 0 { return skippedBecause(missing) } if c.upstream != "" { if down := sc.upstream.standsDown(c.upstream); down != "" { return down } } if c.enforced && sc.upstream.err != nil { return upstreamUnloaded + sc.upstream.err.Error() } return "" } func (c check) group(sc scan, objs *objects, spread *namespaces, keep Filter, shown int) api.CheckGroup { out := api.CheckGroup{ ID: c.id, Title: c.title, Category: c.category, Severity: c.severity, Frameworks: c.frameworksFor(sc), Wrong: c.wrong, Remedy: c.remedy, Findings: []api.CheckFinding{}, } if sc.everyKind && c.wrongEvery != "" { out.Wrong = c.wrongEvery } if c.imported != nil { out.Sources = c.imported.sources out.Taken = c.imported.taken.UTC().Format(time.RFC3339) } if down := c.standsDown(sc); down != "" { out.Skipped = down return out } all, count := c.matching(sc, keep) out.Findings, out.Next = page(all, objs, "", shown) out.Total, out.Muted, out.NewCount = count.total, count.muted, count.fresh if c.comparable() { out.Fixed = keep.fixedSince(c.id, count.found) out.Gone = keep.goneSince(c.id, count.here) out.Baselined = keep.Base.covers(c.id) if was, ran := keep.countedBefore(c.id); ran { out.Was, out.Ran = was, true } } out.Measured = !c.comparable() out.Truncated = out.Next != "" spread.add(all) return out } func joined(parts ...string) string { kept := []string{} for _, part := range parts { if part == "" { continue } kept = append(kept, part) } return strings.Join(kept, "; ") } func registryWith(rules []UserRule, files []importFile) []check { out := append(registry(), userChecks(rules)...) return append(out, importedChecks(files)...) } func registryFor(keep Filter) ([]check, []string) { files, faults := readImports(keep.Imports) return registryWith(keep.Rules, files), faults } func (f Filter) checks() []check { out, _ := registryFor(f) return out } func registry() []check { out := securityChecks() out = append(out, hardeningChecks()...) out = append(out, reliabilityChecks()...) out = append(out, lifecycleChecks()...) out = append(out, supplyChecks()...) out = append(out, batchChecks()...) out = append(out, efficiencyChecks()...) out = append(out, factChecks()...) out = append(out, referenceChecks()...) out = append(out, rbacChecks()...) out = append(out, objectChecks()...) out = append(out, deprecationChecks()...) return out } func Page( ctx context.Context, lister Lister, descs map[string]api.ResourceDescriptor, usage api.Metrics, id string, after string, keep Filter, shown int, ) (api.CheckPage, error) { return (*Surveys)(nil).Page(ctx, lister, descs, usage, id, after, keep, shown) } func (s *Surveys) Page( ctx context.Context, lister Lister, descs map[string]api.ResourceDescriptor, usage api.Metrics, id string, after string, keep Filter, shown int, ) (api.CheckPage, error) { var wanted check for _, entry := range keep.checks() { if entry.id == id { wanted = entry } } if wanted.id == "" { return api.CheckPage{}, ErrNoSuchCheck } sc, _, _ := s.take(ctx, lister, descs, usage, keep) if wanted.standsDown(sc) != "" { return api.CheckPage{Findings: []api.CheckFinding{}, Objects: []api.CheckObject{}}, nil } objs := newObjects() all, _ := wanted.matching(sc, keep) found, next := page(all, objs, decodeCursor(after), pageSize(shown)) return api.CheckPage{Findings: found, Objects: objs.list, Next: next}, nil } func pageSize(shown int) int { if shown <= 0 { return findingsShown } return shown } func survey( ctx context.Context, lister Lister, descs map[string]api.ResourceDescriptor, usage api.Metrics, keep Filter, ) (scan, string, []string) { wanted, absent := needed(descs, keep.WholeCluster) custom := false if keep.EveryKind { wanted, absent, custom = everyDiscovered(descs), nil, true } if keep.WholeCluster && !keep.EveryKind { if wantsCorpus(keep) { wanted = append(wanted, customKinds(descs)...) wanted = append(wanted, mentionKinds(descs)...) custom = true } wanted = append(wanted, alsoWarm(lister, wanted)...) } items, names, unread, mentions, failure := gather(ctx, lister, wanted) facts := lister.Facts() return scan{ subjects: subjectsOf(items), usage: usage.Pods, held: newCorpus(items, names, absent, targetsFor(keep.WholeCluster), unread, mentions), facts: facts, upstream: newUpstreamScan(facts.ServerVersion), everyKind: keep.EveryKind, custom: custom, }, failure, absent } func Run( ctx context.Context, lister Lister, descs map[string]api.ResourceDescriptor, usage api.Metrics, keep Filter, shown int, ) api.CheckReport { return (*Surveys)(nil).Run(ctx, lister, descs, usage, keep, shown) } func (s *Surveys) Run( ctx context.Context, lister Lister, descs map[string]api.ResourceDescriptor, usage api.Metrics, keep Filter, shown int, ) api.CheckReport { sc, failure, absent := s.take(ctx, lister, descs, usage, keep) keep.ruleFailures = newRuleDiagnostics() sc.faults = keep.ruleFailures registered, importFaults := registryFor(keep) checks := keep.chosen(registered) objs := newObjects() spread := newNamespaces() groups := make([]api.CheckGroup, 0, len(checks)) for _, entry := range checks { if ctx.Err() != nil { return api.CheckReport{ Groups: []api.CheckGroup{}, Objects: []api.CheckObject{}, Error: "the audit was stopped before it finished", } } groups = append(groups, entry.group(sc, objs, spread, keep, pageSize(shown))) } return api.CheckReport{ Groups: groups, Objects: objs.list, Namespaces: spread.sorted(), Baseline: keep.takenAt(), BaselineFrom: keep.takenFrom(), WasScanned: keep.scannedBefore(), Scanned: len(sc.subjects), Error: joined(failure, undiscovered(absent), keep.ruleFailures.message(), strings.Join(importFaults, "; ")), } }