Ctrl K
ComunidadGuías…

internal/checks/supply.go

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

Archivo original ↗Preguntar a KiBot
package checks

import (
	"slices"
	"strconv"
	"strings"
)

const (
	digestMarker      = "@sha256:"
	dockerHub         = "docker.io"
	dockerHubIndex    = "index.docker.io"
	alwaysPull        = "Always"
	latestTag         = "latest"
	nameLabel         = "app.kubernetes.io/name"
	defaultNamespace  = "default"
	groupOtherBits    = 0o077
	credentialMinimum = 4
)

var credentialWords = []string{
	"PASSWORD",
	"PASSWD",
	"SECRET",
	"TOKEN",
	"APIKEY",
	"API_KEY",
	"PRIVATE_KEY",
	"ACCESS_KEY",
	"CREDENTIAL",
}

var publicRegistries = []string{
	dockerHub,
	dockerHubIndex,
	"gcr.io",
	"ghcr.io",
	"public.ecr.aws",
	"quay.io",
	"registry.k8s.io",
	"mcr.microsoft.com",
}

func supplyChecks() []check {
	return []check{
		{
			id:       "image-not-digest-pinned",
			title:    "Image not pinned to a digest",
			category: categoryReliability,
			severity: severityLow,
			wrong:    "A tag can be moved, so two pods of the same workload can be running different code and a rollback has no fixed target.",
			remedy:   "Append @sha256:… to the image, keeping the tag for readability.",
			find:     overContainers(imageNotPinned),
		},
		{
			id:       "image-from-docker-hub",
			title:    "Image pulled from Docker Hub",
			category: categoryReliability,
			severity: severityLow,
			wrong:    "Anonymous pulls are rate limited per node address, so a rollout can fail on pull rather than on anything you changed.",
			remedy:   "Mirror the image into a registry you control and pull it from there.",
			find:     overContainers(imageFromDockerHub),
		},
		{
			id:       "pull-policy-not-always",
			arguable: true,
			title:    "Mutable tag not pulled every time",
			category: categoryReliability,
			severity: severityLow,
			wrong:    "The node keeps whatever it cached, so a moved tag reaches new nodes and not old ones. A judgement call: pinning the digest instead makes this moot.",
			remedy:   "Set imagePullPolicy to Always, or pin the image to a digest.",
			find:     overContainers(pullPolicyNotAlways),
		},
		{
			id:       "private-registry-no-pull-secret",
			arguable: true,
			title:    "Private registry with no pull secret",
			category: categoryReliability,
			severity: severityLow,
			wrong:    "If the registry ever needs credentials the pull fails on a node that has none cached. A judgement call: a registry open to the cluster's network needs nothing.",
			remedy:   "Add imagePullSecrets, or attach the secret to the pod's ServiceAccount.",
			find:     overSubjects(privateRegistryNoSecret),
		},
		{
			id:         "secret-in-env-literal",
			title:      "Credential written into the manifest",
			category:   categorySecurity,
			severity:   severityHigh,
			frameworks: []string{nsaCisa, cisBenchmark},
			wrong:      "The value is in the object, so it is in every backup, every audit log and anything that can read the workload.",
			remedy:     "Move it to a Secret and reference it with valueFrom.secretKeyRef.",
			find:       overContainers(secretInEnvLiteral),
		},
		{
			id:         "env-from-secret-wholesale",
			title:      "Whole Secret loaded into the environment",
			category:   categorySecurity,
			severity:   severityMedium,
			frameworks: []string{nsaCisa, cisBenchmark},
			wrong:      "Every key lands in the environment, including ones added later, and the environment is readable by anything that can exec into the container.",
			remedy:     "Name the keys you need with valueFrom.secretKeyRef, or mount the Secret as a file.",
			find:       overContainers(envFromSecret),
		},
		{
			id:         "secret-volume-world-readable",
			arguable:   true,
			title:      "Secret file readable beyond its owner",
			category:   categorySecurity,
			severity:   severityLow,
			frameworks: []string{nsaCisa},
			wrong:      "Any process in the container, whatever user it runs as, can read the secret off disk. A judgement call: 0644 is what the apiserver fills in when nobody chooses, so this is true of almost every secret volume until somebody tightens it.",
			remedy:     "Set the volume's defaultMode to 0400.",
			find:       overSubjects(secretVolumeReadable),
		},
		{
			id:         "default-namespace",
			title:      "Running in the default namespace",
			category:   categoryReliability,
			severity:   severityLow,
			frameworks: []string{cisBenchmark},
			wrong:      "Nothing separates it from anything else somebody applies without thinking, and namespace-scoped policy and quota cannot be aimed at it.",
			remedy:     "Move it to a namespace named after what it is.",
			find:       overSubjects(inDefaultNamespace),
		},
		{
			id:       "missing-recommended-labels",
			arguable: true,
			title:    "No app.kubernetes.io/name",
			category: categoryReliability,
			severity: severityLow,
			wrong:    "Nothing ties the object to the application it belongs to, so selectors, dashboards and cost reports have to guess. A judgement call: your own convention may be a different label entirely.",
			remedy:   "Set app.kubernetes.io/name, and instance and version alongside it.",
			find:     overSubjects(missingNameLabel),
		},
		{
			id:       "selector-template-mismatch",
			title:    "Selector does not match its own template",
			category: categoryReliability,
			severity: severityHigh,
			wrong:    "The controller creates pods it then cannot find, so it creates them again, for ever.",
			remedy:   "Make every label in spec.selector.matchLabels appear on the pod template.",
			find:     overSubjects(selectorMismatch),
		},
		{
			id:       "cpu-limit-set",
			arguable: true,
			title:    "CPU limit set",
			category: categoryEfficiency,
			severity: severityLow,
			wrong:    "A CPU limit throttles the container even when the node is idle, which shows up as latency nobody can account for. A judgement call: a limit is the right answer where one tenant must not crowd out another.",
			remedy:   "Drop resources.limits.cpu and let the request do the scheduling.",
			find:     overContainers(cpuLimitSet),
		},
	}
}

func imageOf(container Container) string {
	return stringAt(container.Spec, "image")
}

func imageNotPinned(_ Subject, container Container) (string, string) {
	image := imageOf(container)
	if image == "" || strings.Contains(image, digestMarker) {
		return "", ""
	}
	return image + " names no digest", ""
}

func registryOf(image string) string {
	head, _, found := strings.Cut(image, "/")
	if !found {
		return dockerHub
	}
	if !strings.ContainsAny(head, ".:") && head != "localhost" {
		return dockerHub
	}
	return head
}

func imageFromDockerHub(_ Subject, container Container) (string, string) {
	image := imageOf(container)
	if image == "" {
		return "", ""
	}
	registry := registryOf(image)
	if registry != dockerHub && registry != dockerHubIndex {
		return "", ""
	}
	return image + " comes from Docker Hub", ""
}

func pullPolicyNotAlways(_ Subject, container Container) (string, string) {
	image := imageOf(container)
	if image == "" || strings.Contains(image, digestMarker) {
		return "", ""
	}
	tag := tagOf(image)
	if tag == "" || tag == latestTag {
		return "", ""
	}
	policy := stringAt(container.Spec, "imagePullPolicy")
	if policy == alwaysPull {
		return "", ""
	}
	named := policy
	if named == "" {
		named = "unset, which caches"
	}
	return "imagePullPolicy is " + named + " on the mutable tag " + tag, ""
}

func privateRegistryNoSecret(subject Subject) (string, string) {
	if listedItems(subject.Pod, "imagePullSecrets") > 0 {
		return "", ""
	}
	registry := firstPrivateRegistry(subject)
	if registry == "" {
		return "", ""
	}
	return "pulls from " + registry + " with no imagePullSecrets", ""
}

func firstPrivateRegistry(subject Subject) string {
	for _, entry := range subject.Containers {
		image := imageOf(entry)
		if image == "" {
			continue
		}
		registry := registryOf(image)
		if slices.Contains(publicRegistries, registry) {
			continue
		}
		return registry
	}
	return ""
}

func looksLikeCredential(name string) bool {
	upper := strings.ToUpper(name)
	for _, word := range credentialWords {
		if strings.Contains(upper, word) {
			return true
		}
	}
	return false
}

func secretInEnvLiteral(_ Subject, container Container) (string, string) {
	for _, entry := range envEntries(container) {
		name := stringAt(entry, "name")
		if !looksLikeCredential(name) {
			continue
		}
		value := stringAt(entry, "value")
		if len(value) < credentialMinimum {
			continue
		}
		return name + " is set to a literal value in the manifest", ""
	}
	return "", ""
}

func envEntries(container Container) []map[string]any {
	listed, ok := container.Spec["env"].([]any)
	if !ok {
		return nil
	}
	out := []map[string]any{}
	for _, entry := range listed {
		item, isMap := entry.(map[string]any)
		if !isMap {
			continue
		}
		out = append(out, item)
	}
	return out
}

func envFromSecret(_ Subject, container Container) (string, string) {
	listed, ok := container.Spec["envFrom"].([]any)
	if !ok {
		return "", ""
	}
	for _, entry := range listed {
		item, isMap := entry.(map[string]any)
		if !isMap {
			continue
		}
		ref, hasRef := item["secretRef"].(map[string]any)
		if !hasRef {
			continue
		}
		return "envFrom loads every key of the secret " + stringAt(ref, "name"), ""
	}
	return "", ""
}

func secretVolumeReadable(subject Subject) (string, string) {
	volumes, ok := subject.Pod["volumes"].([]any)
	if !ok {
		return "", ""
	}
	for _, entry := range volumes {
		volume, isMap := entry.(map[string]any)
		if !isMap {
			continue
		}
		secret, hasSecret := volume["secret"].(map[string]any)
		if !hasSecret {
			continue
		}
		mode, set := numberAt(secret, "defaultMode")
		if !set || mode&groupOtherBits == 0 {
			continue
		}
		return "volume " + stringAt(volume, "name") + " mounts a secret with defaultMode " +
			"0" + strconv.FormatInt(mode, 8), ""
	}
	return "", ""
}

func inDefaultNamespace(subject Subject) (string, string) {
	if subject.Ref.Namespace != defaultNamespace {
		return "", ""
	}
	return "runs in the default namespace", ""
}

func missingNameLabel(subject Subject) (string, string) {
	if subject.Object.GetLabels()[nameLabel] != "" {
		return "", ""
	}
	return "no " + nameLabel + " label", ""
}

func templateLabels(subject Subject) map[string]any {
	path := templatePath(subject.Kind)
	if len(path) < 2 {
		return nil
	}
	meta := specAt(subject.Object, append(path[:len(path)-1], metadataField)...)
	labels, ok := meta["labels"].(map[string]any)
	if !ok {
		return nil
	}
	return labels
}

func selectorMismatch(subject Subject) (string, string) {
	if !isSpreadable(subject.Kind) && subject.Kind != daemonSetKind {
		return "", ""
	}
	wanted := selectorLabels(subject)
	if len(wanted) == 0 {
		return "", ""
	}
	carried := templateLabels(subject)
	missing := []string{}
	for key, value := range wanted {
		if stringAt(carried, key) != value {
			missing = append(missing, key)
		}
	}
	if len(missing) == 0 {
		return "", ""
	}
	slices.Sort(missing)
	return "the pod template does not carry " + strings.Join(missing, ", "), ""
}

func selectorLabels(subject Subject) map[string]string {
	selector, ok := specAt(subject.Object, specField)["selector"].(map[string]any)
	if !ok {
		return nil
	}
	pairs, ok := selector["matchLabels"].(map[string]any)
	if !ok {
		return nil
	}
	out := map[string]string{}
	for key, value := range pairs {
		text, isString := value.(string)
		if !isString {
			continue
		}
		out[key] = text
	}
	return out
}

func cpuLimitSet(_ Subject, container Container) (string, string) {
	limit, ok := quantityAt(container.Spec, limits, cpuName)
	if !ok {
		return "", ""
	}
	return "resources.limits.cpu is " + limit.String(), ""
}