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(), "" }