Teststand
Some checks failed
Deploy KubeViz / deploy (push) Has been cancelled

This commit is contained in:
2026-03-01 07:40:49 +01:00
commit 1a0bbe9dfd
58 changed files with 7756 additions and 0 deletions

334
internal/analyze/analyze.go Normal file
View File

@@ -0,0 +1,334 @@
package analyze
import (
"encoding/json"
"fmt"
"sort"
"strings"
"kubeviz/internal/model"
"kubeviz/internal/parser"
)
const (
RulePrivilegedContainer = "privileged_container"
RuleRunAsNonRootFalse = "run_as_non_root_false"
RuleMissingReq = "missing_resource_requests"
RuleMissingLimits = "missing_resource_limits"
RuleIngressWildcardHost = "ingress_wildcard_host"
RuleUnresolvedReference = "unresolved_reference"
RuleServiceSelectorMatch = "selector_mismatch"
RuleDuplicateResourceID = "duplicate_resource_id"
)
var allRules = []string{
RulePrivilegedContainer,
RuleRunAsNonRootFalse,
RuleMissingReq,
RuleMissingLimits,
RuleIngressWildcardHost,
RuleUnresolvedReference,
RuleServiceSelectorMatch,
RuleDuplicateResourceID,
}
type Config struct {
enabled map[string]bool
}
func DefaultConfig() Config {
enabled := map[string]bool{}
for _, rule := range allRules {
enabled[rule] = true
}
return Config{enabled: enabled}
}
func ConfigFromEnabled(enabledRules map[string]bool) Config {
cfg := Config{enabled: map[string]bool{}}
for _, rule := range allRules {
cfg.enabled[rule] = enabledRules[rule]
}
return cfg
}
func (c Config) EnabledRules() []string {
out := make([]string, 0, len(allRules))
for _, rule := range allRules {
if c.enabled[rule] {
out = append(out, rule)
}
}
return out
}
func (c Config) isEnabled(rule string) bool {
return c.enabled[rule]
}
func AnalyzeDataset(ds *model.Dataset, cfg Config) ([]model.Finding, map[string]string) {
if ds == nil {
return nil, map[string]string{}
}
findings := make([]model.Finding, 0)
findings = append(findings, securityFindings(ds, cfg)...)
findings = append(findings, validationFindings(ds, cfg)...)
findings = append(findings, duplicateFindings(ds, cfg)...)
health := map[string]string{}
for id := range ds.Resources {
health[id] = "healthy"
}
for _, f := range findings {
if f.ResourceID == "" {
continue
}
if f.Severity == "error" || f.Severity == "warning" {
health[f.ResourceID] = "warning"
}
}
sort.Slice(findings, func(i, j int) bool {
if findings[i].Severity != findings[j].Severity {
return findings[i].Severity > findings[j].Severity
}
if findings[i].Category != findings[j].Category {
return findings[i].Category < findings[j].Category
}
return findings[i].Message < findings[j].Message
})
return findings, health
}
func securityFindings(ds *model.Dataset, cfg Config) []model.Finding {
out := make([]model.Finding, 0)
for _, r := range ds.Resources {
spec := extractPodSpec(r.Raw)
if spec != nil {
containers, ok := spec["containers"].([]any)
if ok {
for _, c := range containers {
cm, ok := c.(map[string]any)
if !ok {
continue
}
name, _ := cm["name"].(string)
securityCtx, _ := cm["securityContext"].(map[string]any)
if securityCtx != nil {
if cfg.isEnabled(RulePrivilegedContainer) {
if priv, ok := securityCtx["privileged"].(bool); ok && priv {
out = append(out, finding("security", RulePrivilegedContainer, "error", r.ID, fmt.Sprintf("container %q runs privileged", name)))
}
}
if cfg.isEnabled(RuleRunAsNonRootFalse) {
if runAsNonRoot, ok := securityCtx["runAsNonRoot"].(bool); ok && !runAsNonRoot {
out = append(out, finding("security", RuleRunAsNonRootFalse, "warning", r.ID, fmt.Sprintf("container %q has runAsNonRoot=false", name)))
}
}
}
resources, _ := cm["resources"].(map[string]any)
if resources == nil {
if cfg.isEnabled(RuleMissingReq) {
out = append(out, finding("security", RuleMissingReq, "warning", r.ID, fmt.Sprintf("container %q has no resources.requests", name)))
}
if cfg.isEnabled(RuleMissingLimits) {
out = append(out, finding("security", RuleMissingLimits, "warning", r.ID, fmt.Sprintf("container %q has no resources.limits", name)))
}
continue
}
if cfg.isEnabled(RuleMissingReq) {
if _, ok := resources["requests"].(map[string]any); !ok {
out = append(out, finding("security", RuleMissingReq, "warning", r.ID, fmt.Sprintf("container %q missing resource requests", name)))
}
}
if cfg.isEnabled(RuleMissingLimits) {
if _, ok := resources["limits"].(map[string]any); !ok {
out = append(out, finding("security", RuleMissingLimits, "warning", r.ID, fmt.Sprintf("container %q missing resource limits", name)))
}
}
}
}
}
if cfg.isEnabled(RuleIngressWildcardHost) && r.Kind == "Ingress" {
spec, _ := r.Raw["spec"].(map[string]any)
rules, _ := spec["rules"].([]any)
for _, rr := range rules {
rm, ok := rr.(map[string]any)
if !ok {
continue
}
host, _ := rm["host"].(string)
if host == "" || strings.Contains(host, "*") {
out = append(out, finding("security", RuleIngressWildcardHost, "warning", r.ID, "ingress has wildcard or empty host"))
break
}
}
}
}
return out
}
func validationFindings(ds *model.Dataset, cfg Config) []model.Finding {
out := make([]model.Finding, 0)
if cfg.isEnabled(RuleUnresolvedReference) {
for _, r := range ds.Resources {
for _, ref := range r.References {
target := resolveReferenceTarget(ds, ref)
if target == "" {
out = append(out, finding("validation", RuleUnresolvedReference, "error", r.ID, fmt.Sprintf("unresolved reference: %s %s", ref.Kind, ref.Name)))
}
}
}
}
if cfg.isEnabled(RuleServiceSelectorMatch) {
services := make([]*model.Resource, 0)
workloads := make([]*model.Resource, 0)
for _, r := range ds.Resources {
switch r.Kind {
case "Service":
services = append(services, r)
case "Deployment", "StatefulSet", "DaemonSet":
workloads = append(workloads, r)
}
}
for _, svc := range services {
if svc.WorkloadMeta == nil || len(svc.WorkloadMeta.ServiceSelectors) == 0 {
continue
}
matched := false
for _, wl := range workloads {
if wl.Namespace != svc.Namespace || wl.WorkloadMeta == nil {
continue
}
if selectorsMatch(svc.WorkloadMeta.ServiceSelectors, wl.WorkloadMeta.PodTemplateLabels) {
matched = true
break
}
}
if !matched {
out = append(out, finding("validation", RuleServiceSelectorMatch, "warning", svc.ID, "service selector does not match any workload in namespace"))
}
}
}
return out
}
func duplicateFindings(ds *model.Dataset, cfg Config) []model.Finding {
if !cfg.isEnabled(RuleDuplicateResourceID) {
return nil
}
out := make([]model.Finding, 0, len(ds.Duplicates))
for _, id := range ds.Duplicates {
out = append(out, finding("validation", RuleDuplicateResourceID, "warning", id, "duplicate resource id found in input; last document wins"))
}
return out
}
func DiffDatasets(base, target *model.Dataset) model.DiffResponse {
resp := model.DiffResponse{
Added: []model.DiffItem{},
Removed: []model.DiffItem{},
Changed: []model.DiffItem{},
}
if base == nil || target == nil {
return resp
}
for id, t := range target.Resources {
b, ok := base.Resources[id]
if !ok {
resp.Added = append(resp.Added, toDiffItem(t))
continue
}
if !rawEqual(b.Raw, t.Raw) {
resp.Changed = append(resp.Changed, toDiffItem(t))
}
}
for id, b := range base.Resources {
if _, ok := target.Resources[id]; !ok {
resp.Removed = append(resp.Removed, toDiffItem(b))
}
}
sort.Slice(resp.Added, func(i, j int) bool { return resp.Added[i].ID < resp.Added[j].ID })
sort.Slice(resp.Removed, func(i, j int) bool { return resp.Removed[i].ID < resp.Removed[j].ID })
sort.Slice(resp.Changed, func(i, j int) bool { return resp.Changed[i].ID < resp.Changed[j].ID })
return resp
}
func toDiffItem(r *model.Resource) model.DiffItem {
return model.DiffItem{ID: r.ID, Kind: r.Kind, Name: r.Name, Namespace: r.Namespace}
}
func rawEqual(a, b map[string]any) bool {
aj, err := json.Marshal(a)
if err != nil {
return false
}
bj, err := json.Marshal(b)
if err != nil {
return false
}
return string(aj) == string(bj)
}
func resolveReferenceTarget(dataset *model.Dataset, ref model.ResourceReference) string {
ns := ref.Namespace
if ns == "" {
ns = "default"
}
candidate := parser.ResourceID(ns, ref.Kind, ref.Name)
if _, ok := dataset.Resources[candidate]; ok {
return candidate
}
candidate = parser.ResourceID("", ref.Kind, ref.Name)
if _, ok := dataset.Resources[candidate]; ok {
return candidate
}
for _, r := range dataset.Resources {
if r.Kind == ref.Kind && r.Name == ref.Name {
return r.ID
}
}
return ""
}
func finding(category, rule, severity, resourceID, message string) model.Finding {
return model.Finding{
ID: category + ":" + rule + ":" + severity + ":" + resourceID + ":" + message,
Category: category,
Rule: rule,
Severity: severity,
ResourceID: resourceID,
Message: message,
}
}
func extractPodSpec(raw map[string]any) map[string]any {
spec, _ := raw["spec"].(map[string]any)
if spec == nil {
return nil
}
if template, ok := spec["template"].(map[string]any); ok {
if tplSpec, ok := template["spec"].(map[string]any); ok {
return tplSpec
}
}
return nil
}
func selectorsMatch(selector, labels map[string]string) bool {
for k, v := range selector {
if labels[k] != v {
return false
}
}
return true
}