335 lines
9.1 KiB
Go
335 lines
9.1 KiB
Go
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
|
|
}
|