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 }