Files
project-infra/docs/examples/infra/security-hardening.md
T

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를 예방.