diff --git a/docs/generated/checks.md b/docs/generated/checks.md index 772475bc0..781e5064e 100644 --- a/docs/generated/checks.md +++ b/docs/generated/checks.md @@ -58,6 +58,61 @@ verbs: **Remediation**: Create and assign a separate role that has access to specific resources/actions needed for the service account. **Template**: [cluster-admin-role-binding](templates.md#cluster-admin-role-binding) +## cluster-wide-secrets-access + +**Enabled by default**: No + +**Description**: ClusterRoleBinding granting secrets access (get, list, watch, create, update, patch, delete) across all namespaces. When bound to an operator service account that reconciles tenant-controlled custom resources, this enables cross-namespace secret exfiltration (confused deputy). + +**Remediation**: Use namespaced RoleBindings to limit secrets access to required namespaces, or restrict the ClusterRole to specific resource names if cluster-wide access is necessary. + +**Template**: [access-to-resources](templates.md#access-to-resources) + +**Parameters**: + +```yaml +resources: +- ^secrets$ +- ^\*$ +verbs: +- ^get$ +- ^list$ +- ^watch$ +- ^create$ +- ^update$ +- ^patch$ +- ^delete$ +- ^\*$ +``` +## configmap-with-credentials + +**Enabled by default**: No + +**Description**: ConfigMap contains keys with credential-like names (password, secret, token, api_key). Credentials should be stored in Secrets, not ConfigMaps. + +**Remediation**: Move credential values to a Secret resource. Reference the Secret from pod specs using secretKeyRef instead of configMapKeyRef. + +**Template**: [cel-expression](templates.md#cel) + +**Parameters**: + +```yaml +check: | + object.kind == 'ConfigMap' && ( + (has(object.data) && + object.data.exists(k, + k.contains('password') || k.contains('secret') || k.contains('token') || + k.contains('api_key') || k.contains('apikey') || k.contains('private_key') || + k.contains('credential') + )) || + (has(object.binaryData) && + object.binaryData.exists(k, + k.contains('password') || k.contains('secret') || k.contains('token') || + k.contains('api_key') || k.contains('apikey') || k.contains('private_key') || + k.contains('credential') + )) + ) ? 'ConfigMap contains credential-like keys — use Secrets instead' : '' +``` ## dangling-horizontalpodautoscaler **Enabled by default**: No @@ -242,6 +297,22 @@ forbiddenServiceTypes: - NodePort - LoadBalancer ``` +## externalname-service-redirect + +**Enabled by default**: No + +**Description**: Service with type: ExternalName redirects internal traffic to an external DNS name. If the external name resolves to an attacker-controlled host, this enables SSRF at the infrastructure level. + +**Remediation**: Use type: ClusterIP with an Endpoints object pointing to a known-safe IP, or validate that the externalName resolves to a trusted destination. + +**Template**: [cel-expression](templates.md#cel) + +**Parameters**: + +```yaml +check: | + object.kind == 'Service' && has(object.spec) && object.spec.type == 'ExternalName' && has(object.spec.externalName) ? 'ExternalName service redirects cluster traffic to external DNS — potential SSRF vector' : '' +``` ## host-ipc **Enabled by default**: Yes @@ -284,6 +355,15 @@ forbiddenServiceTypes: ```yaml minReplicas: 3 ``` +## image-not-pinned-by-digest + +**Enabled by default**: No + +**Description**: Container images referenced by tag are mutable — a tag can be moved to point to a different image at any time. Pinning by digest guarantees the exact image content that will run. + +**Remediation**: Reference container images by digest (e.g. image@sha256:abc123...) instead of by tag. + +**Template**: [image-not-pinned-by-digest](templates.md#image-not-pinned-by-digest) ## invalid-target-ports **Enabled by default**: Yes diff --git a/docs/generated/templates.md b/docs/generated/templates.md index 9fda75d79..4749a9ade 100644 --- a/docs/generated/templates.md +++ b/docs/generated/templates.md @@ -445,6 +445,28 @@ KubeLinter supports the following templates: type: integer ``` +## Image Not Pinned By Digest + +**Key**: `image-not-pinned-by-digest` + +**Description**: Flag container images that are not pinned to a specific digest + +**Supported Objects**: DeploymentLike + + +**Parameters**: + +```yaml +- arrayElemType: string + description: list of regular expressions specifying pattern(s) for container images + to exempt from the digest check. + name: allowList + negationAllowed: true + regexAllowed: true + required: false + type: array +``` + ## Image Pull Policy **Key**: `image-pull-policy` diff --git a/e2etests/bats-tests.sh b/e2etests/bats-tests.sh index 56dbeb06a..de0aa249b 100755 --- a/e2etests/bats-tests.sh +++ b/e2etests/bats-tests.sh @@ -113,6 +113,30 @@ get_value_from() { [[ "${count}" == "1" ]] } +@test "cluster-wide-secrets-access" { + tmp="tests/checks/cluster-wide-secrets-access.yml" + cmd="${KUBE_LINTER_BIN} lint --include cluster-wide-secrets-access --do-not-auto-add-defaults --format json ${tmp}" + run ${cmd} + + print_info "${status}" "${output}" "${cmd}" "${tmp}" + [ "$status" -eq 1 ] + + count=$(get_value_from "${lines[0]}" '.Reports | length') + [[ "${count}" == "1" ]] +} + +@test "configmap-with-credentials" { + tmp="tests/checks/configmap-with-credentials.yml" + cmd="${KUBE_LINTER_BIN} lint --include configmap-with-credentials --do-not-auto-add-defaults --format json ${tmp}" + run ${cmd} + + print_info "${status}" "${output}" "${cmd}" "${tmp}" + [ "$status" -eq 1 ] + + count=$(get_value_from "${lines[0]}" '.Reports | length') + [[ "${count}" == "2" ]] +} + @test "dangling-horizontalpodautoscaler" { tmp="tests/checks/dangling-hpa.yml" cmd="${KUBE_LINTER_BIN} lint --include dangling-horizontalpodautoscaler --do-not-auto-add-defaults --format json ${tmp}" @@ -369,6 +393,18 @@ get_value_from() { [[ "${count}" == "1" ]] } +@test "externalname-service-redirect" { + tmp="tests/checks/externalname-service-redirect.yml" + cmd="${KUBE_LINTER_BIN} lint --include externalname-service-redirect --do-not-auto-add-defaults --format json ${tmp}" + run ${cmd} + + print_info "${status}" "${output}" "${cmd}" "${tmp}" + [ "$status" -eq 1 ] + + count=$(get_value_from "${lines[0]}" '.Reports | length') + [[ "${count}" == "1" ]] +} + @test "host-ipc" { tmp="tests/checks/host-ipc.yml" cmd="${KUBE_LINTER_BIN} lint --include host-ipc --do-not-auto-add-defaults --format json ${tmp}" @@ -435,6 +471,18 @@ get_value_from() { [[ "${count}" == "1" ]] } +@test "image-not-pinned-by-digest" { + tmp="tests/checks/image-not-pinned-by-digest.yml" + cmd="${KUBE_LINTER_BIN} lint --include image-not-pinned-by-digest --do-not-auto-add-defaults --format json ${tmp}" + run ${cmd} + + print_info "${status}" "${output}" "${cmd}" "${tmp}" + [ "$status" -eq 1 ] + + count=$(get_value_from "${lines[0]}" '.Reports | length') + [[ "${count}" == "2" ]] +} + @test "invalid-target-ports" { tmp="tests/checks/invalid-target-ports.yaml" cmd="${KUBE_LINTER_BIN} lint --include invalid-target-ports --do-not-auto-add-defaults --format json ${tmp}" diff --git a/pkg/builtinchecks/yamls/cluster-wide-secrets-access.yaml b/pkg/builtinchecks/yamls/cluster-wide-secrets-access.yaml new file mode 100644 index 000000000..1b83557b9 --- /dev/null +++ b/pkg/builtinchecks/yamls/cluster-wide-secrets-access.yaml @@ -0,0 +1,17 @@ +name: cluster-wide-secrets-access +description: >- + ClusterRoleBinding granting secrets access (get, list, watch, create, + update, patch, delete) across all namespaces. When bound to an operator + service account that reconciles tenant-controlled custom resources, this + enables cross-namespace secret exfiltration (confused deputy). +remediation: >- + Use namespaced RoleBindings to limit secrets access to required namespaces, + or restrict the ClusterRole to specific resource names if cluster-wide + access is necessary. +scope: + objectKinds: + - ClusterRoleBinding +template: "access-to-resources" +params: + resources: ["^secrets$", "^\\*$"] + verbs: ["^get$", "^list$", "^watch$", "^create$", "^update$", "^patch$", "^delete$", "^\\*$"] diff --git a/pkg/builtinchecks/yamls/configmap-with-credentials.yaml b/pkg/builtinchecks/yamls/configmap-with-credentials.yaml new file mode 100644 index 000000000..c429a6691 --- /dev/null +++ b/pkg/builtinchecks/yamls/configmap-with-credentials.yaml @@ -0,0 +1,28 @@ +name: configmap-with-credentials +description: >- + ConfigMap contains keys with credential-like names (password, secret, + token, api_key). Credentials should be stored in Secrets, not ConfigMaps. +remediation: >- + Move credential values to a Secret resource. Reference the Secret from + pod specs using secretKeyRef instead of configMapKeyRef. +scope: + objectKinds: + - Any +template: cel-expression +params: + check: > + object.kind == 'ConfigMap' && + ( + (has(object.data) && + object.data.exists(k, + k.contains('password') || k.contains('secret') || k.contains('token') || + k.contains('api_key') || k.contains('apikey') || k.contains('private_key') || + k.contains('credential') + )) || + (has(object.binaryData) && + object.binaryData.exists(k, + k.contains('password') || k.contains('secret') || k.contains('token') || + k.contains('api_key') || k.contains('apikey') || k.contains('private_key') || + k.contains('credential') + )) + ) ? 'ConfigMap contains credential-like keys — use Secrets instead' : '' diff --git a/pkg/builtinchecks/yamls/externalname-service-redirect.yaml b/pkg/builtinchecks/yamls/externalname-service-redirect.yaml new file mode 100644 index 000000000..c45b4621b --- /dev/null +++ b/pkg/builtinchecks/yamls/externalname-service-redirect.yaml @@ -0,0 +1,19 @@ +name: externalname-service-redirect +description: >- + Service with type: ExternalName redirects internal traffic to an external + DNS name. If the external name resolves to an attacker-controlled host, + this enables SSRF at the infrastructure level. +remediation: >- + Use type: ClusterIP with an Endpoints object pointing to a known-safe IP, + or validate that the externalName resolves to a trusted destination. +scope: + objectKinds: + - Any +template: cel-expression +params: + check: > + object.kind == 'Service' && + has(object.spec) && + object.spec.type == 'ExternalName' && + has(object.spec.externalName) + ? 'ExternalName service redirects cluster traffic to external DNS — potential SSRF vector' : '' diff --git a/pkg/builtinchecks/yamls/image-not-pinned-by-digest.yaml b/pkg/builtinchecks/yamls/image-not-pinned-by-digest.yaml new file mode 100644 index 000000000..150cc7c0b --- /dev/null +++ b/pkg/builtinchecks/yamls/image-not-pinned-by-digest.yaml @@ -0,0 +1,12 @@ +name: "image-not-pinned-by-digest" +description: >- + Container images referenced by tag are mutable — a tag can be moved to + point to a different image at any time. Pinning by digest guarantees + the exact image content that will run. +remediation: >- + Reference container images by digest (e.g. image@sha256:abc123...) + instead of by tag. +scope: + objectKinds: + - DeploymentLike +template: "image-not-pinned-by-digest" diff --git a/pkg/templates/all/all.go b/pkg/templates/all/all.go index 89418b159..8efa6220e 100644 --- a/pkg/templates/all/all.go +++ b/pkg/templates/all/all.go @@ -26,6 +26,7 @@ import ( _ "golang.stackrox.io/kube-linter/pkg/templates/hostnetwork" _ "golang.stackrox.io/kube-linter/pkg/templates/hostpid" _ "golang.stackrox.io/kube-linter/pkg/templates/hpareplicas" + _ "golang.stackrox.io/kube-linter/pkg/templates/imagedigest" _ "golang.stackrox.io/kube-linter/pkg/templates/imagepullpolicy" _ "golang.stackrox.io/kube-linter/pkg/templates/jobttlsecondsafterfinished" _ "golang.stackrox.io/kube-linter/pkg/templates/kubeconform" diff --git a/pkg/templates/imagedigest/internal/params/gen-params.go b/pkg/templates/imagedigest/internal/params/gen-params.go new file mode 100644 index 000000000..03b21164e --- /dev/null +++ b/pkg/templates/imagedigest/internal/params/gen-params.go @@ -0,0 +1,68 @@ +// Code generated by kube-linter template codegen. DO NOT EDIT. +//go:build !templatecodegen +// +build !templatecodegen + +package params + +import ( + "fmt" + "strings" + + "golang.stackrox.io/kube-linter/pkg/check" + "golang.stackrox.io/kube-linter/pkg/templates/util" +) + +var ( + // Use some imports in case they don't get used otherwise. + _ = util.MustParseParameterDesc + + allowListParamDesc = util.MustParseParameterDesc(`{ + "Name": "allowList", + "Type": "array", + "Description": "list of regular expressions specifying pattern(s) for container images to exempt from the digest check.", + "Examples": null, + "Enum": null, + "SubParameters": null, + "ArrayElemType": "string", + "Required": false, + "NoRegex": false, + "NotNegatable": false, + "XXXStructFieldName": "AllowList", + "XXXIsPointer": false +} +`) + + ParamDescs = []check.ParameterDesc{ + allowListParamDesc, + } +) + +func (p *Params) Validate() error { + var validationErrors []string + if len(validationErrors) > 0 { + return fmt.Errorf("invalid parameters: %s", strings.Join(validationErrors, ", ")) + } + return nil +} + +// ParseAndValidate instantiates a Params object out of the passed map[string]interface{}, +// validates it, and returns it. +// The return type is interface{} to satisfy the type in the Template struct. +func ParseAndValidate(m map[string]interface{}) (interface{}, error) { + var p Params + if err := util.DecodeMapStructure(m, &p); err != nil { + return nil, err + } + if err := p.Validate(); err != nil { + return nil, err + } + return p, nil +} + +// WrapInstantiateFunc is a convenience wrapper that wraps an untyped instantiate function +// into a typed one. +func WrapInstantiateFunc(f func(p Params) (check.Func, error)) func(interface{}) (check.Func, error) { + return func(paramsInt interface{}) (check.Func, error) { + return f(paramsInt.(Params)) + } +} diff --git a/pkg/templates/imagedigest/internal/params/params.go b/pkg/templates/imagedigest/internal/params/params.go new file mode 100644 index 000000000..80824e7c9 --- /dev/null +++ b/pkg/templates/imagedigest/internal/params/params.go @@ -0,0 +1,8 @@ +package params + +// Params represents the params accepted by this template. +type Params struct { + + // list of regular expressions specifying pattern(s) for container images to exempt from the digest check. + AllowList []string +} diff --git a/pkg/templates/imagedigest/template.go b/pkg/templates/imagedigest/template.go new file mode 100644 index 000000000..e74b56729 --- /dev/null +++ b/pkg/templates/imagedigest/template.go @@ -0,0 +1,65 @@ +package imagedigest + +import ( + "fmt" + "regexp" + + "golang.stackrox.io/kube-linter/pkg/check" + "golang.stackrox.io/kube-linter/pkg/config" + "golang.stackrox.io/kube-linter/pkg/diagnostic" + "golang.stackrox.io/kube-linter/pkg/objectkinds" + "golang.stackrox.io/kube-linter/pkg/templates" + "golang.stackrox.io/kube-linter/pkg/templates/imagedigest/internal/params" + "golang.stackrox.io/kube-linter/pkg/templates/util" + v1 "k8s.io/api/core/v1" +) + +var digestRefRE = regexp.MustCompile(`@sha256:[a-f0-9]{64}`) + +const ( + templateKey = "image-not-pinned-by-digest" +) + +func init() { + templates.Register(check.Template{ + HumanName: "Image Not Pinned By Digest", + Key: templateKey, + Description: "Flag container images that are not pinned to a specific digest", + SupportedObjectKinds: config.ObjectKindsDesc{ + ObjectKinds: []string{objectkinds.DeploymentLike}, + }, + Parameters: params.ParamDescs, + ParseAndValidateParams: params.ParseAndValidate, + Instantiate: params.WrapInstantiateFunc(func(p params.Params) (check.Func, error) { + allowedRegexes := make([]*regexp.Regexp, 0, len(p.AllowList)) + for _, res := range p.AllowList { + rg, err := regexp.Compile(res) + if err != nil { + return nil, fmt.Errorf("invalid regex %s: %w", res, err) + } + allowedRegexes = append(allowedRegexes, rg) + } + + return util.PerContainerCheck(func(container *v1.Container) (results []diagnostic.Diagnostic) { + if isAllowed(allowedRegexes, container.Image) { + return nil + } + if !digestRefRE.MatchString(container.Image) { + results = append(results, diagnostic.Diagnostic{ + Message: fmt.Sprintf("container %q image %q is not pinned by digest", container.Name, container.Image), + }) + } + return results + }), nil + }), + }) +} + +func isAllowed(regexlist []*regexp.Regexp, name string) bool { + for _, regex := range regexlist { + if regex.MatchString(name) { + return true + } + } + return false +} diff --git a/tests/checks/cluster-wide-secrets-access.yml b/tests/checks/cluster-wide-secrets-access.yml new file mode 100644 index 000000000..759fbfe67 --- /dev/null +++ b/tests/checks/cluster-wide-secrets-access.yml @@ -0,0 +1,46 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: secrets-reader +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: fire-cluster-secrets +subjects: + - kind: ServiceAccount + name: operator-sa + namespace: operators +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: secrets-reader +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: ns-secrets-reader + namespace: default +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: dont-fire-ns-scoped + namespace: default +subjects: + - kind: ServiceAccount + name: app-sa + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ns-secrets-reader diff --git a/tests/checks/configmap-with-credentials.yml b/tests/checks/configmap-with-credentials.yml new file mode 100644 index 000000000..963941ea2 --- /dev/null +++ b/tests/checks/configmap-with-credentials.yml @@ -0,0 +1,16 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: dont-fire +data: + config.yaml: | + logLevel: info +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: fire-password-key +data: + database_password: "hunter2" + connection_string: "postgres://localhost:5432" diff --git a/tests/checks/externalname-service-redirect.yml b/tests/checks/externalname-service-redirect.yml new file mode 100644 index 000000000..3b79e6b86 --- /dev/null +++ b/tests/checks/externalname-service-redirect.yml @@ -0,0 +1,17 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: dont-fire +spec: + type: ClusterIP + ports: + - port: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: fire-externalname +spec: + type: ExternalName + externalName: attacker.example.com diff --git a/tests/checks/image-not-pinned-by-digest.yml b/tests/checks/image-not-pinned-by-digest.yml new file mode 100644 index 000000000..c6d2652f8 --- /dev/null +++ b/tests/checks/image-not-pinned-by-digest.yml @@ -0,0 +1,33 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dont-fire +spec: + template: + spec: + containers: + - name: app + image: registry.example.com/app@sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fire-tag-only +spec: + template: + spec: + containers: + - name: app + image: registry.example.com/app:v1.2.3 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fire-no-tag +spec: + template: + spec: + containers: + - name: app + image: registry.example.com/app