529 lines
14 KiB
Markdown
529 lines
14 KiB
Markdown
# security hardening 예시
|
|
|
|
이 문서의 모든 YAML은 `kubectl apply --dry-run=server -f -` 기준 clean을 목표로 한다. 1000+ 서비스 운영 클러스터의 auth-server namespace를 기준 예시로 사용한다.
|
|
|
|
---
|
|
|
|
## 좋은 예시 1: Namespace에 Pod Security Admission 라벨 enforce
|
|
|
|
```yaml
|
|
apiVersion: v1
|
|
kind: Namespace
|
|
metadata:
|
|
name: auth-prod
|
|
labels:
|
|
app.kubernetes.io/part-of: identity-platform
|
|
pod-security.kubernetes.io/enforce: restricted
|
|
pod-security.kubernetes.io/enforce-version: v1.29
|
|
pod-security.kubernetes.io/audit: restricted
|
|
pod-security.kubernetes.io/audit-version: v1.29
|
|
pod-security.kubernetes.io/warn: restricted
|
|
pod-security.kubernetes.io/warn-version: v1.29
|
|
annotations:
|
|
platform.example.com/owner: identity-team
|
|
platform.example.com/adr: ADR-0017-psa-restricted-baseline
|
|
```
|
|
|
|
**왜 좋은가:**
|
|
|
|
- 운영 namespace의 PSA 기본값을 `restricted`로 enforce. violation Pod는 API server 단에서 reject된다.
|
|
- version을 pin해 Kubernetes 업그레이드 시 silent behavior drift를 방지한다.
|
|
- audit/warn을 함께 붙여 위반을 audit log와 kubectl warning으로 수집한다.
|
|
|
|
---
|
|
|
|
## 좋은 예시 2: Restricted 프로파일을 완전히 만족하는 Deployment
|
|
|
|
```yaml
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: auth-server
|
|
namespace: auth-prod
|
|
labels:
|
|
app.kubernetes.io/name: auth-server
|
|
app.kubernetes.io/instance: auth-server
|
|
app.kubernetes.io/component: api
|
|
app.kubernetes.io/part-of: identity-platform
|
|
app.kubernetes.io/managed-by: argocd
|
|
automountServiceAccountToken: false
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: auth-server
|
|
namespace: auth-prod
|
|
labels:
|
|
app.kubernetes.io/name: auth-server
|
|
app.kubernetes.io/instance: auth-server
|
|
app.kubernetes.io/component: api
|
|
app.kubernetes.io/part-of: identity-platform
|
|
app.kubernetes.io/version: 1.42.0
|
|
app.kubernetes.io/managed-by: argocd
|
|
spec:
|
|
replicas: 6
|
|
revisionHistoryLimit: 5
|
|
strategy:
|
|
type: RollingUpdate
|
|
rollingUpdate:
|
|
maxSurge: 25%
|
|
maxUnavailable: 0
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: auth-server
|
|
app.kubernetes.io/instance: auth-server
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: auth-server
|
|
app.kubernetes.io/instance: auth-server
|
|
app.kubernetes.io/component: api
|
|
app.kubernetes.io/part-of: identity-platform
|
|
app.kubernetes.io/version: 1.42.0
|
|
app.kubernetes.io/managed-by: argocd
|
|
spec:
|
|
serviceAccountName: auth-server
|
|
automountServiceAccountToken: false
|
|
terminationGracePeriodSeconds: 30
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
runAsUser: 10001
|
|
runAsGroup: 10001
|
|
fsGroup: 10001
|
|
fsGroupChangePolicy: OnRootMismatch
|
|
seccompProfile:
|
|
type: RuntimeDefault
|
|
topologySpreadConstraints:
|
|
- maxSkew: 1
|
|
topologyKey: topology.kubernetes.io/zone
|
|
whenUnsatisfiable: ScheduleAnyway
|
|
labelSelector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: auth-server
|
|
containers:
|
|
- name: auth-server
|
|
image: registry.example.com/identity/auth-server@sha256:8f3c0a8c6b3a2a7a0f1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071
|
|
imagePullPolicy: IfNotPresent
|
|
ports:
|
|
- name: http
|
|
containerPort: 8080
|
|
protocol: TCP
|
|
- name: metrics
|
|
containerPort: 9090
|
|
protocol: TCP
|
|
env:
|
|
- name: JAVA_TOOL_OPTIONS
|
|
value: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
|
|
envFrom:
|
|
- secretRef:
|
|
name: auth-server-db
|
|
resources:
|
|
requests:
|
|
cpu: 200m
|
|
memory: 512Mi
|
|
limits:
|
|
cpu: "2"
|
|
memory: 1Gi
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /actuator/health/readiness
|
|
port: http
|
|
initialDelaySeconds: 5
|
|
periodSeconds: 5
|
|
failureThreshold: 3
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /actuator/health/liveness
|
|
port: http
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 10
|
|
failureThreshold: 5
|
|
startupProbe:
|
|
httpGet:
|
|
path: /actuator/health/liveness
|
|
port: http
|
|
failureThreshold: 30
|
|
periodSeconds: 5
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
runAsUser: 10001
|
|
runAsGroup: 10001
|
|
allowPrivilegeEscalation: false
|
|
privileged: false
|
|
readOnlyRootFilesystem: true
|
|
capabilities:
|
|
drop: ["ALL"]
|
|
seccompProfile:
|
|
type: RuntimeDefault
|
|
volumeMounts:
|
|
- name: tmp
|
|
mountPath: /tmp
|
|
- name: workdir
|
|
mountPath: /workspace
|
|
volumes:
|
|
- name: tmp
|
|
emptyDir:
|
|
medium: Memory
|
|
sizeLimit: 64Mi
|
|
- name: workdir
|
|
emptyDir:
|
|
sizeLimit: 256Mi
|
|
imagePullSecrets:
|
|
- name: registry-example-com
|
|
```
|
|
|
|
**왜 좋은가:**
|
|
|
|
- `restricted` 프로파일의 전 필드(runAsNonRoot, numeric UID/GID, fsGroup, seccompProfile, allowPrivilegeEscalation, readOnlyRootFilesystem, drop ALL capabilities)를 Pod+컨테이너 양쪽에 일관 명시한다.
|
|
- image는 digest pin. mutable tag에 의존하지 않는다.
|
|
- ServiceAccount는 전용 SA + `automountServiceAccountToken: false`.
|
|
- writable 경로는 `emptyDir`로 분리해 root FS는 read-only 유지.
|
|
|
|
---
|
|
|
|
## 나쁜 예시 1: Restricted 프로파일 위반 Pod
|
|
|
|
```yaml
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: auth-server
|
|
namespace: auth-prod
|
|
spec:
|
|
replicas: 1
|
|
selector:
|
|
matchLabels:
|
|
app: auth-server
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app: auth-server
|
|
spec:
|
|
containers:
|
|
- name: auth-server
|
|
image: auth-server:latest
|
|
securityContext:
|
|
privileged: true
|
|
```
|
|
|
|
**문제:**
|
|
|
|
- `privileged: true`는 baseline조차 위반. PSA enforce=restricted namespace에서는 API server가 reject한다.
|
|
- `runAsNonRoot`, `allowPrivilegeEscalation`, `capabilities.drop`, `seccompProfile`, `readOnlyRootFilesystem` 전부 누락.
|
|
- image tag `latest`는 digest 고정 없이 rolling silently breaks.
|
|
- SA 미지정 → `default` SA가 토큰 자동 마운트.
|
|
|
|
---
|
|
|
|
## 좋은 예시 3: Default-deny + DNS + Ingress + DB + Prometheus allow NetworkPolicy 세트
|
|
|
|
```yaml
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: default-deny-all
|
|
namespace: auth-prod
|
|
spec:
|
|
podSelector: {}
|
|
policyTypes:
|
|
- Ingress
|
|
- Egress
|
|
---
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: allow-dns-egress
|
|
namespace: auth-prod
|
|
spec:
|
|
podSelector: {}
|
|
policyTypes:
|
|
- Egress
|
|
egress:
|
|
- to:
|
|
- namespaceSelector:
|
|
matchLabels:
|
|
kubernetes.io/metadata.name: kube-system
|
|
podSelector:
|
|
matchLabels:
|
|
k8s-app: kube-dns
|
|
ports:
|
|
- protocol: UDP
|
|
port: 53
|
|
- protocol: TCP
|
|
port: 53
|
|
---
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: allow-from-ingress-traefik
|
|
namespace: auth-prod
|
|
spec:
|
|
podSelector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: auth-server
|
|
policyTypes:
|
|
- Ingress
|
|
ingress:
|
|
- from:
|
|
- namespaceSelector:
|
|
matchLabels:
|
|
kubernetes.io/metadata.name: ingress-traefik
|
|
podSelector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: traefik
|
|
ports:
|
|
- protocol: TCP
|
|
port: 8080
|
|
---
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: allow-to-postgres
|
|
namespace: auth-prod
|
|
spec:
|
|
podSelector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: auth-server
|
|
policyTypes:
|
|
- Egress
|
|
egress:
|
|
- to:
|
|
- namespaceSelector:
|
|
matchLabels:
|
|
kubernetes.io/metadata.name: data-prod
|
|
podSelector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: postgres
|
|
app.kubernetes.io/instance: identity-postgres
|
|
ports:
|
|
- protocol: TCP
|
|
port: 5432
|
|
---
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: allow-metrics-scrape-from-prometheus
|
|
namespace: auth-prod
|
|
spec:
|
|
podSelector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: auth-server
|
|
policyTypes:
|
|
- Ingress
|
|
ingress:
|
|
- from:
|
|
- namespaceSelector:
|
|
matchLabels:
|
|
kubernetes.io/metadata.name: monitoring
|
|
podSelector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: prometheus
|
|
ports:
|
|
- protocol: TCP
|
|
port: 9090
|
|
```
|
|
|
|
**왜 좋은가:**
|
|
|
|
- `namespaceSelector`와 `podSelector`가 **동일 `from` 엔트리** 안에 있으므로 AND(교집합): monitoring namespace 안의 Prometheus Pod만 9090 scrape 허용된다.
|
|
- default-deny + minimum allow 세트로 ingress/egress 모두 통제.
|
|
- DNS는 `kube-system`의 `k8s-app=kube-dns` Pod로 한정, egress 전체를 열지 않음.
|
|
|
|
---
|
|
|
|
## 나쁜 예시 2: NetworkPolicy AND/OR 혼동
|
|
|
|
```yaml
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: broken-scrape
|
|
namespace: auth-prod
|
|
spec:
|
|
podSelector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: auth-server
|
|
policyTypes:
|
|
- Ingress
|
|
ingress:
|
|
- from:
|
|
- namespaceSelector:
|
|
matchLabels:
|
|
kubernetes.io/metadata.name: monitoring
|
|
- podSelector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: prometheus
|
|
ports:
|
|
- protocol: TCP
|
|
port: 9090
|
|
```
|
|
|
|
**문제:**
|
|
|
|
- `namespaceSelector`와 `podSelector`가 **별도 엔트리**(두 개의 `-`) → OR로 해석된다.
|
|
- 결과: ① monitoring namespace의 **모든 Pod**가 허용되고, ② `auth-prod` namespace의 label `app.kubernetes.io/name=prometheus`를 가진 **아무 Pod**도 허용된다.
|
|
- 의도했던 "monitoring의 Prometheus만 허용"이 아니라 훨씬 넓은 경로가 열린다. 실제 클러스터에서 NetworkPolicy 버그의 1순위.
|
|
|
|
---
|
|
|
|
## 좋은 예시 4: Namespace-scoped RBAC (Role + RoleBinding)
|
|
|
|
```yaml
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: auth-server-secret-rotator
|
|
namespace: auth-prod
|
|
automountServiceAccountToken: true
|
|
---
|
|
apiVersion: rbac.authorization.k8s.io/v1
|
|
kind: Role
|
|
metadata:
|
|
name: auth-server-secret-reader
|
|
namespace: auth-prod
|
|
rules:
|
|
- apiGroups: [""]
|
|
resources: ["secrets"]
|
|
resourceNames:
|
|
- auth-server-db
|
|
- auth-server-oidc-client
|
|
verbs: ["get"]
|
|
---
|
|
apiVersion: rbac.authorization.k8s.io/v1
|
|
kind: RoleBinding
|
|
metadata:
|
|
name: auth-server-secret-reader
|
|
namespace: auth-prod
|
|
subjects:
|
|
- kind: ServiceAccount
|
|
name: auth-server-secret-rotator
|
|
namespace: auth-prod
|
|
roleRef:
|
|
apiGroup: rbac.authorization.k8s.io
|
|
kind: Role
|
|
name: auth-server-secret-reader
|
|
```
|
|
|
|
**왜 좋은가:**
|
|
|
|
- namespace 경계 안에서 특정 Secret 이름 2개만 `get`. `list`/`watch` 미부여.
|
|
- SA/Role/RoleBinding 모두 같은 namespace에 명시. `---`로 분리된 다중 리소스 문서.
|
|
- `system:masters`나 `cluster-admin` 같은 전능 role과 무관.
|
|
|
|
---
|
|
|
|
## 나쁜 예시 3: cluster-admin ClusterRoleBinding 남용
|
|
|
|
```yaml
|
|
apiVersion: rbac.authorization.k8s.io/v1
|
|
kind: ClusterRoleBinding
|
|
metadata:
|
|
name: auth-server-admin
|
|
subjects:
|
|
- kind: ServiceAccount
|
|
name: auth-server
|
|
namespace: auth-prod
|
|
roleRef:
|
|
kind: ClusterRole
|
|
name: cluster-admin
|
|
apiGroup: rbac.authorization.k8s.io
|
|
```
|
|
|
|
**문제:**
|
|
|
|
- 단일 SA가 모든 namespace의 모든 리소스(Secret, Node, CRD)를 수정할 수 있다.
|
|
- 앱 노드 1개가 compromise되면 전체 클러스터가 compromise된다.
|
|
- least privilege 원칙의 정반대.
|
|
|
|
---
|
|
|
|
## 좋은 예시 5: Private registry ImagePullSecret
|
|
|
|
```yaml
|
|
apiVersion: v1
|
|
kind: Secret
|
|
metadata:
|
|
name: registry-example-com
|
|
namespace: auth-prod
|
|
type: kubernetes.io/dockerconfigjson
|
|
data:
|
|
.dockerconfigjson: eyJhdXRocyI6eyJyZWdpc3RyeS5leGFtcGxlLmNvbSI6eyJ1c2VybmFtZSI6ImNpLWJvdCIsInBhc3N3b3JkIjoiPFJFREFDVEVEPiIsImF1dGgiOiI8UkVEQUNURUQ+In19fQ==
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: auth-server
|
|
namespace: auth-prod
|
|
automountServiceAccountToken: false
|
|
imagePullSecrets:
|
|
- name: registry-example-com
|
|
```
|
|
|
|
**왜 좋은가:**
|
|
|
|
- type이 `kubernetes.io/dockerconfigjson`으로 정확. kubelet이 이 포맷만 pull credential로 인식한다.
|
|
- SA에 `imagePullSecrets`를 묶어 Deployment 마다 반복 선언 불필요.
|
|
- 실제 운영에서는 이 Secret 자체도 VSO로 Vault → K8s로 sync(config-and-secrets 문서 참고).
|
|
|
|
---
|
|
|
|
## 나쁜 예시 4: 정책 없는 운영 namespace
|
|
|
|
```yaml
|
|
apiVersion: v1
|
|
kind: Namespace
|
|
metadata:
|
|
name: auth-prod
|
|
```
|
|
|
|
**문제:**
|
|
|
|
- PSA 라벨 없음 → `privileged` Pod도 통과.
|
|
- NetworkPolicy 없음 → ingress/egress 모두 allow-all. 침해 시 lateral movement 자유.
|
|
- ResourceQuota/LimitRange 없음 → 한 Deployment가 namespace CPU/memory 전부 점유 가능.
|
|
- 1000-서비스 운영에서 이런 namespace는 허용되지 않는다.
|
|
|
|
---
|
|
|
|
## 좋은 예시 6: ResourceQuota + LimitRange 묶음
|
|
|
|
```yaml
|
|
apiVersion: v1
|
|
kind: ResourceQuota
|
|
metadata:
|
|
name: auth-prod-quota
|
|
namespace: auth-prod
|
|
spec:
|
|
hard:
|
|
requests.cpu: "50"
|
|
requests.memory: 100Gi
|
|
limits.cpu: "100"
|
|
limits.memory: 200Gi
|
|
pods: "200"
|
|
services.loadbalancers: "0"
|
|
services.nodeports: "0"
|
|
---
|
|
apiVersion: v1
|
|
kind: LimitRange
|
|
metadata:
|
|
name: auth-prod-defaults
|
|
namespace: auth-prod
|
|
spec:
|
|
limits:
|
|
- type: Container
|
|
default:
|
|
cpu: 500m
|
|
memory: 512Mi
|
|
defaultRequest:
|
|
cpu: 100m
|
|
memory: 128Mi
|
|
max:
|
|
cpu: "4"
|
|
memory: 4Gi
|
|
```
|
|
|
|
**왜 좋은가:**
|
|
|
|
- `services.loadbalancers=0`, `services.nodeports=0`으로 namespace 내 외부 노출 Service 생성을 금지(ingress 경유 강제).
|
|
- LimitRange로 컨테이너별 default request/limit을 보장해 limit 누락 Pod를 예방.
|