Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/iac-security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: IaC Security

# Zero Trust policy-as-code gate. Runs the Conftest/OPA policies and a Trivy
# misconfiguration scan against the Kubernetes manifests on every change so the
# hardening controls cannot regress.
on:
pull_request:
paths:
- "kubernetes/**"
- "policy/**"
- ".github/workflows/iac-security.yml"
push:
branches:
- DevOps
paths:
- "kubernetes/**"
- "policy/**"
- ".github/workflows/iac-security.yml"

permissions:
contents: read

jobs:
policy-as-code:
name: Conftest (OPA) policy gate
runs-on: ubuntu-latest
env:
CONFTEST_VERSION: 0.56.0
steps:
- uses: actions/checkout@v4

- name: Install Conftest
run: |
curl -sSL -o conftest.tar.gz \
"https://github.com/open-policy-agent/conftest/releases/download/v${CONFTEST_VERSION}/conftest_${CONFTEST_VERSION}_Linux_x86_64.tar.gz"
tar xzf conftest.tar.gz conftest
sudo install conftest /usr/local/bin/conftest
conftest --version

- name: Run policy unit tests
run: conftest verify --policy policy

- name: Enforce pod/container security context
run: conftest test kubernetes/bankapp-deployment.yml kubernetes/mysql-deployment.yml --policy policy

- name: Enforce default-deny NetworkPolicy
run: conftest test --combine kubernetes/ --namespace netpol --policy policy

trivy-iac:
name: Trivy IaC misconfiguration scan
runs-on: ubuntu-latest
env:
TRIVY_VERSION: 0.71.0
steps:
- uses: actions/checkout@v4

- name: Install Trivy
run: |
curl -sSL -o trivy.tar.gz \
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
tar xzf trivy.tar.gz trivy
sudo install trivy /usr/local/bin/trivy
trivy --version

- name: Trivy config scan (fail on HIGH/CRITICAL)
run: trivy config --severity HIGH,CRITICAL --exit-code 1 kubernetes/
27 changes: 27 additions & 0 deletions kubernetes/bankapp-deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,30 @@ spec:
labels:
app: bankapp-deploy
spec:
# Zero Trust: run the whole pod as an unprivileged user and apply the
# runtime's default seccomp profile so syscalls are filtered by default.
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: bankapp
image: trainwithshubham/bankapp-eks:v2
ports:
- containerPort: 8080
# Zero Trust: drop every Linux capability, forbid privilege escalation
# and make the root filesystem read-only to shrink the attack surface.
securityContext:
allowPrivilegeEscalation: false
privileged: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
env:
- name: SPRING_DATASOURCE_URL
valueFrom:
Expand Down Expand Up @@ -60,4 +79,12 @@ spec:
limits:
memory: "1Gi"
cpu: "500m"
# Writable scratch space required because the root filesystem is read-only.
# The JVM and embedded Tomcat only need a writable temp dir.
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}

32 changes: 32 additions & 0 deletions kubernetes/mysql-deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,30 @@ spec:
labels:
app: mysql
spec:
# Zero Trust: run MySQL as the unprivileged mysql user (uid/gid 999 in the
# official image) and apply the runtime default seccomp profile.
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: mysql
image: mysql:8.0 # Use a specific, stable version for production
ports:
- containerPort: 3306
# Zero Trust: drop all capabilities, forbid privilege escalation and run
# on a read-only root filesystem (writable paths are mounted explicitly).
securityContext:
allowPrivilegeEscalation: false
privileged: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
Expand All @@ -35,8 +54,21 @@ spec:
- name: mysql-pv-storage
mountPath: /var/lib/mysql
subPath: mysql-data # Optional: Ensure a subdirectory is used for better volume organization
# Writable paths required by mysqld when the root filesystem is read-only.
- name: run
mountPath: /var/run/mysqld
- name: tmp
mountPath: /tmp
- name: files
mountPath: /var/lib/mysql-files
volumes:
- name: mysql-pv-storage
persistentVolumeClaim:
claimName: mysql-pvc
- name: run
emptyDir: {}
- name: tmp
emptyDir: {}
- name: files
emptyDir: {}

57 changes: 57 additions & 0 deletions kubernetes/network-policies.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Zero Trust network segmentation for the bankapp namespace.
# Default-deny all ingress, then explicitly allow only the required east-west and
# north-south paths. This stops lateral movement if any pod is compromised.
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: bankapp-namespace
spec:
# Empty podSelector selects every pod in the namespace.
podSelector: {}
policyTypes:
- Ingress
# No ingress rules => deny all inbound traffic by default.
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-bankapp-from-ingress-nginx
namespace: bankapp-namespace
spec:
podSelector:
matchLabels:
app: bankapp-deploy
policyTypes:
- Ingress
ingress:
# Only the NGINX ingress controller may reach the app on 8080.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- protocol: TCP
port: 8080
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-mysql-from-bankapp
namespace: bankapp-namespace
spec:
podSelector:
matchLabels:
app: mysql
policyTypes:
- Ingress
ingress:
# The database accepts connections only from the application pods on 3306.
- from:
- podSelector:
matchLabels:
app: bankapp-deploy
ports:
- protocol: TCP
port: 3306
26 changes: 26 additions & 0 deletions policy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Kubernetes security policy-as-code

Conftest/OPA policies that enforce the Zero Trust controls applied to the
manifests in `kubernetes/`. They run in CI via `.github/workflows/iac-security.yml`
and can be run locally:

```bash
# Rego unit tests
conftest verify --policy policy

# Enforce hardened pod/container security context on the workloads
conftest test kubernetes/bankapp-deployment.yml kubernetes/mysql-deployment.yml --policy policy

# Enforce a default-deny ingress NetworkPolicy exists in the namespace
conftest test --combine kubernetes/ --namespace netpol --policy policy
```

## Controls enforced

| Control | Threat mitigated |
| --- | --- |
| `runAsNonRoot` + `seccompProfile: RuntimeDefault` (pod) | Container breakout / host compromise via root + unrestricted syscalls |
| `allowPrivilegeEscalation: false`, `privileged: false` | Privilege escalation inside the container |
| `readOnlyRootFilesystem: true` | Malware persistence / tampering with the container filesystem |
| `capabilities.drop: [ALL]` | Abuse of Linux capabilities (e.g. `NET_RAW` spoofing) |
| Default-deny ingress NetworkPolicy | East-west lateral movement after a pod compromise |
62 changes: 62 additions & 0 deletions policy/kubernetes-security.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Policy-as-code gate enforcing the Zero Trust controls applied to the bankapp
# Kubernetes manifests. Evaluated with Conftest (OPA) in CI so the controls
# cannot silently regress.
#
# conftest test kubernetes/*.yml kubernetes/*.yaml --policy policy
# conftest test --combine kubernetes/ --namespace netpol --policy policy
package main

import rego.v1

is_workload if input.kind == "Deployment"

workload_id := sprintf("%s/%s", [input.kind, input.metadata.name])

# --- Pod-level controls -----------------------------------------------------

deny contains msg if {
is_workload
not input.spec.template.spec.securityContext.runAsNonRoot == true
msg := sprintf("%s: pod securityContext.runAsNonRoot must be true", [workload_id])
}

deny contains msg if {
is_workload
not input.spec.template.spec.securityContext.seccompProfile.type == "RuntimeDefault"
msg := sprintf("%s: pod securityContext.seccompProfile.type must be RuntimeDefault", [workload_id])
}

# --- Container-level controls ----------------------------------------------

deny contains msg if {
is_workload
some c in input.spec.template.spec.containers
not c.securityContext.allowPrivilegeEscalation == false
msg := sprintf("%s container %s: securityContext.allowPrivilegeEscalation must be false", [workload_id, c.name])
}

deny contains msg if {
is_workload
some c in input.spec.template.spec.containers
c.securityContext.privileged == true
msg := sprintf("%s container %s: securityContext.privileged must not be true", [workload_id, c.name])
}

deny contains msg if {
is_workload
some c in input.spec.template.spec.containers
not c.securityContext.readOnlyRootFilesystem == true
msg := sprintf("%s container %s: securityContext.readOnlyRootFilesystem must be true", [workload_id, c.name])
}

deny contains msg if {
is_workload
some c in input.spec.template.spec.containers
not drops_all_capabilities(c)
msg := sprintf("%s container %s: securityContext.capabilities.drop must include ALL", [workload_id, c.name])
}

drops_all_capabilities(c) if {
some cap in c.securityContext.capabilities.drop
cap == "ALL"
}
54 changes: 54 additions & 0 deletions policy/kubernetes-security_test.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package main

import rego.v1

hardened_deployment := {
"kind": "Deployment",
"metadata": {"name": "bankapp-deploy"},
"spec": {"template": {"spec": {
"securityContext": {
"runAsNonRoot": true,
"seccompProfile": {"type": "RuntimeDefault"},
},
"containers": [{
"name": "bankapp",
"securityContext": {
"allowPrivilegeEscalation": false,
"readOnlyRootFilesystem": true,
"capabilities": {"drop": ["ALL"]},
},
}],
}}},
}

insecure_deployment := {
"kind": "Deployment",
"metadata": {"name": "legacy"},
"spec": {"template": {"spec": {"containers": [{"name": "legacy"}]}}},
}

test_hardened_deployment_passes if {
count(deny) == 0 with input as hardened_deployment
}

test_insecure_deployment_is_denied if {
count(deny) > 0 with input as insecure_deployment
}

test_missing_drop_all_is_denied if {
d := json.patch(hardened_deployment, [{
"op": "replace",
"path": "/spec/template/spec/containers/0/securityContext/capabilities/drop",
"value": ["NET_RAW"],
}])
count(deny) > 0 with input as d
}

test_writable_root_filesystem_is_denied if {
d := json.patch(hardened_deployment, [{
"op": "replace",
"path": "/spec/template/spec/containers/0/securityContext/readOnlyRootFilesystem",
"value": false,
}])
count(deny) > 0 with input as d
}
20 changes: 20 additions & 0 deletions policy/networkpolicy.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Combined-mode policy: asserts the namespace ships a default-deny ingress
# NetworkPolicy. Run with:
#
# conftest test --combine kubernetes/ --namespace netpol --policy policy
package netpol

import rego.v1

default_deny_exists if {
some f in input
np := f.contents
np.kind == "NetworkPolicy"
np.spec.podSelector == {}
"Ingress" in np.spec.policyTypes
}

deny contains msg if {
not default_deny_exists
msg := "no default-deny ingress NetworkPolicy found in the bankapp namespace manifests"
}
Loading
Loading