Ctrl K
ComunidadGuías…

internal/checks/hardening.go

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

Archivo original ↗Preguntar a KiBot
package checks

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

const (
	unconfined       = "Unconfined"
	runtimeDefault   = "RuntimeDefault"
	seccompField     = "seccompProfile"
	apparmorField    = "appArmorProfile"
	apparmorPrefix   = "container.apparmor.security.beta.kubernetes.io/"
	dropAll          = "ALL"
	netRaw           = "NET_RAW"
	sensitiveMount   = "hostPath"
	windowsOptions   = "windowsOptions"
	seLinuxField     = "seLinuxOptions"
	procMountField   = "procMount"
	procMountUnmask  = "Unmasked"
	sysctlsField     = "sysctls"
	hostProcessField = "hostProcess"
)

var safeSysctls = []string{
	"kernel.shm_rmid_forced",
	"net.ipv4.ip_local_port_range",
	"net.ipv4.ip_local_reserved_ports",
	"net.ipv4.ip_unprivileged_port_start",
	"net.ipv4.ping_group_range",
	"net.ipv4.tcp_syncookies",
}

var sensitiveHostPaths = []string{
	"/",
	"/boot",
	"/dev",
	"/etc",
	"/home",
	"/proc",
	"/root",
	"/run",
	"/sys",
	"/var/lib/kubelet",
	"/var/log",
	"/var/run",
}

var restrictedVolumeTypes = []string{
	"configMap",
	"csi",
	"downwardAPI",
	"emptyDir",
	"ephemeral",
	"persistentVolumeClaim",
	"projected",
	"secret",
}

var seLinuxNarrowing = []string{"user", "role", "type"}

func hardeningChecks() []check {
	return []check{
		{
			id:         "seccomp-unset",
			upstream:   "seccompProfile_restricted",
			title:      "No seccomp profile",
			category:   categorySecurity,
			severity:   severityMedium,
			frameworks: []string{nsaCisa, cisBenchmark},
			wrong:      "Every syscall the kernel offers is reachable, including the ones no container needs.",
			remedy:     "Set securityContext.seccompProfile.type to RuntimeDefault on the pod or the container.",
			find:       overContainers(seccompUnset),
		},
		{
			id:         "seccomp-unconfined",
			upstream:   "seccompProfile_baseline",
			title:      "Seccomp explicitly disabled",
			category:   categorySecurity,
			severity:   severityHigh,
			frameworks: []string{nsaCisa, cisBenchmark},
			wrong:      "Unconfined turns the syscall filter off, which is weaker than leaving it unset on most runtimes.",
			remedy:     "Change securityContext.seccompProfile.type to RuntimeDefault.",
			find:       overContainers(seccompUnconfined),
		},
		{
			id:       "apparmor-unconfined",
			upstream: "appArmorProfile",
			title:    "AppArmor disabled",
			category: categorySecurity,
			severity: severityHigh,
			wrong:    "The container is exempt from the node's AppArmor policy.",
			remedy:   "Drop the unconfined profile and let the runtime default apply.",
			find:     overContainers(apparmorUnconfined),
		},
		{
			id:       "selinux-options-set",
			upstream: "seLinuxOptions",
			title:    "SELinux type or role overridden",
			category: categorySecurity,
			severity: severityHigh,
			wrong:    "Choosing your own SELinux user, role or type can hand the container a domain wider than the default.",
			remedy:   "Remove seLinuxOptions.user, .role and .type; level is the only field that is safe to set.",
			find:     overContainers(seLinuxWidened),
		},
		{
			id:       "proc-mount-unmasked",
			upstream: "procMount",
			title:    "/proc mounted unmasked",
			category: categorySecurity,
			severity: severityHigh,
			wrong:    "The masked and read-only paths under /proc are exposed, which is a documented break-out surface.",
			remedy:   "Remove securityContext.procMount, which defaults to Default.",
			find:     overContainers(procMountUnmasked),
		},
		{
			id:       "unsafe-sysctls",
			upstream: "sysctls",
			title:    "Unsafe sysctls set",
			category: categorySecurity,
			severity: severityHigh,
			wrong:    "An unsafe sysctl is namespaced badly or not at all, so it can reach the node and the pods beside it.",
			remedy:   "Remove the sysctl, or move the setting into the node's own configuration.",
			find:     overSubjects(unsafeSysctls),
		},
		{
			id:         "host-ports",
			upstream:   "hostPorts",
			title:      "Host port bound",
			category:   categorySecurity,
			severity:   severityMedium,
			frameworks: []string{cisBenchmark},
			wrong:      "The port is claimed on the node itself, so the pod is reachable around any Service and collides with anything else wanting it.",
			remedy:     "Drop hostPort and publish the container through a Service.",
			find:       overContainers(hostPorts),
		},
		{
			id:         "host-path-volume",
			upstream:   "hostPathVolumes",
			presented:  hasHostPathVolume,
			title:      "Node filesystem mounted",
			category:   categorySecurity,
			severity:   severityMedium,
			frameworks: []string{nsaCisa, cisBenchmark},
			wrong:      "The pod reads and writes the node's own disk, so it outlives the pod and is shared with everything else on that node.",
			remedy:     "Use a PersistentVolumeClaim, an emptyDir or a ConfigMap instead.",
			find:       overSubjects(hostPathVolume),
		},
		{
			id:         "sensitive-host-path",
			title:      "Sensitive node path mounted",
			category:   categorySecurity,
			severity:   severityHigh,
			frameworks: []string{nsaCisa, cisBenchmark},
			wrong:      "These paths carry the node's identity, its credentials or its running state.",
			remedy:     "Remove the hostPath volume; read what you need from the API server instead.",
			find:       overSubjects(sensitiveHostPath),
		},
		{
			id:         "writable-host-mount",
			title:      "Node filesystem mounted writable",
			category:   categorySecurity,
			severity:   severityHigh,
			frameworks: []string{nsaCisa, cisBenchmark},
			wrong:      "The container can change the node's files, which persists after the pod is gone.",
			remedy:     "Set readOnly: true on the volumeMount, or drop the mount.",
			find:       overContainers(writableHostMount),
		},
		{
			id:         "capabilities-not-dropped",
			upstream:   "capabilities_restricted",
			title:      "Capabilities not dropped",
			category:   categorySecurity,
			severity:   severityMedium,
			frameworks: []string{nsaCisa, cisBenchmark},
			wrong:      "The container keeps the runtime's whole default capability set rather than only what it needs.",
			remedy:     "Set securityContext.capabilities.drop to [ALL], then add back what the process actually needs.",
			find:       overContainers(capabilitiesNotDropped),
		},
		{
			id:         "net-raw-kept",
			title:      "NET_RAW kept",
			category:   categorySecurity,
			severity:   severityMedium,
			frameworks: []string{nsaCisa, cisBenchmark},
			wrong:      "NET_RAW allows raw sockets, which is how a compromised container spoofs and sniffs traffic on its network.",
			remedy:     "Drop NET_RAW, or drop ALL.",
			find:       overContainers(netRawKept),
		},
		{
			id:       "restricted-volume-types",
			upstream: "restrictedVolumes",
			title:    "Volume type outside the restricted set",
			category: categorySecurity,
			severity: severityMedium,
			wrong:    "The restricted profile allows only volume types the pod cannot use to reach the node or the network directly.",
			remedy:   "Move the data behind a PersistentVolumeClaim, a projected volume or a CSI driver.",
			find:     overSubjects(restrictedVolumes),
		},
		{
			id:         "root-group",
			title:      "Running with a root group",
			category:   categorySecurity,
			severity:   severityMedium,
			frameworks: []string{nsaCisa, cisBenchmark},
			wrong:      "Group 0 reaches files the root group owns, which is most of what a break-out wants.",
			remedy:     "Set runAsGroup, fsGroup and supplementalGroups to a non-zero id.",
			find:       overContainers(rootGroup),
		},
		{
			id:         "host-process",
			upstream:   "windowsHostProcess",
			title:      "Windows HostProcess container",
			category:   categorySecurity,
			severity:   severityHigh,
			frameworks: []string{cisBenchmark},
			wrong:      "A HostProcess container runs directly on the Windows node with the node's own privileges.",
			remedy:     "Set windowsOptions.hostProcess to false.",
			find:       overContainers(hostProcess),
		},
		{
			id:         "automount-token",
			arguable:   true,
			title:      "Service account token mounted",
			category:   categorySecurity,
			severity:   severityLow,
			frameworks: []string{nsaCisa, cisBenchmark},
			wrong:      "A token that reaches the API server is on disk in every container, whether or not anything reads it. A judgement call: leave it on for workloads that genuinely talk to the API server.",
			remedy:     "Set automountServiceAccountToken to false on the pod, or on the ServiceAccount it uses.",
			find:       overSubjects(automountToken),
		},
		{
			id:         "default-service-account",
			arguable:   true,
			title:      "Default service account used",
			category:   categorySecurity,
			severity:   severityLow,
			frameworks: []string{nsaCisa, cisBenchmark},
			wrong:      "Every workload that names no account shares one identity, so a grant to any of them is a grant to all. A judgement call on a cluster where the default account holds nothing.",
			remedy:     "Give the workload its own ServiceAccount and name it in serviceAccountName.",
			find:       overSubjects(defaultServiceAccount),
		},
		{
			id:         "run-as-user-zero",
			upstream:   "runAsUser",
			title:      "Runs as uid 0 by request",
			category:   categorySecurity,
			severity:   severityMedium,
			frameworks: []string{cisBenchmark},
			wrong:      "runAsUser is 0, so the process is root whatever user the image names, and runAsNonRoot cannot save it.",
			remedy:     "Remove runAsUser: 0 from the pod and its containers, or set a non-zero uid.",
			find:       upstreamOnly,
		},
		{
			id:       "probe-host-set",
			upstream: "hostProbesAndHostLifecycle",
			title:    "A probe or hook targets another host",
			category: categorySecurity,
			severity: severityMedium,
			wrong:    "A liveness, readiness or startup probe, or a lifecycle hook, names a host, so the kubelet talks to something other than the container it is meant to check.",
			remedy:   "Remove host from every httpGet and tcpSocket in probes and lifecycle hooks; the container is the default target.",
			find:     upstreamOnly,
		},
		{
			id:        "proc-mount-in-user-namespace",
			upstream:  "procMount_restricted",
			presented: outsideUserNamespace,
			title:     "/proc unmasked inside a user namespace",
			category:  categorySecurity,
			severity:  severityMedium,
			wrong:     "Baseline lets a pod in its own user namespace unmask /proc; restricted does not, so the pod is refused there.",
			remedy:    "Remove securityContext.procMount, or keep the workload out of a restricted namespace.",
			find:      upstreamOnly,
		},
	}
}

func profileType(spec map[string]any, field string) (value string, set bool) {
	profile, ok := spec[field].(map[string]any)
	if !ok {
		return "", false
	}
	return stringAt(profile, "type"), true
}

func seccompUnset(subject Subject, container Container) (string, string) {
	if _, set := profileType(securityContextOf(container.Spec), seccompField); set {
		return "", ""
	}
	if _, set := profileType(securityContextOf(subject.Pod), seccompField); set {
		return "", ""
	}
	body := []string{
		securityBlock,
		indent + seccompField + ":",
		strings.Repeat(indent, 2) + "type: " + runtimeDefault,
	}
	return "no securityContext.seccompProfile on the container or the pod", containerPatch(subject, container, body)
}

func seccompUnconfined(subject Subject, container Container) (string, string) {
	where, found := unconfinedProfile(subject, container, seccompField)
	if !found {
		return "", ""
	}
	body := []string{
		securityBlock,
		indent + seccompField + ":",
		strings.Repeat(indent, 2) + "type: " + runtimeDefault,
	}
	return "securityContext.seccompProfile.type is Unconfined on the " + where, containerPatch(subject, container, body)
}

func unconfinedProfile(subject Subject, container Container, field string) (where string, found bool) {
	if value, set := profileType(securityContextOf(container.Spec), field); set {
		if value == unconfined {
			return "container", true
		}
		return "", false
	}
	if value, set := profileType(securityContextOf(subject.Pod), field); set && value == unconfined {
		return "pod", true
	}
	return "", false
}

func apparmorUnconfined(subject Subject, container Container) (string, string) {
	if annotated := apparmorAnnotation(subject, container); annotated != "" {
		return annotated, ""
	}
	where, found := unconfinedProfile(subject, container, apparmorField)
	if !found {
		return "", ""
	}
	body := []string{
		securityBlock,
		indent + apparmorField + ":",
		strings.Repeat(indent, 2) + "type: " + runtimeDefault,
	}
	return "securityContext.appArmorProfile.type is Unconfined on the " + where, containerPatch(subject, container, body)
}

func apparmorAnnotation(subject Subject, container Container) string {
	annotations, listed := templateMetaOf(subject)["annotations"].(map[string]any)
	if !listed {
		return ""
	}
	value, ok := annotations[apparmorPrefix+container.Name].(string)
	if !ok || !strings.EqualFold(value, "unconfined") {
		return ""
	}
	return "the " + apparmorPrefix + container.Name + " annotation is unconfined"
}

func seLinuxWidened(subject Subject, container Container) (string, string) {
	widened := narrowedFields(securityContextOf(container.Spec))
	if len(widened) == 0 {
		widened = narrowedFields(securityContextOf(subject.Pod))
	}
	if len(widened) == 0 {
		return "", ""
	}
	return "seLinuxOptions sets " + strings.Join(widened, ", "), ""
}

func narrowedFields(spec map[string]any) []string {
	options, ok := spec[seLinuxField].(map[string]any)
	if !ok {
		return nil
	}
	out := []string{}
	for _, field := range seLinuxNarrowing {
		if stringAt(options, field) != "" {
			out = append(out, field)
		}
	}
	return out
}

func procMountUnmasked(subject Subject, container Container) (string, string) {
	if stringAt(securityContextOf(container.Spec), procMountField) != procMountUnmask {
		return "", ""
	}
	body := []string{securityBlock, indent + procMountField + ": Default"}
	return "securityContext.procMount is Unmasked", containerPatch(subject, container, body)
}

func unsafeSysctls(subject Subject) (string, string) {
	named := listedSysctls(subject)
	if len(named) == 0 {
		return "", ""
	}
	return "sets the unsafe " + plural(named, "sysctl") + " " + strings.Join(named, ", "), ""
}

func listedSysctls(subject Subject) []string {
	listed, ok := securityContextOf(subject.Pod)[sysctlsField].([]any)
	if !ok {
		return nil
	}
	out := []string{}
	for _, entry := range listed {
		item, isMap := entry.(map[string]any)
		if !isMap {
			continue
		}
		name := stringAt(item, "name")
		if name == "" || slices.Contains(safeSysctls, name) {
			continue
		}
		out = append(out, name)
	}
	slices.Sort(out)
	return out
}

func plural(named []string, word string) string {
	if len(named) == 1 {
		return word
	}
	return word + "s"
}

func hostPorts(subject Subject, container Container) (string, string) {
	claimed := claimedHostPorts(container)
	if len(claimed) == 0 {
		return "", ""
	}
	return "binds host " + plural(claimed, "port") + " " + strings.Join(claimed, ", "), ""
}

func claimedHostPorts(container Container) []string {
	listed, ok := container.Spec["ports"].([]any)
	if !ok {
		return nil
	}
	out := []string{}
	for _, entry := range listed {
		item, isMap := entry.(map[string]any)
		if !isMap {
			continue
		}
		port, set := numberAt(item, "hostPort")
		if !set || port == 0 {
			continue
		}
		out = append(out, strconv.FormatInt(port, 10))
	}
	return out
}

func hostPathVolume(subject Subject) (string, string) {
	name, path := firstHostPath(subject, false)
	if name == "" {
		return "", ""
	}
	return "volume " + name + " mounts " + path + " from the node", volumePatch(subject, name)
}

func sensitiveHostPath(subject Subject) (string, string) {
	name, path := firstHostPath(subject, true)
	if name == "" {
		return "", ""
	}
	return "volume " + name + " mounts " + path + " from the node", volumePatch(subject, name)
}

func firstHostPath(subject Subject, wantSensitive bool) (name, path string) {
	volumes, ok := subject.Pod["volumes"].([]any)
	if !ok {
		return "", ""
	}
	for _, entry := range volumes {
		volume, isMap := entry.(map[string]any)
		if !isMap {
			continue
		}
		found := hostPathOf(volume)
		if found == "" || slices.Contains(runtimeSockets, found) {
			continue
		}
		if slices.Contains(sensitiveHostPaths, found) != wantSensitive {
			continue
		}
		return stringAt(volume, "name"), found
	}
	return "", ""
}

func hostPathOf(volume map[string]any) string {
	spec, ok := volume[sensitiveMount].(map[string]any)
	if !ok {
		return ""
	}
	return stringAt(spec, "path")
}

func writableHostMount(subject Subject, container Container) (string, string) {
	for _, name := range hostPathVolumeNames(subject) {
		if mounted := writableMountOf(container, name); mounted != "" {
			return "mounts the node's " + mounted + " writable", ""
		}
	}
	return "", ""
}

func outsideUserNamespace(subject Subject) bool {
	value, set := boolAt(subject.Pod, "hostUsers")
	return !set || value
}

func hasHostPathVolume(subject Subject) bool {
	return len(hostPathVolumeNames(subject)) > 0
}

func hostPathVolumeNames(subject Subject) []string {
	volumes, ok := subject.Pod["volumes"].([]any)
	if !ok {
		return nil
	}
	out := []string{}
	for _, entry := range volumes {
		volume, isMap := entry.(map[string]any)
		if !isMap {
			continue
		}
		if hostPathOf(volume) == "" {
			continue
		}
		out = append(out, stringAt(volume, "name"))
	}
	return out
}

func writableMountOf(container Container, volume string) string {
	listed, ok := container.Spec["volumeMounts"].([]any)
	if !ok {
		return ""
	}
	for _, entry := range listed {
		item, isMap := entry.(map[string]any)
		if !isMap {
			continue
		}
		if stringAt(item, "name") != volume {
			continue
		}
		readOnly, set := boolAt(item, "readOnly")
		if set && readOnly {
			continue
		}
		return stringAt(item, "mountPath")
	}
	return ""
}

func capabilitiesNotDropped(subject Subject, container Container) (string, string) {
	if slices.ContainsFunc(droppedCapabilities(container), func(name string) bool {
		return strings.EqualFold(name, dropAll)
	}) {
		return "", ""
	}
	body := []string{
		securityBlock,
		indent + "capabilities:",
		strings.Repeat(indent, 2) + "drop:",
		strings.Repeat(indent, 3) + "- " + dropAll,
	}
	return "securityContext.capabilities.drop does not include ALL", containerPatch(subject, container, body)
}

func netRawKept(subject Subject, container Container) (string, string) {
	dropped := droppedCapabilities(container)
	for _, name := range dropped {
		if strings.EqualFold(name, dropAll) || strings.EqualFold(name, netRaw) {
			return "", ""
		}
	}
	body := []string{
		securityBlock,
		indent + "capabilities:",
		strings.Repeat(indent, 2) + "drop:",
		strings.Repeat(indent, 3) + "- " + netRaw,
	}
	return "NET_RAW is not in securityContext.capabilities.drop", containerPatch(subject, container, body)
}

func droppedCapabilities(container Container) []string {
	capabilities, ok := securityContextOf(container.Spec)["capabilities"].(map[string]any)
	if !ok {
		return nil
	}
	listed, ok := capabilities["drop"].([]any)
	if !ok {
		return nil
	}
	out := []string{}
	for _, entry := range listed {
		name, isString := entry.(string)
		if !isString {
			continue
		}
		out = append(out, name)
	}
	return out
}

func restrictedVolumes(subject Subject) (string, string) {
	named := disallowedVolumeTypes(subject)
	if len(named) == 0 {
		return "", ""
	}
	return "uses the " + plural(named, "volume type") + " " + strings.Join(named, ", "), ""
}

func disallowedVolumeTypes(subject Subject) []string {
	volumes, ok := subject.Pod["volumes"].([]any)
	if !ok {
		return nil
	}
	seen := map[string]bool{}
	out := []string{}
	for _, entry := range volumes {
		volume, isMap := entry.(map[string]any)
		if !isMap {
			continue
		}
		for key := range volume {
			if key == "name" || slices.Contains(restrictedVolumeTypes, key) {
				continue
			}
			if seen[key] {
				continue
			}
			seen[key] = true
			out = append(out, key)
		}
	}
	slices.Sort(out)
	return out
}

func rootGroup(subject Subject, container Container) (string, string) {
	own := securityContextOf(container.Spec)
	inherited := securityContextOf(subject.Pod)
	for _, field := range []string{"runAsGroup", "fsGroup"} {
		if rootAt(own, inherited, field) {
			return "securityContext." + field + " is 0", ""
		}
	}
	if rootSupplemental(own, inherited) {
		return "securityContext.supplementalGroups includes 0", ""
	}
	return "", ""
}

func rootAt(own, inherited map[string]any, field string) bool {
	value, set := numberAt(own, field)
	if !set {
		value, set = numberAt(inherited, field)
	}
	return set && value == 0
}

func rootSupplemental(own, inherited map[string]any) bool {
	listed, ok := own["supplementalGroups"].([]any)
	if !ok {
		listed, ok = inherited["supplementalGroups"].([]any)
	}
	if !ok {
		return false
	}
	for _, entry := range listed {
		switch value := entry.(type) {
		case int64:
			if value == 0 {
				return true
			}
		case float64:
			if value == 0 {
				return true
			}
		default:
			continue
		}
	}
	return false
}

func hostProcess(subject Subject, container Container) (string, string) {
	if !hostProcessAt(securityContextOf(container.Spec)) && !hostProcessAt(securityContextOf(subject.Pod)) {
		return "", ""
	}
	body := []string{
		securityBlock,
		indent + windowsOptions + ":",
		strings.Repeat(indent, 2) + hostProcessField + ": false",
	}
	return "windowsOptions.hostProcess is true", containerPatch(subject, container, body)
}

func hostProcessAt(spec map[string]any) bool {
	options, ok := spec[windowsOptions].(map[string]any)
	if !ok {
		return false
	}
	value, set := boolAt(options, hostProcessField)
	return set && value
}

func automountToken(subject Subject) (string, string) {
	value, set := boolAt(subject.Pod, "automountServiceAccountToken")
	if set && !value {
		return "", ""
	}
	detail := "automountServiceAccountToken is unset, which mounts the token"
	if set {
		detail = "automountServiceAccountToken is true"
	}
	return detail, podPatch(subject, []string{"automountServiceAccountToken: false"})
}

func defaultServiceAccount(subject Subject) (string, string) {
	name := stringAt(subject.Pod, "serviceAccountName")
	if name != "" && name != defaultNamespace {
		return "", ""
	}
	if name == "" {
		return "serviceAccountName is unset, so the pod uses the namespace's default account", ""
	}
	return "serviceAccountName is the namespace's default account", ""
}