diff --git a/iac/ql/lib/codeql/iac/YamlDocumentClassification.qll b/iac/ql/lib/codeql/iac/YamlDocumentClassification.qll index 9829bfe845d7..bff776bff270 100644 --- a/iac/ql/lib/codeql/iac/YamlDocumentClassification.qll +++ b/iac/ql/lib/codeql/iac/YamlDocumentClassification.qll @@ -22,6 +22,7 @@ import iac private import codeql.iac.YAML private import codeql.iac.azure.Pipelines private import codeql.iac.helmcharts.HelmChart +private import codeql.iac.kubernetes.Kubernetes private import codeql.iac.compose.Compose private import codeql.iac.openapi.OpenApi private import codeql.iac.aws.CloudFormation @@ -69,7 +70,8 @@ module YamlDocumentClassification { ( doc instanceof AzurePipelines::Document and kind = "ado-pipeline" or - doc instanceof HelmChart::Document and kind = "kubernetes-helm" + (doc instanceof HelmChart::Document or doc instanceof YamlKubernetes::Document) and + kind = "kubernetes-helm" or doc instanceof Compose::Document and kind = "compose" or diff --git a/iac/ql/lib/codeql/iac/compose/Compose.qll b/iac/ql/lib/codeql/iac/compose/Compose.qll index 496d123c4483..78b0cca8a045 100644 --- a/iac/ql/lib/codeql/iac/compose/Compose.qll +++ b/iac/ql/lib/codeql/iac/compose/Compose.qll @@ -34,6 +34,12 @@ module Compose { * Returns the services defined in the Compose file. */ Service getServices() { result = this.lookup("services").getAChildNode() } + + YamlValue getNetworks() { result = this.lookup("networks") } + + YamlValue getVolumes() { result = this.lookup("volumes") } + + YamlValue getSecrets() { result = this.lookup("secrets") } } /** @@ -51,8 +57,73 @@ module Compose { * Returns the name of the service. */ string getName() { - result = this.lookup("container_name").toString() - // TODO get parent key name + result = yamlToString(this.lookup("container_name")) + or + exists(YamlMapping services, YamlValue key, YamlValue value | + services = compose.lookup("services") and + services.maps(key, value) and + value = this and + result = key.toString() + ) } + + string getImage() { result = yamlToString(this.lookup("image")) } + + YamlValue getBuild() { result = this.lookup("build") } + + YamlValue getEnvironment() { result = this.lookup("environment") } + + EnvironmentEntry getEnvironmentEntries() { + result = this.lookup("environment").(YamlSequence).getAChild() + or + result = this.lookup("environment").(YamlMapping).getAChild() + } + + YamlValue getSecrets() { result = this.lookup("secrets") } + + YamlValue getVolumes() { result = this.lookup("volumes") } + + YamlValue getCapAdd() { result = this.lookup("cap_add") } + + YamlValue getCapDrop() { result = this.lookup("cap_drop") } + + YamlValue getPrivileged() { result = this.lookup("privileged") } + + YamlValue getReadOnly() { result = this.lookup("read_only") } + + YamlValue getUser() { result = this.lookup("user") } + + YamlValue getPid() { result = this.lookup("pid") } + + YamlValue getNetworkMode() { result = this.lookup("network_mode") } + + YamlValue getDevices() { result = this.lookup("devices") } + } + + class EnvironmentEntry extends YamlValue { + EnvironmentEntry() { + exists(Service service | + service.lookup("environment").(YamlSequence).getAChild() = this + ) + or + exists(Service service | + service.lookup("environment").(YamlMapping).getAChild() = this + ) + } + + string getName() { + exists(YamlMapping environment, YamlValue key, YamlValue value | + environment.maps(key, value) and + value = this and + result = yamlToString(key.(YamlString)) + ) + or + result = this.(YamlString).getValue().regexpCapture("([^=]+)=.*", 1) + or + result = this.(YamlString).getValue() and + not result.matches("%=%") + } + + YamlValue getValue() { result = this } } } diff --git a/iac/ql/lib/codeql/iac/kubernetes/Kubernetes.qll b/iac/ql/lib/codeql/iac/kubernetes/Kubernetes.qll new file mode 100644 index 000000000000..30f1cc123213 --- /dev/null +++ b/iac/ql/lib/codeql/iac/kubernetes/Kubernetes.qll @@ -0,0 +1,227 @@ +private import codeql.iac.YAML +private import codeql.files.FileSystem + +/** + * Structural model for Kubernetes YAML manifests. + * + * The model intentionally covers locally observable workload and RBAC + * structure. It does not attempt to resolve admission policies, rendered Helm + * values, or cluster-level defaults. + */ +module YamlKubernetes { + class Document extends YamlNode, YamlDocument, YamlMapping { + Document() { + this.getFile().getExtension() = ["yml", "yaml"] and + exists(this.lookup("apiVersion")) and + exists(this.lookup("kind")) and + this.lookup("kind").(YamlString).getValue() != "Chart" + } + + override string toString() { result = "Kubernetes " + this.getKind() + " document" } + + string getApiVersion() { result = yamlToString(this.lookup("apiVersion")) } + + string getKind() { result = yamlToString(this.lookup("kind")) } + + Metadata getMetadata() { result = this.lookup("metadata") } + + YamlMapping getSpec() { result = this.lookup("spec") } + + PodSpec getPodSpec() { result.getDocument() = this } + + Container getContainers() { result = this.getPodSpec().getContainers() } + + Container getInitContainers() { result = this.getPodSpec().getInitContainers() } + + Container getEphemeralContainers() { result = this.getPodSpec().getEphemeralContainers() } + } + + class Metadata extends YamlNode, YamlMapping { + Metadata() { exists(Document document | document.lookup("metadata") = this) } + + string getName() { result = yamlToString(this.lookup("name")) } + + string getNamespace() { result = yamlToString(this.lookup("namespace")) } + + YamlValue getLabels() { result = this.lookup("labels") } + + YamlValue getAnnotations() { result = this.lookup("annotations") } + } + + class PodSpec extends YamlNode, YamlMapping { + PodSpec() { + exists(Document document | + document.getKind() = "Pod" and + document.lookup("spec") = this + ) + or + exists(Document document | + document.getKind() = + [ + "DaemonSet", "Deployment", "Job", "ReplicaSet", "ReplicationController", "StatefulSet", + ] and + document.lookup("spec").(YamlMapping).lookup("template").(YamlMapping).lookup("spec") = this + ) + or + exists(Document document | + document.getKind() = "CronJob" and + document.lookup("spec").(YamlMapping).lookup("jobTemplate").(YamlMapping).lookup("spec") + .(YamlMapping).lookup("template").(YamlMapping).lookup("spec") = this + ) + } + + Container getContainers() { result = this.lookup("containers").(YamlSequence).getAChild() } + + Container getInitContainers() { + result = this.lookup("initContainers").(YamlSequence).getAChild() + } + + Container getEphemeralContainers() { + result = this.lookup("ephemeralContainers").(YamlSequence).getAChild() + } + + SecurityContext getSecurityContext() { result = this.lookup("securityContext") } + + YamlValue getServiceAccountName() { result = this.lookup("serviceAccountName") } + + YamlValue getAutomountServiceAccountToken() { + result = this.lookup("automountServiceAccountToken") + } + + Volume getVolumes() { result = this.lookup("volumes").(YamlSequence).getAChild() } + + YamlValue getHostNetwork() { result = this.lookup("hostNetwork") } + + YamlValue getHostPid() { result = this.lookup("hostPID") } + + YamlValue getHostIpc() { result = this.lookup("hostIPC") } + } + + class Container extends YamlNode, YamlMapping { + Container() { + exists(PodSpec pod | pod.lookup("containers").(YamlSequence).getAChildNode() = this) + or + exists(PodSpec pod | pod.lookup("initContainers").(YamlSequence).getAChildNode() = this) + or + exists(PodSpec pod | pod.lookup("ephemeralContainers").(YamlSequence).getAChildNode() = this) + } + + string getName() { result = yamlToString(this.lookup("name")) } + + string getImage() { result = yamlToString(this.lookup("image")) } + + SecurityContext getSecurityContext() { result = this.lookup("securityContext") } + + YamlValue getCommand() { result = this.lookup("command") } + + YamlValue getArgs() { result = this.lookup("args") } + + YamlValue getEnv() { result = this.lookup("env") } + + EnvEntry getEnvironmentEntries() { result = this.lookup("env").(YamlSequence).getAChild() } + + YamlValue getEnvFrom() { result = this.lookup("envFrom") } + + YamlValue getVolumeMounts() { result = this.lookup("volumeMounts") } + + YamlValue getPorts() { result = this.lookup("ports") } + } + + class EnvEntry extends YamlNode, YamlMapping { + EnvEntry() { + exists(Container container | container.lookup("env").(YamlSequence).getAChildNode() = this) + } + + string getName() { result = yamlToString(this.lookup("name")) } + + YamlValue getValue() { result = this.lookup("value") } + + YamlMapping getValueFrom() { result = this.lookup("valueFrom") } + + YamlMapping getSecretKeyRef() { result = this.getValueFrom().lookup("secretKeyRef") } + + YamlMapping getConfigMapKeyRef() { result = this.getValueFrom().lookup("configMapKeyRef") } + } + + class SecurityContext extends YamlNode, YamlMapping { + SecurityContext() { + exists(PodSpec pod | pod.lookup("securityContext") = this) + or + exists(Container container | container.lookup("securityContext") = this) + } + + YamlValue getPrivileged() { result = this.lookup("privileged") } + + YamlValue getAllowPrivilegeEscalation() { + result = this.lookup("allowPrivilegeEscalation") + } + + YamlValue getRunAsUser() { result = this.lookup("runAsUser") } + + YamlValue getRunAsGroup() { result = this.lookup("runAsGroup") } + + YamlValue getRunAsNonRoot() { result = this.lookup("runAsNonRoot") } + + YamlValue getReadOnlyRootFilesystem() { + result = this.lookup("readOnlyRootFilesystem") + } + + YamlValue getCapabilities() { result = this.lookup("capabilities") } + + YamlValue getSeccompProfile() { result = this.lookup("seccompProfile") } + } + + class Volume extends YamlNode, YamlMapping { + Volume() { exists(PodSpec pod | pod.lookup("volumes").(YamlSequence).getAChildNode() = this) } + + string getName() { result = yamlToString(this.lookup("name")) } + + YamlValue getHostPath() { result = this.lookup("hostPath") } + + YamlValue getProjected() { result = this.lookup("projected") } + + YamlValue getSecret() { result = this.lookup("secret") } + + YamlValue getConfigMap() { result = this.lookup("configMap") } + } + + class Role extends Document { + Role() { this.getKind() = ["Role", "ClusterRole"] } + + Rule getRules() { result = this.lookup("rules").(YamlSequence).getAChild() } + } + + class Rule extends YamlNode, YamlMapping { + Rule() { exists(Role role | role.lookup("rules").(YamlSequence).getAChildNode() = this) } + + YamlValue getApiGroups() { result = this.lookup("apiGroups") } + + YamlValue getResources() { result = this.lookup("resources") } + + YamlValue getVerbs() { result = this.lookup("verbs") } + + YamlValue getResourceNames() { result = this.lookup("resourceNames") } + } + + class RoleBinding extends Document { + RoleBinding() { this.getKind() = ["RoleBinding", "ClusterRoleBinding"] } + + YamlMapping getRoleRef() { result = this.lookup("roleRef") } + + Subject getSubjects() { result = this.lookup("subjects").(YamlSequence).getAChild() } + } + + class Subject extends YamlNode, YamlMapping { + Subject() { + exists(RoleBinding binding | + binding.lookup("subjects").(YamlSequence).getAChildNode() = this + ) + } + + string getKind() { result = yamlToString(this.lookup("kind")) } + + string getName() { result = yamlToString(this.lookup("name")) } + + string getNamespace() { result = yamlToString(this.lookup("namespace")) } + } +} diff --git a/iac/ql/lib/iac.qll b/iac/ql/lib/iac.qll index f5258b4b39dd..62838ba9a842 100644 --- a/iac/ql/lib/iac.qll +++ b/iac/ql/lib/iac.qll @@ -12,6 +12,8 @@ import codeql.iac.containers.Containers import codeql.iac.containers.Images // Compose import codeql.iac.compose.Compose +// Kubernetes +import codeql.iac.kubernetes.Kubernetes // HelmCharts import codeql.iac.helmcharts.HelmChart // Terraform / HCL @@ -20,3 +22,4 @@ import hcl import codeql.iac.openapi.OpenApi // YAML import codeql.iac.YAML +import codeql.iac.YamlDocumentClassification diff --git a/iac/ql/test/library-tests/compose/ast/AST.expected b/iac/ql/test/library-tests/compose/ast/AST.expected new file mode 100644 index 000000000000..9ebc41d7f4ee --- /dev/null +++ b/iac/ql/test/library-tests/compose/ast/AST.expected @@ -0,0 +1,13 @@ +documents +| compose.yaml:1:1:18:25 | services: | +| docker-compose.yaml:1:1:11:29 | services: | +services +| compose.yaml:3:5:15:23 | image: nginx:1.25 | +| docker-compose.yaml:3:5:8:21 | image: ... ker:1.0 | +environment +| compose.yaml:5:7:5:14 | PASSWORD | +| compose.yaml:5:17:5:27 | ${PASSWORD} | +| compose.yaml:6:7:6:11 | DEBUG | +| compose.yaml:6:14:6:19 | "true" | +| docker-compose.yaml:5:9:5:18 | DEBUG=true | +| docker-compose.yaml:6:9:6:17 | API_TOKEN | diff --git a/iac/ql/test/library-tests/compose/ast/AST.ql b/iac/ql/test/library-tests/compose/ast/AST.ql new file mode 100644 index 000000000000..7f16160e10e6 --- /dev/null +++ b/iac/ql/test/library-tests/compose/ast/AST.ql @@ -0,0 +1,5 @@ +private import iac + +query predicate documents(Compose::Document n) { any() } +query predicate services(Compose::Service n) { any() } +query predicate environment(Compose::EnvironmentEntry n) { any() } diff --git a/iac/ql/test/library-tests/compose/ast/compose.yaml b/iac/ql/test/library-tests/compose/ast/compose.yaml new file mode 100644 index 000000000000..74fe325175ec --- /dev/null +++ b/iac/ql/test/library-tests/compose/ast/compose.yaml @@ -0,0 +1,18 @@ +services: + web: + image: nginx:1.25 + environment: + PASSWORD: ${PASSWORD} + DEBUG: "true" + secrets: + - app-password + cap_drop: + - ALL + read_only: true + user: "1000:1000" + network_mode: bridge + volumes: + - ./web:/srv/web +secrets: + app-password: + file: ./password.txt diff --git a/iac/ql/test/library-tests/compose/ast/docker-compose.yaml b/iac/ql/test/library-tests/compose/ast/docker-compose.yaml new file mode 100644 index 000000000000..0d853ba26cdd --- /dev/null +++ b/iac/ql/test/library-tests/compose/ast/docker-compose.yaml @@ -0,0 +1,11 @@ +services: + worker: + image: example/worker:1.0 + environment: + - DEBUG=true + - API_TOKEN + secrets: + - worker-token +secrets: + worker-token: + file: ./worker-token.txt diff --git a/iac/ql/test/library-tests/kubernetes/ast/AST.expected b/iac/ql/test/library-tests/kubernetes/ast/AST.expected new file mode 100644 index 000000000000..7b800c10241c --- /dev/null +++ b/iac/ql/test/library-tests/kubernetes/ast/AST.expected @@ -0,0 +1,31 @@ +documents +| workloads.yaml:1:1:32:29 | HelmChart Document | +| workloads.yaml:1:1:32:29 | Kubernetes Deployment document | +| workloads.yaml:34:1:41:27 | HelmChart Document | +| workloads.yaml:34:1:41:27 | Kubernetes ClusterRole document | +| workloads.yaml:43:1:54:20 | HelmChart Document | +| workloads.yaml:43:1:54:20 | Kubernetes ClusterRoleBinding document | +metadata +| workloads.yaml:4:3:5:18 | name: web | +| workloads.yaml:37:3:37:15 | name: reader | +| workloads.yaml:46:3:46:23 | name: reader-binding | +podSpecs +| workloads.yaml:13:7:32:29 | service ... me: web | +containers +| workloads.yaml:17:11:29:6 | name: web | +env +| workloads.yaml:20:15:25:10 | name: PASSWORD | +security +| workloads.yaml:26:13:29:6 | privileged: false | +volumes +| workloads.yaml:30:11:32:29 | name: config | +roles +| workloads.yaml:34:1:41:27 | HelmChart Document | +| workloads.yaml:34:1:41:27 | Kubernetes ClusterRole document | +rules +| workloads.yaml:39:5:41:27 | apiGroups: [""] | +bindings +| workloads.yaml:43:1:54:20 | HelmChart Document | +| workloads.yaml:43:1:54:20 | Kubernetes ClusterRoleBinding document | +subjects +| workloads.yaml:52:5:54:20 | kind: ServiceAccount | diff --git a/iac/ql/test/library-tests/kubernetes/ast/AST.ql b/iac/ql/test/library-tests/kubernetes/ast/AST.ql new file mode 100644 index 000000000000..d265519566fc --- /dev/null +++ b/iac/ql/test/library-tests/kubernetes/ast/AST.ql @@ -0,0 +1,13 @@ +private import iac + +query predicate documents(YamlKubernetes::Document n) { any() } +query predicate metadata(YamlKubernetes::Metadata n) { any() } +query predicate podSpecs(YamlKubernetes::PodSpec n) { any() } +query predicate containers(YamlKubernetes::Container n) { any() } +query predicate env(YamlKubernetes::EnvEntry n) { any() } +query predicate security(YamlKubernetes::SecurityContext n) { any() } +query predicate volumes(YamlKubernetes::Volume n) { any() } +query predicate roles(YamlKubernetes::Role n) { any() } +query predicate rules(YamlKubernetes::Rule n) { any() } +query predicate bindings(YamlKubernetes::RoleBinding n) { any() } +query predicate subjects(YamlKubernetes::Subject n) { any() } diff --git a/iac/ql/test/library-tests/kubernetes/ast/workloads.yaml b/iac/ql/test/library-tests/kubernetes/ast/workloads.yaml new file mode 100644 index 000000000000..390918371b0b --- /dev/null +++ b/iac/ql/test/library-tests/kubernetes/ast/workloads.yaml @@ -0,0 +1,54 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web + namespace: prod +spec: + replicas: 2 + template: + metadata: + labels: + app: web + spec: + serviceAccountName: web + automountServiceAccountToken: false + hostNetwork: false + containers: + - name: web + image: nginx:1.25 + env: + - name: PASSWORD + valueFrom: + secretKeyRef: + name: app-secrets + key: password + securityContext: + privileged: false + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + volumes: + - name: config + configMap: + name: app-config +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: reader +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: reader-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: reader +subjects: + - kind: ServiceAccount + name: web + namespace: prod diff --git a/iac/ql/test/library-tests/yaml-classification/AST.expected b/iac/ql/test/library-tests/yaml-classification/AST.expected index c8d07e824bca..75de151586df 100644 --- a/iac/ql/test/library-tests/yaml-classification/AST.expected +++ b/iac/ql/test/library-tests/yaml-classification/AST.expected @@ -3,6 +3,7 @@ supportedYamlDocument | cloudformation.yaml:1:1:6:29 | CloudFormation Document | cloudformation | | compose.yaml:1:1:4:24 | version: "3.9" | compose | | deployment.yaml:1:1:8:29 | HelmChart Document | kubernetes-helm | +| deployment.yaml:1:1:8:29 | Kubernetes Deployment document | kubernetes-helm | | openapi.yaml:1:1:12:26 | OpenApi Document | openapi | allYamlDocuments | arm-template.yaml:1:1:5:24 | $schema ... .json#" | @@ -10,6 +11,7 @@ allYamlDocuments | cloudformation.yaml:1:1:6:29 | CloudFormation Document | | compose.yaml:1:1:4:24 | version: "3.9" | | deployment.yaml:1:1:8:29 | HelmChart Document | +| deployment.yaml:1:1:8:29 | Kubernetes Deployment document | | openapi.json:1:1:8:1 | OpenApi Document | | openapi.yaml:1:1:12:26 | OpenApi Document | | unrelated.yaml:1:1:4:8 | descrip ... ocument |