internal/checks/hardening.go
Rama main · réplica de lectura del repositorio original.
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", ""
}