diff --git a/.eslintrc.js b/.eslintrc.js index 3e2505f..374b027 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,18 +3,13 @@ module.exports = { browser: false, es2022: true }, - extends: [ - 'standard' - ], + extends: ["standard", "prettier"], ignorePatterns: [], - parser: '@typescript-eslint/parser', + parser: "@typescript-eslint/parser", parserOptions: { ecmaVersion: 12, - sourceType: 'module' + sourceType: "module" }, - plugins: [ - '@typescript-eslint' - ], - rules: { - } + plugins: ["@typescript-eslint"], + rules: {} } diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 010875d..55c6290 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] jobs: build: timeout-minutes: 10 @@ -15,47 +15,47 @@ jobs: node-version: [16.x, 18.x, 20.x] steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run build --if-present - - run: npm run lint --if-present - - run: npm test - - uses: actions/upload-artifact@v3 - if: ${{ matrix.node-version == '16.x' }} - with: - name: coverage - path: | - coverage + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + - run: npm ci + - run: npm run build --if-present + - run: npm run lint --if-present + - run: npm test + - uses: actions/upload-artifact@v3 + if: ${{ matrix.node-version == '16.x' }} + with: + name: coverage + path: | + coverage sonar: runs-on: ubuntu-latest needs: - - build + - build steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - uses: actions/download-artifact@v3 - with: - name: coverage - path: coverage - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - SONAR_TOKEN: ${{ secrets.SONARQUBE_KEY }} + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/download-artifact@v3 + with: + name: coverage + path: coverage + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONARQUBE_KEY }} build-image: timeout-minutes: 10 runs-on: ubuntu-latest needs: - - build + - build env: - REGISTRY: 'ghcr.io' - IMAGE_NAME: 'bryopsida/psa-restricted-patcher' + REGISTRY: "ghcr.io" + IMAGE_NAME: "bryopsida/psa-restricted-patcher" permissions: contents: read packages: write @@ -67,7 +67,7 @@ jobs: - name: Install cosign uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 with: - cosign-release: 'v1.13.1' + cosign-release: "v1.13.1" # for multi arch container builds - name: Set up QEMU uses: docker/setup-qemu-action@master @@ -124,10 +124,10 @@ jobs: matrix: k8s-version: [1.22.13, 1.23.10, 1.24.4, 1.25.0, 1.26.0, 1.27.0] needs: - - build-image + - build-image env: - REGISTRY: 'ghcr.io' - IMAGE_NAME: 'bryopsida/psa-restricted-patcher' + REGISTRY: "ghcr.io" + IMAGE_NAME: "bryopsida/psa-restricted-patcher" steps: - name: Checkout repository uses: actions/checkout@v3 @@ -135,7 +135,7 @@ jobs: uses: actions/setup-node@v3 with: node-version: 18 - cache: 'npm' + cache: "npm" - name: Install Dependencies run: npm ci - name: Start Minikube @@ -161,7 +161,7 @@ jobs: mkdir -p /tmp/failure-logs minikube logs > /tmp/failure-logs/minikube.log kubectl logs deployments/psa-restricted-patcher --prefix=true --ignore-errors=true --timestamps --pod-running-timeout=60s > /tmp/failure-logs/psa-restricted-patcher.deployment.log - kubectl describe deployment kpsa-restricted-patcher > /tmp/failure-logs/psa-restricted-patcher.deployment.describe + kubectl describe deployment psa-restricted-patcher > /tmp/failure-logs/psa-restricted-patcher.deployment.describe kubectl get deployment psa-restricted-patcher -o yaml > /tmp/failure-logs/psa-restricted-patcher.deployment.yaml kubectl describe configmap psa-restricted-patcher > /tmp/failure-logs/psa-restricted-patcher.configmap.describe - name: Upload Logs On Failure @@ -174,11 +174,11 @@ jobs: publish-chart: if: ${{ github.event_name != 'pull_request' }} needs: - - verify + - verify timeout-minutes: 5 runs-on: ubuntu-latest permissions: - contents: write + contents: write steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index a86fab6..7fd5409 100644 --- a/.gitignore +++ b/.gitignore @@ -104,4 +104,5 @@ dist .tern-port .dccache -.DS_Store \ No newline at end of file +.DS_Store +docs \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..059f273 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +helm +node_modules +coverage +dist diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..92eb82a --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,4 @@ +module.exports = { + semi: false, + trailingComma: "none" +} diff --git a/README.md b/README.md index 478b81e..7a8e788 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ # psa-restricted-patcher -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=bryopsida_psa-restricted-patcher&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=bryopsida_psa-restricted-patcher) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=bryopsida_psa-restricted-patcher&metric=coverage)](https://sonarcloud.io/summary/new_code?id=bryopsida_psa-restricted-patcher) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=bryopsida_psa-restricted-patcher&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=bryopsida_psa-restricted-patcher) [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=bryopsida_psa-restricted-patcher&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=bryopsida_psa-restricted-patcher) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=bryopsida_psa-restricted-patcher&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=bryopsida_psa-restricted-patcher) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=bryopsida_psa-restricted-patcher&metric=bugs)](https://sonarcloud.io/summary/new_code?id=bryopsida_psa-restricted-patcher) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=bryopsida_psa-restricted-patcher&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=bryopsida_psa-restricted-patcher) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=bryopsida_psa-restricted-patcher&metric=coverage)](https://sonarcloud.io/summary/new_code?id=bryopsida_psa-restricted-patcher) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=bryopsida_psa-restricted-patcher&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=bryopsida_psa-restricted-patcher) [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=bryopsida_psa-restricted-patcher&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=bryopsida_psa-restricted-patcher) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=bryopsida_psa-restricted-patcher&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=bryopsida_psa-restricted-patcher) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=bryopsida_psa-restricted-patcher&metric=bugs)](https://sonarcloud.io/summary/new_code?id=bryopsida_psa-restricted-patcher) ## NPM Scripts + The following scripts are available + - `lint` lints the source code using eslint - `lint:fix` automatically fixes any lint errors that can be fixed automatically - `test` uses jest to run test suites - `test:e2e` runs e2e test suite, this requires an active helm:deploy - `build` compiles the typescript into js and places it in the `dist` folder - `build:image` builds the container image +- `build:docs` builds the api docs - `minikube:start` create a minikube k8s cluster - `minikube:stop` stop minikube but do not delete - `minikube:delete` delete the minikube cluster @@ -21,20 +24,21 @@ The following scripts are available - `helm:uninstallCertManager` remove cert-manager from the k8s cluster ## Deploy it + If you don't already have cert manager installed you will need to run: -``` bash +```bash helm repo add jetstack https://charts.jetstack.io && helm repo update && \ helm upgrade --install --namespace cert-manager --create-namespace \ cert-manager jetstack/cert-manager --set installCRDs=true --debug --wait ``` -Add the helm repos `helm repo add psa https://bryopsida.github.io/psa-restricted-patcher` fetch updates `helm repo update`. +Add the helm repos `helm repo add psa https://bryopsida.github.io/psa-restricted-patcher` fetch updates `helm repo update`. Verify it worked `helm search repo psa` and you should see something like. ``` -NAME CHART VERSION APP VERSION DESCRIPTION +NAME CHART VERSION APP VERSION DESCRIPTION psa/psa-restricted-patcher... 0.1.0 0.1.0 ... ``` diff --git a/helm/psa-restricted-patcher/Chart.yaml b/helm/psa-restricted-patcher/Chart.yaml index af580c2..8790a02 100644 --- a/helm/psa-restricted-patcher/Chart.yaml +++ b/helm/psa-restricted-patcher/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: psa-restricted-patcher description: Automatically patches pods on creation to conform to the pod security restricted profile type: application -version: 0.7.0 -appVersion: "0.2.0" +version: 0.8.0 +appVersion: "0.3.0" maintainers: - name: bryopsida \ No newline at end of file diff --git a/helm/psa-restricted-patcher/README.md b/helm/psa-restricted-patcher/README.md index e81bca9..31ec8fe 100644 --- a/helm/psa-restricted-patcher/README.md +++ b/helm/psa-restricted-patcher/README.md @@ -1,6 +1,6 @@ # psa-restricted-patcher -![Version: 0.7.0](https://img.shields.io/badge/Version-0.7.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.2.0](https://img.shields.io/badge/AppVersion-0.2.0-informational?style=flat-square) +![Version: 0.8.0](https://img.shields.io/badge/Version-0.8.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.3.0](https://img.shields.io/badge/AppVersion-0.3.0-informational?style=flat-square) Automatically patches pods on creation to conform to the pod security restricted profile @@ -44,6 +44,7 @@ Automatically patches pods on creation to conform to the pod security restricted | passthroughPatterns | list | `[]` | A list of regex patterns, that if matched, the pod passes through untouched | | podAnnotations | object | `{}` | | | podSecurityContext.seccompProfile.type | string | `"RuntimeDefault"` | | +| rbacCreate | bool | `true` | Create the RBAC rules and bindings to allow the webhook to update the caBundle value, this is needed to handle rotations, if disabled you can provide your own bindings | | reinvocationPolicy | string | `"IfNeeded"` | ReinvocationPolicy can be Never or IfNeeded, this hook operates in a idempotent manner so IfNeeded is the default. | | replicaCount | int | `1` | | | resources.limits.cpu | string | `"0.2"` | | diff --git a/helm/psa-restricted-patcher/scripts/inject-ca.sh b/helm/psa-restricted-patcher/scripts/inject-ca.sh deleted file mode 100755 index 33de9d0..0000000 --- a/helm/psa-restricted-patcher/scripts/inject-ca.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env sh - -# Need to wait and watch for the TLS secret to be create -while ! kubectl get secret "$SECRET_NAME" --namespace "$RELEASE_NAMESPACE"; do echo "Waiting for TLS secret."; sleep 1; done - -# Once it's available we need to pull out the CA value -TLS_PEM=$(kubectl --namespace $RELEASE_NAMESPACE get secret $SECRET_NAME -o jsonpath="{.data['tls\.crt']}") -echo "$TLS_PEM" - -# Once we have the CA value we need to patch the validating webhook -kubectl --namespace "$RELEASE_NAMESPACE" patch mutatingwebhookconfiguration "$HOOK_NAME" -p "{\"webhooks\":[{\"name\":\"$HOOK_SVC_NAME\",\"clientConfig\":{\"caBundle\":\"$TLS_PEM\"}}]}" diff --git a/helm/psa-restricted-patcher/templates/configmap.yaml b/helm/psa-restricted-patcher/templates/configmap.yaml index 7f7c373..a58238f 100644 --- a/helm/psa-restricted-patcher/templates/configmap.yaml +++ b/helm/psa-restricted-patcher/templates/configmap.yaml @@ -11,8 +11,11 @@ data: "level": "{{ .Values.logLevel }}" }, "tls": { - "enabled": true + "enabled": true, + "secretName": {{ .Values.tlsSecretName | quote }} }, + "hookName": "{{ include "psa-restricted-patcher.fullname" . }}-hooks", + "hookNamespace": {{ .Release.Namespace | quote }}, "addSeccompProfile": {{ .Values.addSeccompProfile }}, "seccompProfile": "{{ .Values.seccompProfile }}", "namespaces": {{ .Values.namespaces | toJson }}, diff --git a/helm/psa-restricted-patcher/templates/mutating-webhook.yaml b/helm/psa-restricted-patcher/templates/mutating-webhook.yaml index bdc4b05..f5b558a 100644 --- a/helm/psa-restricted-patcher/templates/mutating-webhook.yaml +++ b/helm/psa-restricted-patcher/templates/mutating-webhook.yaml @@ -24,10 +24,8 @@ webhooks: scope: "*" {{- end }} clientConfig: - {{- /* if this is an upgrade and cm is enabled, and it's a self signed issuer, enforce hookCaBundle being set */}} - {{- if and .Release.IsUpgrade .Values.certmanager.enabled .Values.certmanager.useSelfSignedIssuer }} - caBundle: {{ required "When upgrading after using a self signed issuer with certmanager you must provide the ca.crt as hookCaBundle on upgrades, otherwise api trust will break!" .Values.hookCaBundle}} - {{- else if .Values.hookCaBundle }} + {{- /* Allow setting this but, have periodic check tied to health checks in hook that can update this it's rotated */}} + {{- if .Values.hookCaBundle }} caBundle: {{ .Values.hookCaBundle }} {{- end }} service: diff --git a/helm/psa-restricted-patcher/templates/rbac.yaml b/helm/psa-restricted-patcher/templates/rbac.yaml new file mode 100644 index 0000000..6d83042 --- /dev/null +++ b/helm/psa-restricted-patcher/templates/rbac.yaml @@ -0,0 +1,59 @@ +{{- /* Create RBAC for the hook to update its mutatingwebhook configuration + Adds permission to read the referenced TLS secret for the hook which is already + mounted into the pod, and to read and patch its mutatingwebhookconfiguration to update its cabundle + */}} +{{- if .Values.rbacCreate }} +{{- /* Read the TLS secret for the hook */}} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + namespace: {{ .Release.Namespace }} + name: {{ .Release.Name }}-tls-reader +rules: +- apiGroups: [""] + resourceNames: ["{{ .Values.tlsSecretName }}"] + resources: ["secrets"] + verbs: ["get"] +--- +{{- /* we need to be able to mutate the validating hook, this is a cluster level/global resource, we need a clusterrole for this*/}} +{{- /* this role can read/write validating webhooks*/}} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Release.Name }}-ca-injector +rules: +- apiGroups: ["admissionregistration.k8s.io"] + resources: ["mutatingwebhookconfigurations"] + resourceNames: ["{{ include "psa-restricted-patcher.fullname" . }}-hooks"] + verbs: ["get", "update", "patch"] +{{- /* bind sa to roles */}} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Release.Name }}-hook-read-secrets-binding + namespace: {{ .Release.Namespace }} +subjects: +- kind: ServiceAccount + name: {{ include "psa-restricted-patcher.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +roleRef: + kind: Role + name: {{ .Release.Name }}-tls-reader + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Release.Name }}-hook-read-secrets-binding +subjects: +- kind: ServiceAccount + name: {{ include "psa-restricted-patcher.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +roleRef: + kind: ClusterRole + name: {{ .Release.Name }}-ca-injector + apiGroup: rbac.authorization.k8s.io +{{- end }} \ No newline at end of file diff --git a/helm/psa-restricted-patcher/templates/self-signed-ca-inject-job.yaml b/helm/psa-restricted-patcher/templates/self-signed-ca-inject-job.yaml deleted file mode 100644 index 5fcaa1a..0000000 --- a/helm/psa-restricted-patcher/templates/self-signed-ca-inject-job.yaml +++ /dev/null @@ -1,183 +0,0 @@ -{{- if and .Values.certmanager.enabled .Values.certmanager.useSelfSignedIssuer }} -{{- /* Only needed if a self signed issuer is used, if user brings their own cert or uses a different issuer paradigm skip all this */}} - -{{- /* Create access for the hook job so it can fetch the CA Bundle and mutate the hook, -once this is done clean up the service account and roles, -leave the job and pod so logs can be retrieved easily */}} -{{- /* In plain terms this role will be able to read secrets in the namespace*/}} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - namespace: {{ .Release.Namespace }} - name: {{ .Release.Name }}-mutating-webhook-tls-reader - annotations: - "helm.sh/hook": post-install - "helm.sh/hook-weight": "0" - "helm.sh/resource-policy": delete - "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded" -rules: -- apiGroups: [""] - resourceNames: ["{{ .Values.tlsSecretName }}"] - resources: ["secrets"] - verbs: ["get"] - -{{- /* we need to be able to mutate the validating hook, this is a cluster level/global resource, we need a clusterrole for this*/}} -{{- /* this role can read/write validating webhooks*/}} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: {{ .Release.Name }}-mutating-webhook-ca-injector - annotations: - "helm.sh/hook": post-install - "helm.sh/hook-weight": "0" - "helm.sh/resource-policy": delete - "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded" -rules: -- apiGroups: ["admissionregistration.k8s.io"] - resources: ["mutatingwebhookconfigurations"] - resourceNames: ["{{ include "psa-restricted-patcher.fullname" . }}-hooks"] - verbs: ["get", "update", "patch"] - -{{- /*create sa for job*/}} ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - namespace: {{ .Release.Namespace }} - name: {{ .Release.Name }}-post-install-job-sa - labels: - {{- include "psa-restricted-patcher.labels" . | nindent 4 }} - annotations: - "helm.sh/hook": post-install - "helm.sh/hook-weight": "0" - "helm.sh/resource-policy": keep - "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded" - -{{- /* bind sa to roles */}} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: {{ .Release.Name }}-hook-read-secrets-binding - namespace: {{ .Release.Namespace }} - annotations: - "helm.sh/hook": post-install - "helm.sh/hook-weight": "1" - "helm.sh/resource-policy": delete - "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded" -subjects: -- kind: ServiceAccount - name: {{ .Release.Name }}-post-install-job-sa - namespace: {{ .Release.Namespace }} -roleRef: - kind: Role - name: {{ .Release.Name }}-mutating-webhook-tls-reader - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: {{ .Release.Name }}-hook-read-secrets-binding - annotations: - "helm.sh/hook": post-install - "helm.sh/hook-weight": "1" - "helm.sh/resource-policy": delete - "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded" -subjects: -- kind: ServiceAccount - name: {{ .Release.Name }}-post-install-job-sa - namespace: {{ .Release.Namespace }} -roleRef: - kind: ClusterRole - name: {{ .Release.Name }}-mutating-webhook-ca-injector - apiGroup: rbac.authorization.k8s.io - -{{- /* create a configmap with a shell script */}} ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ .Release.Name }}-ca-inject-scripts - namespace: {{ .Release.Namespace }} - annotations: - "helm.sh/hook": post-install - "helm.sh/hook-weight": "1" - "helm.sh/resource-policy": delete - "helm.sh/hook-delete-policy": "before-hook-creation" -data: -{{ (.Files.Glob "scripts/*").AsConfig | indent 2 }} -{{- /* mount SA into pod */}} ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: "{{ .Release.Name }}-ca-bundle-inject" - namespace: "{{ .Release.Namespace }}" - labels: - app.kubernetes.io/managed-by: {{ .Release.Service | quote }} - app.kubernetes.io/instance: {{ .Release.Name | quote }} - app.kubernetes.io/version: {{ .Chart.AppVersion }} - helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - annotations: - "helm.sh/hook": post-install - "helm.sh/hook-weight": "2" - "helm.sh/resource-policy": delete - "helm.sh/hook-delete-policy": "before-hook-creation" -spec: - ttlSecondsAfterFinished: 600 - template: - metadata: - name: "{{ .Release.Name }}" - namespace: "{{ .Release.Namespace }}" - labels: - app.kubernetes.io/managed-by: {{ .Release.Service | quote }} - app.kubernetes.io/instance: {{ .Release.Name | quote }} - helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - spec: - securityContext: - runAsUser: 2000 - runAsGroup: 2000 - fsGroup: 2000 - seccompProfile: - type: RuntimeDefault - serviceAccountName: {{ .Release.Name }}-post-install-job-sa - restartPolicy: Never - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - volumes: - - name: script - configMap: - name: {{ .Release.Name }}-ca-inject-scripts - items: - - key: inject-ca.sh - path: entry-point.sh - mode: 0755 - containers: - - volumeMounts: - - name: script - mountPath: /job/ - name: post-install-job - image: "ghcr.io/curium-rocks/docker-kubectl:main" - imagePullPolicy: Always - securityContext: - runAsNonRoot: true - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: - - ALL - env: - - name: SECRET_NAME - value: "{{ .Values.tlsSecretName }}" - - name: RELEASE_NAMESPACE - value: "{{ .Release.Namespace }}" - - name: HOOK_NAME - value: "{{ include "psa-restricted-patcher.fullname" . }}-hooks" - - name: HOOK_SVC_NAME - value: "{{ include "psa-restricted-patcher.fullname" . }}.{{ .Release.Namespace }}.svc" - command: ["/job/entry-point.sh"] -{{- end }} \ No newline at end of file diff --git a/helm/psa-restricted-patcher/values.yaml b/helm/psa-restricted-patcher/values.yaml index 196ed8d..d4761ef 100644 --- a/helm/psa-restricted-patcher/values.yaml +++ b/helm/psa-restricted-patcher/values.yaml @@ -63,6 +63,9 @@ passthroughPatterns: [] namespaceSelector: {} # -- Optional object selector: https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector objectSelector: {} + +# -- Create the RBAC rules and bindings to allow the webhook to update the caBundle value, this is needed to handle rotations, if disabled you can provide your own bindings +rbacCreate: true resources: requests: memory: "64Mi" diff --git a/jest.config.js b/jest.config.js index 94f3f73..8943fa8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,6 @@ module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', + preset: "ts-jest", + testEnvironment: "node", testTimeout: 25000, verbose: true } diff --git a/package-lock.json b/package-lock.json index 9edc379..05ca98b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "psa-restricted-patcher", - "version": "0.1.0", + "version": "0.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "psa-restricted-patcher", - "version": "0.1.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { "@fastify/under-pressure": "^8.1.0", @@ -24,13 +24,16 @@ "@types/jest": "^29.0.0", "@typescript-eslint/eslint-plugin": "^5.15.0", "@typescript-eslint/parser": "^5.15.0", + "eslint-config-prettier": "^8.8.0", "eslint-config-standard": "^17.0.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^6.0.0", "jest": "^29.0.0", + "prettier": "^2.8.8", "ts-jest": "29.x.x", "ts-node": "^10.7.0", + "typedoc": "^0.24.8", "typescript": "^5.0.0" } }, @@ -1862,6 +1865,12 @@ "node": ">=8" } }, + "node_modules/ansi-sequence-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.0.tgz", + "integrity": "sha512-lEm8mt52to2fT8GhciPCGeCXACSz2UwIN4X2e2LJSnZ5uAbn2/dsYdOmUXq0AtWS5cpAupysIneExOgH0Vd2TQ==", + "dev": true + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2790,6 +2799,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz", + "integrity": "sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-config-standard": { "version": "17.1.0", "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.1.0.tgz", @@ -5072,6 +5093,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, "node_modules/jsonpath-plus": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-7.2.0.tgz", @@ -5182,6 +5209,12 @@ "node": ">=10" } }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -5221,6 +5254,18 @@ "tmpl": "1.0.5" } }, + "node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -5760,6 +5805,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.5.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.5.0.tgz", @@ -6204,6 +6264,18 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.3.tgz", + "integrity": "sha512-U3S/a+b0KS+UkTyMjoNojvTgrBHjgp7L6ovhFVZsXmBGnVdQ4K4U9oK0z63w538S91ATngv1vXigHCSWOwnr+g==", + "dev": true, + "dependencies": { + "ansi-sequence-parser": "^1.1.0", + "jsonc-parser": "^3.2.0", + "vscode-oniguruma": "^1.7.0", + "vscode-textmate": "^8.0.0" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -6798,6 +6870,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedoc": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.24.8.tgz", + "integrity": "sha512-ahJ6Cpcvxwaxfu4KtjA8qZNqS43wYt6JL27wYiIgl1vd38WW/KWX11YuAeZhuz9v+ttrutSsgK+XO1CjL1kA3w==", + "dev": true, + "dependencies": { + "lunr": "^2.3.9", + "marked": "^4.3.0", + "minimatch": "^9.0.0", + "shiki": "^0.14.1" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 14.14" + }, + "peerDependencies": { + "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x" + } + }, + "node_modules/typedoc/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typedoc/node_modules/minimatch": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.2.tgz", + "integrity": "sha512-PZOT9g5v2ojiTL7r1xF6plNHLtOeTpSlDI007As2NlA2aYBMfVom17yqa6QzhmDP8QOhn7LjHTg7DFCVSSa6yg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/typescript": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", @@ -6907,6 +7024,18 @@ "extsprintf": "^1.2.0" } }, + "node_modules/vscode-oniguruma": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", + "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==", + "dev": true + }, + "node_modules/vscode-textmate": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz", + "integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==", + "dev": true + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -8516,6 +8645,12 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true }, + "ansi-sequence-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.0.tgz", + "integrity": "sha512-lEm8mt52to2fT8GhciPCGeCXACSz2UwIN4X2e2LJSnZ5uAbn2/dsYdOmUXq0AtWS5cpAupysIneExOgH0Vd2TQ==", + "dev": true + }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -9243,6 +9378,13 @@ } } }, + "eslint-config-prettier": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz", + "integrity": "sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==", + "dev": true, + "requires": {} + }, "eslint-config-standard": { "version": "17.1.0", "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.1.0.tgz", @@ -10914,6 +11056,12 @@ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" }, + "jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, "jsonpath-plus": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-7.2.0.tgz", @@ -11000,6 +11148,12 @@ "yallist": "^4.0.0" } }, + "lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true + }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -11032,6 +11186,12 @@ "tmpl": "1.0.5" } }, + "marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true + }, "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -11435,6 +11595,12 @@ "dev": true, "peer": true }, + "prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true + }, "pretty-format": { "version": "29.5.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.5.0.tgz", @@ -11746,6 +11912,18 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, + "shiki": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.3.tgz", + "integrity": "sha512-U3S/a+b0KS+UkTyMjoNojvTgrBHjgp7L6ovhFVZsXmBGnVdQ4K4U9oK0z63w538S91ATngv1vXigHCSWOwnr+g==", + "dev": true, + "requires": { + "ansi-sequence-parser": "^1.1.0", + "jsonc-parser": "^3.2.0", + "vscode-oniguruma": "^1.7.0", + "vscode-textmate": "^8.0.0" + } + }, "side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -12169,6 +12347,38 @@ "is-typed-array": "^1.1.9" } }, + "typedoc": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.24.8.tgz", + "integrity": "sha512-ahJ6Cpcvxwaxfu4KtjA8qZNqS43wYt6JL27wYiIgl1vd38WW/KWX11YuAeZhuz9v+ttrutSsgK+XO1CjL1kA3w==", + "dev": true, + "requires": { + "lunr": "^2.3.9", + "marked": "^4.3.0", + "minimatch": "^9.0.0", + "shiki": "^0.14.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.2.tgz", + "integrity": "sha512-PZOT9g5v2ojiTL7r1xF6plNHLtOeTpSlDI007As2NlA2aYBMfVom17yqa6QzhmDP8QOhn7LjHTg7DFCVSSa6yg==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, "typescript": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", @@ -12242,6 +12452,18 @@ "extsprintf": "^1.2.0" } }, + "vscode-oniguruma": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", + "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==", + "dev": true + }, + "vscode-textmate": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz", + "integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==", + "dev": true + }, "walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/package.json b/package.json index 03de4ba..64b6f94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psa-restricted-patcher", - "version": "0.2.0", + "version": "0.3.0", "description": "Automatically patches pods on creation to comply with the restricted pod security admission policy", "main": "dist/app.js", "scripts": { @@ -16,11 +16,12 @@ "helm:uninstall": "helm del psa-restricted-patcher", "helm:uninstallCertManager": "helm del --namespace cert-manager cert-manager", "build": "node_modules/typescript/bin/tsc --project ./ && mkdir -p dist/config && cp src/config/*.json dist/config/", + "build:docs": "typedoc src/**/*.ts", "build:image": "docker build . -t ghcr.io/bryopsida/psa-restricted-patcher:local", "test": "NODE_CONFIG_DIR=$PWD/src/config/:$PWD/test/config/ jest --coverage --testPathPattern='test/.*\\.spec\\.ts' --testPathIgnorePatterns='test/.*\\.e2e\\.spec\\.ts'", "test:e2e": "NODE_CONFIG_DIR=$PWD/src/config/:$PWD/test/config/ jest --testPathPattern='test/.*\\.e2e\\.spec\\.ts'", - "lint": "eslint --ext .ts src/ test/", - "lint:fix": "eslint --ext .ts src/ test/ --fix" + "lint": "eslint --ext .ts src/ test/ && prettier --check .", + "lint:fix": "eslint --ext .ts src/ test/ --fix && prettier --write ." }, "repository": { "type": "git", @@ -47,13 +48,16 @@ "@types/jest": "^29.0.0", "@typescript-eslint/eslint-plugin": "^5.15.0", "@typescript-eslint/parser": "^5.15.0", + "eslint-config-prettier": "^8.8.0", "eslint-config-standard": "^17.0.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^6.0.0", "jest": "^29.0.0", + "prettier": "^2.8.8", "ts-jest": "29.x.x", "ts-node": "^10.7.0", + "typedoc": "^0.24.8", "typescript": "^5.0.0" }, "dependencies": { diff --git a/renovate.json b/renovate.json index 9ff1687..f3fefee 100644 --- a/renovate.json +++ b/renovate.json @@ -3,4 +3,4 @@ "baseBranches": ["main"], "labels": ["dependencies"], "automerge": true -} \ No newline at end of file +} diff --git a/src/app.ts b/src/app.ts index 4741f07..d5cfe0b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,38 +1,53 @@ -import path from 'path' -import { readFile } from 'fs/promises' -import { Container } from 'inversify' -import { Server } from './server' -import { FastifyServerOptions } from 'fastify' -import { TYPES } from './types' +import path from "path" +import { readFile } from "fs/promises" +import { Container } from "inversify" +import { Server } from "./server" +import { FastifyServerOptions } from "fastify" +import { TYPES } from "./types" -export default async function main (container: Container, options?: FastifyServerOptions, host?: string, port? : number) : Promise { +export default async function main( + container: Container, + options?: FastifyServerOptions, + host?: string, + port?: number +): Promise { let https if (container.get(TYPES.Config.TLSEnabled)) { const keyPath = path.resolve(container.get(TYPES.Config.TLSKeyPath)) - const certPath = path.resolve(container.get(TYPES.Config.TLSCertPath)) + const certPath = path.resolve( + container.get(TYPES.Config.TLSCertPath) + ) const key = await readFile(keyPath, { - encoding: 'utf-8' + encoding: "utf-8" }) const cert = await readFile(certPath, { - encoding: 'utf-8' + encoding: "utf-8" }) https = { key, cert } } - return new Server(container, options || { - logger: true, - https - } as any, host || '0.0.0.0', port || 3000) + return new Server( + container, + options || + ({ + logger: true, + https + } as any), + host ?? "0.0.0.0", + port ?? 3000 + ) } if (require.main === module) { - const appContainer = require('./inversify.config').appContainer - main(appContainer).then((server) => { - return server.open().then(() => { - process.on('SIGINT|SIGTERM', async () => { - await server.close() + const appContainer = require("./inversify.config").appContainer + main(appContainer) + .then((server) => { + return server.open().then(() => { + process.on("SIGINT|SIGTERM", async () => { + await server.close() + }) }) }) - }).catch((err) => { - console.error(`Error while running: ${err}`) - }) + .catch((err) => { + console.error(`Error while running: ${err}`) + }) } diff --git a/src/config/default.json b/src/config/default.json index cabe1ba..18d38de 100644 --- a/src/config/default.json +++ b/src/config/default.json @@ -2,7 +2,8 @@ "tls": { "enabled": false, "keyPath": "/var/run/secrets/tls/tls.key", - "certPath": "/var/run/secrets/tls/tls.crt" + "certPath": "/var/run/secrets/tls/tls.crt", + "secretName": "" }, "log": { "level": "info" @@ -15,5 +16,7 @@ "defaultFsGroup": 1001, "passthrough": [], "ignoredAnnotations": [], - "targettedAnnotations": [] + "targettedAnnotations": [], + "hookName": "", + "hookNamespace": "" } diff --git a/src/controllers/admission.ts b/src/controllers/admission.ts index 485bfca..1d883de 100644 --- a/src/controllers/admission.ts +++ b/src/controllers/admission.ts @@ -1,47 +1,68 @@ -import { V1Pod } from '@kubernetes/client-node' -import { FastifyInstance, FastifyPluginOptions } from 'fastify' -import { IAdmission } from '../services/admission' -import { TYPES } from '../types' -import { IFilter } from '../services/filter' +import { V1Pod } from "@kubernetes/client-node" +import { FastifyInstance, FastifyPluginOptions } from "fastify" +import { IAdmission } from "../services/admission" +import { TYPES } from "../types" +import { IFilter } from "../services/filter" -export function AdmissionController (instance: FastifyInstance, opts: FastifyPluginOptions, done: Function) { - instance.log.info('Registering AdmissionController') - const admissionService = instance.inversifyContainer.get(TYPES.Services.Admission) - const namespaces = instance.inversifyContainer.get>(TYPES.Config.Namespaces) - const passThroughPatterns = instance.inversifyContainer.get>(TYPES.Config.PassthroughPatterns) +export function AdmissionController( + instance: FastifyInstance, + opts: FastifyPluginOptions, + done: Function +) { + instance.log.info("Registering AdmissionController") + const admissionService = instance.inversifyContainer.get( + TYPES.Services.Admission + ) + const namespaces = instance.inversifyContainer.get>( + TYPES.Config.Namespaces + ) + const passThroughPatterns = instance.inversifyContainer.get>( + TYPES.Config.PassthroughPatterns + ) const filter = instance.inversifyContainer.get(TYPES.Services.Filter) - const processStats : Record = {} + const processStats: Record = {} processStats.requestsServed = 0 - instance.post('/', async (req, reply) => { + instance.post("/", async (req, reply) => { const body: any = req.body const allowResponse = { - apiVersion: 'admission.k8s.io/v1', - kind: 'AdmissionReview', + apiVersion: "admission.k8s.io/v1", + kind: "AdmissionReview", response: { uid: body.request.uid, allowed: true } } - if (body.kind === 'AdmissionReview' && body.request.operation === 'CREATE' && body.request.kind.kind === 'Pod') { + if ( + body.kind === "AdmissionReview" && + body.request.operation === "CREATE" && + body.request.kind.kind === "Pod" + ) { const newPod: V1Pod = body.request.object - if (namespaces.length !== 0 && !namespaces.some((n) => n.toLowerCase() === newPod.metadata?.namespace?.toLowerCase())) { + if ( + namespaces.length !== 0 && + !namespaces.some( + (n) => n.toLowerCase() === newPod.metadata?.namespace?.toLowerCase() + ) + ) { reply.send(allowResponse) } else if (await filter.isIgnored(newPod)) { reply.send(allowResponse) - } else if (passThroughPatterns.some((p) => p.test(newPod.metadata?.name as string))) { + } else if ( + passThroughPatterns.some((p) => p.test(newPod.metadata?.name as string)) + ) { reply.send(allowResponse) } else if (await filter.isTargetted(newPod)) { const patch = await admissionService.admit(newPod) - instance.log.info('Generated patch = %s', patch) + instance.log.info("Generated patch = %s", patch) reply.send({ - apiVersion: 'admission.k8s.io/v1', - kind: 'AdmissionReview', + apiVersion: "admission.k8s.io/v1", + kind: "AdmissionReview", response: { uid: body.request.uid, allowed: true, - patch: Buffer.from(patch).toString('base64'), - patchType: 'JSONPatch' + patch: Buffer.from(patch).toString("base64"), + patchType: "JSONPatch" } }) } @@ -51,9 +72,9 @@ export function AdmissionController (instance: FastifyInstance, opts: FastifyPlu processStats.requestsServed = (processStats.requestsServed as number) + 1 }) - instance.get('/meta', async (req, reply) => { + instance.get("/meta", async (req, reply) => { reply.send(processStats) }) done() - instance.log.info('Finished Registering AdmissionController') + instance.log.info("Finished Registering AdmissionController") } diff --git a/src/inversify.config.ts b/src/inversify.config.ts index 6bd8b73..014dcc6 100644 --- a/src/inversify.config.ts +++ b/src/inversify.config.ts @@ -1,12 +1,16 @@ -import 'reflect-metadata' -import { Container, interfaces } from 'inversify' -import { TYPES } from './types' -import { IKubernetes, Kubernetes } from './services/kubernetes' -import { CoreV1Api, KubeConfig } from '@kubernetes/client-node' -import { Admission, IAdmission } from './services/admission' -import config from 'config' -import pino, { Logger } from 'pino' -import { Filter, IAnnotationMap, IFilter } from './services/filter' +import "reflect-metadata" +import { Container, interfaces } from "inversify" +import { TYPES } from "./types" +import { IKubernetes, Kubernetes } from "./services/kubernetes" +import { + AdmissionregistrationV1Api, + CoreV1Api, + KubeConfig +} from "@kubernetes/client-node" +import { Admission, IAdmission } from "./services/admission" +import config from "config" +import pino, { Logger } from "pino" +import { Filter, IAnnotationMap, IFilter } from "./services/filter" const appContainer = new Container() @@ -14,31 +18,78 @@ appContainer.bind(TYPES.Services.Kubernetes).to(Kubernetes) appContainer.bind(TYPES.Services.Admission).to(Admission) appContainer.bind(TYPES.Services.Filter).to(Filter) -appContainer.bind(TYPES.K8S.Config).toDynamicValue((context: interfaces.Context) => { - const config = new KubeConfig() - config.loadFromDefault() - return config -}) -appContainer.bind(TYPES.K8S.CoreApi).toDynamicValue((context: interfaces.Context) => { - const config = context.container.get(TYPES.K8S.Config) - return config.makeApiClient(CoreV1Api) -}) -appContainer.bind(TYPES.Config.TLSEnabled).toConstantValue(config.get('tls.enabled')) -appContainer.bind(TYPES.Config.TLSKeyPath).toConstantValue(config.get('tls.keyPath')) -appContainer.bind(TYPES.Config.TLSCertPath).toConstantValue(config.get('tls.certPath')) -appContainer.bind>(TYPES.Config.Namespaces).toConstantValue(config.get>('namespaces')) -appContainer.bind(TYPES.Config.DefaultFsGroup).toConstantValue(config.get('defaultFsGroup')) -appContainer.bind(TYPES.Config.DefaultGid).toConstantValue(config.get('defaultGid')) -appContainer.bind(TYPES.Config.DefaultUid).toConstantValue(config.get('defaultUid')) -appContainer.bind(TYPES.Config.SeccompProfile).toConstantValue(config.get('seccompProfile')) -appContainer.bind(TYPES.Config.AddSeccompProfile).toConstantValue(config.get('addSeccompProfile')) -appContainer.bind>(TYPES.Config.PassthroughPatterns).toConstantValue(config.get>('passthrough').map((s) => new RegExp(s))) -appContainer.bind>(TYPES.Config.IgnoredSet).toConstantValue(config.get>('ignoredAnnotations')) -appContainer.bind>(TYPES.Config.TargettedSet).toConstantValue(config.get>('targettedAnnotations')) +appContainer + .bind(TYPES.K8S.Config) + .toDynamicValue((context: interfaces.Context) => { + const config = new KubeConfig() + config.loadFromDefault() + return config + }) +appContainer + .bind(TYPES.K8S.CoreApi) + .toDynamicValue((context: interfaces.Context) => { + const config = context.container.get(TYPES.K8S.Config) + return config.makeApiClient(CoreV1Api) + }) +appContainer + .bind(TYPES.K8S.AdmissionApi) + .toDynamicValue((context: interfaces.Context) => { + const config = context.container.get(TYPES.K8S.Config) + return config.makeApiClient(AdmissionregistrationV1Api) + }) +appContainer + .bind(TYPES.Config.TLSEnabled) + .toConstantValue(config.get("tls.enabled")) +appContainer + .bind(TYPES.Config.TLSKeyPath) + .toConstantValue(config.get("tls.keyPath")) +appContainer + .bind(TYPES.Config.TLSCertPath) + .toConstantValue(config.get("tls.certPath")) +appContainer + .bind(TYPES.Config.SecretName) + .toConstantValue(config.get("tls.secretName")) +appContainer + .bind(TYPES.Config.HookName) + .toConstantValue(config.get("hookName")) +appContainer + .bind>(TYPES.Config.Namespaces) + .toConstantValue(config.get>("namespaces")) +appContainer + .bind(TYPES.Config.DefaultFsGroup) + .toConstantValue(config.get("defaultFsGroup")) +appContainer + .bind(TYPES.Config.DefaultGid) + .toConstantValue(config.get("defaultGid")) +appContainer + .bind(TYPES.Config.DefaultUid) + .toConstantValue(config.get("defaultUid")) +appContainer + .bind(TYPES.Config.SeccompProfile) + .toConstantValue(config.get("seccompProfile")) +appContainer + .bind(TYPES.Config.AddSeccompProfile) + .toConstantValue(config.get("addSeccompProfile")) +appContainer + .bind>(TYPES.Config.PassthroughPatterns) + .toConstantValue( + config.get>("passthrough").map((s) => new RegExp(s)) + ) +appContainer + .bind>(TYPES.Config.IgnoredSet) + .toConstantValue(config.get>("ignoredAnnotations")) +appContainer + .bind>(TYPES.Config.TargettedSet) + .toConstantValue(config.get>("targettedAnnotations")) +appContainer + .bind(TYPES.Config.HookNamespace) + .toConstantValue(config.get("hookNamespace")) // create pino parent logger for services to use -appContainer.bind(TYPES.Services.Logging).toConstantValue(pino({ - level: config.get('log.level') -})) +appContainer.bind(TYPES.Services.Logging).toConstantValue( + pino({ + level: config.get("log.level") + }) +) export { appContainer } diff --git a/src/inversify.fastify.plugin.ts b/src/inversify.fastify.plugin.ts index 34e38ba..539de13 100644 --- a/src/inversify.fastify.plugin.ts +++ b/src/inversify.fastify.plugin.ts @@ -1,8 +1,13 @@ -import { FastifyInstance, FastifyPluginOptions, FastifyReply, FastifyRequest } from 'fastify' -import fastifyPlugin from 'fastify-plugin' -import { Container } from 'inversify' +import { + FastifyInstance, + FastifyPluginOptions, + FastifyReply, + FastifyRequest +} from "fastify" +import fastifyPlugin from "fastify-plugin" +import { Container } from "inversify" -declare module 'fastify' { +declare module "fastify" { // eslint-disable-next-line no-unused-vars interface FastifyInstance { inversifyContainer: Container @@ -13,34 +18,50 @@ declare module 'fastify' { } } -export function InversifyFastifyPlugin (fastify: FastifyInstance, options: FastifyPluginOptions, done: Function) { - if (!options.container) done(new Error('options.container must be provided to the plugin')) - if (!(options.container instanceof Container)) done(new Error('options.container must be an instance of inversify.Container')) - fastify.log.info('Registering InversifyFastifyPlugin') - fastify.decorate('inversifyContainer', options.container) - fastify.decorateRequest('inversifyScope', null) +export function InversifyFastifyPlugin( + fastify: FastifyInstance, + options: FastifyPluginOptions, + done: Function +) { + if (!options.container) + done(new Error("options.container must be provided to the plugin")) + if (!(options.container instanceof Container)) + done( + new Error("options.container must be an instance of inversify.Container") + ) + fastify.log.info("Registering InversifyFastifyPlugin") + fastify.decorate("inversifyContainer", options.container) + fastify.decorateRequest("inversifyScope", null) - fastify.addHook('onRequest', (req: FastifyRequest, reply :FastifyReply, done: Function) => { - req.inversifyScope = fastify.inversifyContainer.createChild(options.requestScopeOptions) - done() - }) - if (options.disposeOnResponse) { - fastify.addHook('onResponse', (req: FastifyRequest, reply: FastifyReply, done: Function) => { - req.inversifyScope.unbindAll() + fastify.addHook( + "onRequest", + (req: FastifyRequest, reply: FastifyReply, done: Function) => { + req.inversifyScope = fastify.inversifyContainer.createChild( + options.requestScopeOptions + ) done() - }) + } + ) + if (options.disposeOnResponse) { + fastify.addHook( + "onResponse", + (req: FastifyRequest, reply: FastifyReply, done: Function) => { + req.inversifyScope.unbindAll() + done() + } + ) } if (options.disposeOnClose) { - fastify.addHook('onClose', (fastify: FastifyInstance, done: Function) => { + fastify.addHook("onClose", (fastify: FastifyInstance, done: Function) => { fastify.inversifyContainer.unbindAll() done() }) } done() - fastify.log.info('Finished Registering InversifyFastifyPlugin') + fastify.log.info("Finished Registering InversifyFastifyPlugin") } export default fastifyPlugin(InversifyFastifyPlugin, { - fastify: '4.x', - name: '@curium-rocks/fastify-inversify-plugin' + fastify: "4.x", + name: "@curium-rocks/fastify-inversify-plugin" }) diff --git a/src/server.ts b/src/server.ts index 7552b6d..9cb80ee 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,16 +1,25 @@ -import Fastify, { FastifyInstance, FastifyServerOptions } from 'fastify' -import { Container } from 'inversify' -import { AdmissionController } from './controllers/admission' -import fastifyInversifyPlugin from './inversify.fastify.plugin' -import fastifyUnderPressurePlugin from '@fastify/under-pressure' +import Fastify, { FastifyInstance, FastifyServerOptions } from "fastify" +import { Container } from "inversify" +import { AdmissionController } from "./controllers/admission" +import fastifyInversifyPlugin from "./inversify.fastify.plugin" +import fastifyUnderPressurePlugin from "@fastify/under-pressure" +import { IKubernetes } from "./services/kubernetes" +import { TYPES } from "./types" export class Server { private readonly container: Container private readonly fastify: FastifyInstance + private readonly k8s: IKubernetes private readonly host: string private readonly port: number - constructor (container: Container, options: FastifyServerOptions, host: string, port: number) { + constructor( + container: Container, + options: FastifyServerOptions, + host: string, + port: number + ) { this.container = container + this.k8s = container.get(TYPES.Services.Kubernetes) this.host = host this.port = port this.fastify = Fastify(options) @@ -18,8 +27,8 @@ export class Server { this.registerControllers() } - private registerPlugins () { - this.fastify.log.info('Registering plugins') + private registerPlugins() { + this.fastify.log.info("Registering plugins") this.fastify.register(fastifyInversifyPlugin, { container: this.container, disposeOnClose: false, @@ -30,29 +39,34 @@ export class Server { maxHeapUsedBytes: 100000000, maxRssBytes: 100000000, maxEventLoopUtilization: 0.98, - message: 'Unavailable', + message: "Unavailable", retryAfter: 50, - exposeStatusRoute: true + exposeStatusRoute: true, + healthCheck: async () => { + const result = await this.k8s.syncCaBundle() + return result + }, + healthCheckInterval: 15000 }) - this.fastify.log.info('Finished registering plugins') + this.fastify.log.info("Finished registering plugins") } - private registerControllers () { - this.fastify.log.info('Registering controllers') + private registerControllers() { + this.fastify.log.info("Registering controllers") this.fastify.register(AdmissionController, { - prefix: 'api/v1/admission' + prefix: "api/v1/admission" }) - this.fastify.log.info('Finished registering controllers') + this.fastify.log.info("Finished registering controllers") } - public async open () : Promise { + public async open(): Promise { await this.fastify.listen({ port: this.port, host: this.host }) } - public async close () : Promise { + public async close(): Promise { await this.fastify?.close() } } diff --git a/src/services/admission.ts b/src/services/admission.ts index af2edd6..585d3e9 100644 --- a/src/services/admission.ts +++ b/src/services/admission.ts @@ -1,8 +1,8 @@ -import { inject, injectable } from 'inversify' -import { TYPES } from '../types' -import { Logger } from 'pino' -import { V1Pod, V1PodSpec } from '@kubernetes/client-node' -import * as jsonpatch from 'fast-json-patch' +import { inject, injectable } from "inversify" +import { TYPES } from "../types" +import { Logger } from "pino" +import { V1Pod, V1PodSpec } from "@kubernetes/client-node" +import * as jsonpatch from "fast-json-patch" export interface IAdmission { /** @@ -22,14 +22,15 @@ export class Admission implements IAdmission { private readonly addSeccompProfile: boolean private readonly seccompProfile: string - constructor ( - @inject(TYPES.Services.Logging)parentLogger: Logger, - @inject(TYPES.Config.DefaultFsGroup)defaultFsGroup: number, - @inject(TYPES.Config.DefaultGid)defaultGid: number, - @inject(TYPES.Config.DefaultUid)defaultUid: number, - @inject(TYPES.Config.AddSeccompProfile)addSeccompProfile: boolean, - @inject(TYPES.Config.SeccompProfile)seccompProfile: string) { - this.logger = parentLogger.child({ module: 'services/Admission' }) + constructor( + @inject(TYPES.Services.Logging) parentLogger: Logger, + @inject(TYPES.Config.DefaultFsGroup) defaultFsGroup: number, + @inject(TYPES.Config.DefaultGid) defaultGid: number, + @inject(TYPES.Config.DefaultUid) defaultUid: number, + @inject(TYPES.Config.AddSeccompProfile) addSeccompProfile: boolean, + @inject(TYPES.Config.SeccompProfile) seccompProfile: string + ) { + this.logger = parentLogger.child({ module: "services/Admission" }) this.defaultFsGroup = defaultFsGroup this.defaultGid = defaultGid this.defaultUid = defaultUid @@ -37,12 +38,14 @@ export class Admission implements IAdmission { this.seccompProfile = seccompProfile } - async admit (pod: V1Pod): Promise { + async admit(pod: V1Pod): Promise { const observer = jsonpatch.observe(pod) const spec = pod.spec as V1PodSpec if (!spec.securityContext) spec.securityContext = {} - if (!spec.securityContext.runAsNonRoot) spec.securityContext.runAsNonRoot = true - if (!spec.securityContext.fsGroup) spec.securityContext.fsGroup = this.defaultFsGroup ?? 1001 + if (!spec.securityContext.runAsNonRoot) + spec.securityContext.runAsNonRoot = true + if (!spec.securityContext.fsGroup) + spec.securityContext.fsGroup = this.defaultFsGroup ?? 1001 if (!spec.securityContext.seccompProfile && this.addSeccompProfile) { spec.securityContext.seccompProfile = { type: this.seccompProfile @@ -50,15 +53,22 @@ export class Admission implements IAdmission { } spec.containers = spec.containers.map((c) => { if (!c.securityContext) c.securityContext = {} - if (c.securityContext.allowPrivilegeEscalation == null || c.securityContext.allowPrivilegeEscalation) c.securityContext.allowPrivilegeEscalation = false - if (c.securityContext.privileged == null || c.securityContext.privileged) c.securityContext.privileged = false - if (!c.securityContext.readOnlyRootFilesystem) c.securityContext.readOnlyRootFilesystem = true + if ( + c.securityContext.allowPrivilegeEscalation == null || + c.securityContext.allowPrivilegeEscalation + ) + c.securityContext.allowPrivilegeEscalation = false + if (c.securityContext.privileged == null || c.securityContext.privileged) + c.securityContext.privileged = false + if (!c.securityContext.readOnlyRootFilesystem) + c.securityContext.readOnlyRootFilesystem = true if (!c.securityContext.runAsNonRoot) c.securityContext.runAsNonRoot = true - if (!c.securityContext.runAsGroup) c.securityContext.runAsGroup = this.defaultGid ?? 1001 - if (!c.securityContext.runAsUser) c.securityContext.runAsUser = this.defaultUid ?? 1001 + if (!c.securityContext.runAsGroup) + c.securityContext.runAsGroup = this.defaultGid ?? 1001 + if (!c.securityContext.runAsUser) + c.securityContext.runAsUser = this.defaultUid ?? 1001 return c }) - return Promise.resolve(JSON.stringify(jsonpatch.generate(observer)) - ) + return Promise.resolve(JSON.stringify(jsonpatch.generate(observer))) } } diff --git a/src/services/filter.ts b/src/services/filter.ts index 546c549..d5f33c2 100644 --- a/src/services/filter.ts +++ b/src/services/filter.ts @@ -1,41 +1,41 @@ -import { V1Pod } from '@kubernetes/client-node' -import { inject, injectable } from 'inversify' -import { Logger } from 'pino' -import { TYPES } from '../types' +import { V1Pod } from "@kubernetes/client-node" +import { inject, injectable } from "inversify" +import { Logger } from "pino" +import { TYPES } from "../types" export type IAnnotationMap = Record export interface IFilter { - /** - * Take a pod, check against the set of ignored annotation maps, if - * the pod has matching annotations this will resolve with true and - * the admission controller should not mutate the pod. - * - * If no ignored maps are provided this will always resolve false - * @param {V1Pod} unmutated pod - * @returns {boolean} true if the pod should be ignored, false otherwise - */ - isIgnored(pod: V1Pod): Promise - /** - * Take a pod, check against the set of targetted annotation maps, if - * the pod has mattching annotations this will resolve true and - * the admission controller should mutate the pod. - * - * If not targetted maps are providded this will always resolve true - * @param {V1Pod} unmutated pod - * @returns {boolean} true if the should be enhanced, false otherwise - */ - isTargetted(pod: V1Pod): Promise - /** - * Check if all pods are targetted and no annotation filters have been set for targetting - * @returns {boolean} true if no pod filters have been set for targetting - */ - isTargetAll(): boolean - /** - * Check if no pods are ignored and no annotation filters have been set for ignoring - * @returns {boolean} true if no pod filters have been set for ignoring - */ - isIgnoreNone(): boolean - } + /** + * Take a pod, check against the set of ignored annotation maps, if + * the pod has matching annotations this will resolve with true and + * the admission controller should not mutate the pod. + * + * If no ignored maps are provided this will always resolve false + * @param {V1Pod} unmutated pod + * @returns {boolean} true if the pod should be ignored, false otherwise + */ + isIgnored(pod: V1Pod): Promise + /** + * Take a pod, check against the set of targetted annotation maps, if + * the pod has mattching annotations this will resolve true and + * the admission controller should mutate the pod. + * + * If not targetted maps are providded this will always resolve true + * @param {V1Pod} unmutated pod + * @returns {boolean} true if the should be enhanced, false otherwise + */ + isTargetted(pod: V1Pod): Promise + /** + * Check if all pods are targetted and no annotation filters have been set for targetting + * @returns {boolean} true if no pod filters have been set for targetting + */ + isTargetAll(): boolean + /** + * Check if no pods are ignored and no annotation filters have been set for ignoring + * @returns {boolean} true if no pod filters have been set for ignoring + */ + isIgnoreNone(): boolean +} @injectable() export class Filter implements IFilter { @@ -43,53 +43,66 @@ export class Filter implements IFilter { private readonly ignoredSet: Array private readonly targettedSet: Array - constructor ( - @inject(TYPES.Services.Logging)parentLogger: Logger, - @inject(TYPES.Config.IgnoredSet)ignoredSet: Array, - @inject(TYPES.Config.TargettedSet)targettedSet: Array) { - this.logger = parentLogger.child({ module: 'services/Filter' }) + constructor( + @inject(TYPES.Services.Logging) parentLogger: Logger, + @inject(TYPES.Config.IgnoredSet) ignoredSet: Array, + @inject(TYPES.Config.TargettedSet) targettedSet: Array + ) { + this.logger = parentLogger.child({ module: "services/Filter" }) this.ignoredSet = ignoredSet this.targettedSet = targettedSet } - private checkAnnotationMap (pod:V1Pod, matchSet: Array): Promise { - return Promise.resolve(matchSet.some((annotationSet: IAnnotationMap) => { - // the pod must have matching annotation, for now, not dealing with case normalization - // if the pod doesn't have annotations, it will not match - // lets walk the provided set and return false on the first failure - for (const kvp of Object.entries(annotationSet)) { - if (pod.metadata?.annotations != null && (pod.metadata?.annotations[kvp[0]] == null || pod.metadata.annotations[kvp[0]] !== kvp[1])) return false - } - // if we reach this spot - // 1) the pod either had no annotations (should be filtered before running some filter but just in case) - // 2) the pod had all the annotations and matching values - return true - })) + private checkAnnotationMap( + pod: V1Pod, + matchSet: Array + ): Promise { + return Promise.resolve( + matchSet.some((annotationSet: IAnnotationMap) => { + // the pod must have matching annotation, for now, not dealing with case normalization + // if the pod doesn't have annotations, it will not match + // lets walk the provided set and return false on the first failure + for (const kvp of Object.entries(annotationSet)) { + if ( + pod.metadata?.annotations != null && + (pod.metadata?.annotations[kvp[0]] == null || + pod.metadata.annotations[kvp[0]] !== kvp[1]) + ) + return false + } + // if we reach this spot + // 1) the pod either had no annotations (should be filtered before running some filter but just in case) + // 2) the pod had all the annotations and matching values + return true + }) + ) } /** * @inheritdoc */ - isIgnored (pod: V1Pod): Promise { + isIgnored(pod: V1Pod): Promise { if (this.isIgnoreNone()) return Promise.resolve(false) - if (pod.metadata == null || pod.metadata.annotations == null) return Promise.resolve(false) + if (pod.metadata == null || pod.metadata.annotations == null) + return Promise.resolve(false) return this.checkAnnotationMap(pod, this.ignoredSet) } /** @inheritdoc */ - isTargetted (pod: V1Pod): Promise { + isTargetted(pod: V1Pod): Promise { if (this.isTargetAll()) return Promise.resolve(true) - if (pod.metadata == null || pod.metadata.annotations == null) return Promise.resolve(true) + if (pod.metadata == null || pod.metadata.annotations == null) + return Promise.resolve(true) return this.checkAnnotationMap(pod, this.targettedSet) } /** @inheritdoc */ - isTargetAll (): boolean { + isTargetAll(): boolean { return this.targettedSet.length === 0 } /** @inheritdoc */ - isIgnoreNone (): boolean { + isIgnoreNone(): boolean { return this.ignoredSet.length === 0 } } diff --git a/src/services/kubernetes.ts b/src/services/kubernetes.ts index db131c6..7331f52 100644 --- a/src/services/kubernetes.ts +++ b/src/services/kubernetes.ts @@ -1,8 +1,109 @@ -import { injectable } from 'inversify' +import { + AdmissionregistrationV1Api, + CoreV1Api, + PatchUtils, + V1MutatingWebhook, + V1MutatingWebhookConfiguration +} from "@kubernetes/client-node" +import { inject, injectable } from "inversify" +import { TYPES } from "../types" +import { Logger } from "pino" +import * as jsonpatch from "fast-json-patch" export interface IKubernetes { + /** + * Check if the bundle is synced on the hook configuration, if not sync it, resolve false or throw if unable to sync + */ + syncCaBundle(): Promise } @injectable() export class Kubernetes implements IKubernetes { + static readonly CA_CRT = "ca.crt" + private readonly log: Logger + private readonly hookName: string + private readonly tlsSecretName: string + private readonly namespaceName: string + private readonly kubeClient: CoreV1Api + private readonly admissionKubeClient: AdmissionregistrationV1Api + + constructor( + @inject(TYPES.Services.Logging) parentLogger: Logger, + @inject(TYPES.Config.HookName) hookName: string, + @inject(TYPES.Config.SecretName) secretName: string, + @inject(TYPES.Config.HookNamespace) namespace: string, + @inject(TYPES.K8S.CoreApi) kubeClient: CoreV1Api, + @inject(TYPES.K8S.AdmissionApi) admissionApi: AdmissionregistrationV1Api + ) { + this.log = parentLogger.child({ module: "services/Kubernetes" }) + this.hookName = hookName + this.tlsSecretName = secretName + this.kubeClient = kubeClient + this.namespaceName = namespace + this.admissionKubeClient = admissionApi + } + + private generateCaPatch( + hook: V1MutatingWebhookConfiguration, + newCa: string + ): any { + const observer = jsonpatch.observe(hook) + const config = hook.webhooks?.at(0) as V1MutatingWebhook + config.clientConfig.caBundle = newCa + return jsonpatch + .generate(observer) + .filter((patch) => patch.path === "/webhooks/0/clientConfig/caBundle") + } + + /** @inheritdoc */ + async syncCaBundle(): Promise { + // on any failure resolve failse + try { + // get the secret first + const secret = await this.kubeClient.readNamespacedSecret( + this.tlsSecretName, + this.namespaceName + ) + if (secret.body.data == null) + throw new Error("Secret data property is null, cannot sync ca.crt") + if (secret.body.data[Kubernetes.CA_CRT] == null) + throw new Error("ca.crt is not present on secret, cannot sync") + const caCrt = secret.body.data[Kubernetes.CA_CRT] as string + // fetch the mutating webhook + const webhook = + await this.admissionKubeClient.readMutatingWebhookConfiguration( + this.hookName + ) + // we have one hook and one ruleset + if (webhook.body.webhooks == null || webhook.body.webhooks.length === 0) + throw new Error("Invalid hookName configuration provided, no webhooks") + const caBundle = webhook.body.webhooks[0].clientConfig.caBundle + if (caCrt !== caBundle) { + // the bundles have diverged we need to update + this.log.warn( + "Updating webhook configurations clientConfig.caBundle to match ca.crt in tlsSecret" + ) + this.log.debug("TLS Secret ca.crt value = %s", caCrt) + this.log.debug("clientConfig.caBundle value = %s", caBundle) + const options = { + headers: { "Content-type": PatchUtils.PATCH_FORMAT_JSON_PATCH } + } + const patch = this.generateCaPatch(webhook.body, caCrt) + await this.admissionKubeClient.patchMutatingWebhookConfiguration( + this.hookName, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + options + ) + } + return Promise.resolve(true) + } catch (err) { + this.log.error(err, "Error while checking ca bundle sync status") + return Promise.resolve(false) + } + } } diff --git a/src/types.ts b/src/types.ts index 127aa5f..de91183 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,30 +1,34 @@ const TYPES = { Services: { - Kubernetes: Symbol.for('Kubernetes'), - Admission: Symbol.for('Admission'), - Logging: Symbol.for('Logging'), - Filter: Symbol.for('Filter') + Kubernetes: Symbol.for("Kubernetes"), + Admission: Symbol.for("Admission"), + Logging: Symbol.for("Logging"), + Filter: Symbol.for("Filter") }, Config: { - AllowedList: Symbol.for('AllowedList'), - BlockedList: Symbol.for('BlockedList'), - StrictMode: Symbol.for('StrictMode'), - Namespaces: Symbol.for('Namespaces'), - DefaultUid: Symbol.for('DefaultUid'), - DefaultGid: Symbol.for('DefaultGid'), - DefaultFsGroup: Symbol.for('DefaultFsGroup'), - TLSEnabled: Symbol.for('TLSEnabled'), - TLSKeyPath: Symbol.for('TLSKeyPath'), - TLSCertPath: Symbol.for('TLSCertPath'), - SeccompProfile: Symbol.for('SeccompProfile'), - AddSeccompProfile: Symbol.for('AddSeccompProfile'), - PassthroughPatterns: Symbol.for('PassthroughPatterns'), - IgnoredSet: Symbol.for('IgnoredSet'), - TargettedSet: Symbol.for('TargettedSet') + AllowedList: Symbol.for("AllowedList"), + BlockedList: Symbol.for("BlockedList"), + StrictMode: Symbol.for("StrictMode"), + Namespaces: Symbol.for("Namespaces"), + DefaultUid: Symbol.for("DefaultUid"), + DefaultGid: Symbol.for("DefaultGid"), + DefaultFsGroup: Symbol.for("DefaultFsGroup"), + TLSEnabled: Symbol.for("TLSEnabled"), + TLSKeyPath: Symbol.for("TLSKeyPath"), + TLSCertPath: Symbol.for("TLSCertPath"), + SeccompProfile: Symbol.for("SeccompProfile"), + AddSeccompProfile: Symbol.for("AddSeccompProfile"), + PassthroughPatterns: Symbol.for("PassthroughPatterns"), + IgnoredSet: Symbol.for("IgnoredSet"), + TargettedSet: Symbol.for("TargettedSet"), + SecretName: Symbol.for("TLSSecretName"), + HookName: Symbol.for("HookName"), + HookNamespace: Symbol.for("HookNamespace") }, K8S: { - Config: Symbol.for('Config'), - CoreApi: Symbol.for('CoreApi') + Config: Symbol.for("Config"), + CoreApi: Symbol.for("CoreApi"), + AdmissionApi: Symbol.for("AdmissionApi") } } export { TYPES } diff --git a/test/app.spec.ts b/test/app.spec.ts index 19c732f..cd92e88 100644 --- a/test/app.spec.ts +++ b/test/app.spec.ts @@ -1,20 +1,30 @@ -import { describe, it, expect } from '@jest/globals' -import app from '../src/app' -import { appContainer } from '../src/inversify.config' -import { Server } from '../src/server' -describe('app', () => { - it('Should create the server', async () => { - const appObj = await app(appContainer, { - logger: false - }, '127.0.0.1', 30001) +import { describe, it, expect } from "@jest/globals" +import app from "../src/app" +import { appContainer } from "../src/inversify.config" +import { Server } from "../src/server" +describe("app", () => { + it("Should create the server", async () => { + const appObj = await app( + appContainer, + { + logger: false + }, + "127.0.0.1", + 30001 + ) expect(appObj).toBeInstanceOf(Server) await appObj.open() await appObj.close() }) - it('Should close server', async () => { - const appObj = await app(appContainer, { - logger: false - }, '127.0.0.1', 30002) + it("Should close server", async () => { + const appObj = await app( + appContainer, + { + logger: false + }, + "127.0.0.1", + 30002 + ) expect(appObj).toBeInstanceOf(Server) await appObj.open() await appObj.close() diff --git a/test/config/test.json b/test/config/test.json index 9e26dfe..0967ef4 100644 --- a/test/config/test.json +++ b/test/config/test.json @@ -1 +1 @@ -{} \ No newline at end of file +{} diff --git a/test/controllers/admission.e2e.spec.ts b/test/controllers/admission.e2e.spec.ts index a0c47bb..e54f09c 100644 --- a/test/controllers/admission.e2e.spec.ts +++ b/test/controllers/admission.e2e.spec.ts @@ -1,26 +1,46 @@ -import { describe, it, afterAll, beforeAll, expect } from '@jest/globals' -import { CoreV1Api, KubeConfig, V1Namespace, V1Pod } from '@kubernetes/client-node' -import { randomUUID } from 'node:crypto' +import { describe, it, afterAll, beforeAll, expect } from "@jest/globals" +import { + CoreV1Api, + KubeConfig, + V1Namespace, + V1Pod +} from "@kubernetes/client-node" +import { randomUUID } from "node:crypto" -const TEST_NAMESPACE = 'k8s-mutating-webhook' +const TEST_NAMESPACE = "k8s-mutating-webhook" -describe('controllers/admission', () => { +describe("controllers/admission", () => { let client: CoreV1Api - async function deletePods () : Promise { + async function deletePods(): Promise { const pods = await client.listNamespacedPod(TEST_NAMESPACE) - const deleteProms = pods.body.items.map((pod: V1Pod) => client.deleteNamespacedPod(pod.metadata?.name as string, pod.metadata?.namespace as string)) + const deleteProms = pods.body.items.map((pod: V1Pod) => + client.deleteNamespacedPod( + pod.metadata?.name as string, + pod.metadata?.namespace as string + ) + ) return Promise.all(deleteProms) } - async function deleteNamespace () : Promise { + async function deleteNamespace(): Promise { try { - if ((await client.listNamespace()).body.items.some((ele:V1Namespace) => { - return ele.metadata?.name === TEST_NAMESPACE - })) { + if ( + (await client.listNamespace()).body.items.some((ele: V1Namespace) => { + return ele.metadata?.name === TEST_NAMESPACE + }) + ) { await deletePods() - await client.deleteNamespace(TEST_NAMESPACE, undefined, undefined, 0, true) + await client.deleteNamespace( + TEST_NAMESPACE, + undefined, + undefined, + 0, + true + ) // wait for namespace to terminate, this could be more elegant and watch for termination to complete - await new Promise((resolve) => { setTimeout(resolve, 5000) }) + await new Promise((resolve) => { + setTimeout(resolve, 5000) + }) } } catch (err) { console.error(err) @@ -47,20 +67,22 @@ describe('controllers/admission', () => { afterAll(async () => { await deleteNamespace() }) - it('Should enhance busybox', async () => { + it("Should enhance busybox", async () => { const resp = await client.createNamespacedPod(TEST_NAMESPACE, { metadata: { name: `test-enhance-${randomUUID()}`, namespace: TEST_NAMESPACE }, spec: { - containers: [{ - image: 'busybox', - name: 'busybox' - }] + containers: [ + { + image: "busybox", + name: "busybox" + } + ] } } as V1Pod) - expect(resp.response.statusMessage).toEqual('Created') + expect(resp.response.statusMessage).toEqual("Created") expect(resp.body.spec?.securityContext?.runAsNonRoot).toBeTruthy() resp.body.spec?.containers.forEach((c) => { expect(c.securityContext?.allowPrivilegeEscalation).toBeFalsy() diff --git a/test/controllers/admission.spec.ts b/test/controllers/admission.spec.ts index 7d5932c..1c5e215 100644 --- a/test/controllers/admission.spec.ts +++ b/test/controllers/admission.spec.ts @@ -1,94 +1,110 @@ -import { describe, it, beforeEach, jest, expect } from '@jest/globals' -import 'reflect-metadata' -import Fastify, { FastifyInstance } from 'fastify' -import { Admission, IAdmission } from '../../src/services/admission' -import fastifyInversifyPlugin from '../../src/inversify.fastify.plugin' -import { AdmissionController } from '../../src/controllers/admission' -import { Container } from 'inversify' -import { TYPES } from '../../src/types' -import pino from 'pino' -import { V1Pod } from '@kubernetes/client-node' -import * as jsonpatch from 'fast-json-patch' -import { Filter, IFilter } from '../../src/services/filter' +import { describe, it, beforeEach, jest, expect } from "@jest/globals" +import "reflect-metadata" +import Fastify, { FastifyInstance } from "fastify" +import { Admission, IAdmission } from "../../src/services/admission" +import fastifyInversifyPlugin from "../../src/inversify.fastify.plugin" +import { AdmissionController } from "../../src/controllers/admission" +import { Container } from "inversify" +import { TYPES } from "../../src/types" +import pino from "pino" +import { V1Pod } from "@kubernetes/client-node" +import * as jsonpatch from "fast-json-patch" +import { Filter, IFilter } from "../../src/services/filter" -function buildCreatePodRequest (imageName: string) : any { - const baseReq = require('../requests/createPod.json') +function buildCreatePodRequest(imageName: string): any { + const baseReq = require("../requests/createPod.json") baseReq.request.object.spec.containers[0].image = imageName return baseReq } -describe('controllers/admission', () => { +describe("controllers/admission", () => { let fastify: FastifyInstance let container: Container let mockAdmissionService: jest.Mocked beforeEach(() => { fastify = Fastify() container = new Container() - const logger = pino({ level: 'error' }) - mockAdmissionService = jest.mocked(new Admission(logger, 1001, 1001, 1001, false, 'RuntimeDefault')) - container.bind(TYPES.Services.Filter).toConstantValue(new Filter(logger, [], [])) - container.bind(TYPES.Services.Admission).toConstantValue(mockAdmissionService) + const logger = pino({ level: "error" }) + mockAdmissionService = jest.mocked( + new Admission(logger, 1001, 1001, 1001, false, "RuntimeDefault") + ) + container + .bind(TYPES.Services.Filter) + .toConstantValue(new Filter(logger, [], [])) + container + .bind(TYPES.Services.Admission) + .toConstantValue(mockAdmissionService) container.bind>(TYPES.Config.Namespaces).toConstantValue([]) - container.bind>(TYPES.Config.PassthroughPatterns).toConstantValue([]) + container + .bind>(TYPES.Config.PassthroughPatterns) + .toConstantValue([]) fastify.register(fastifyInversifyPlugin, { container }) fastify.register(AdmissionController, { - prefix: '/api/v1/admission' + prefix: "/api/v1/admission" }) }) - it('Should patch pods when needed', async () => { - jest.spyOn(mockAdmissionService, 'admit').mockImplementation((pod: V1Pod) => { - const observer = jsonpatch.observe(pod) - if (!pod.metadata) pod.metadata = {} - if (!pod.metadata.annotations) pod.metadata.annotations = {} - pod.metadata.annotations.test = 'test' - return Promise.resolve(JSON.stringify(jsonpatch.generate(observer))) - }) - const payload = buildCreatePodRequest('busybox') + it("Should patch pods when needed", async () => { + jest + .spyOn(mockAdmissionService, "admit") + .mockImplementation((pod: V1Pod) => { + const observer = jsonpatch.observe(pod) + if (!pod.metadata) pod.metadata = {} + if (!pod.metadata.annotations) pod.metadata.annotations = {} + pod.metadata.annotations.test = "test" + return Promise.resolve(JSON.stringify(jsonpatch.generate(observer))) + }) + const payload = buildCreatePodRequest("busybox") const result = await fastify.inject({ - method: 'POST', + method: "POST", payload, - url: '/api/v1/admission' + url: "/api/v1/admission" }) expect(result.statusCode).toBe(200) const responseBody = JSON.parse(result.body) expect(responseBody.response.uid).toEqual(payload.request.uid) expect(responseBody.response.allowed).toBeTruthy() - const patch = Buffer.from(responseBody.response.patch, 'base64').toString() - expect(patch).toEqual('[{"op":"add","path":"/metadata/annotations","value":{"test":"test"}}]') + const patch = Buffer.from(responseBody.response.patch, "base64").toString() + expect(patch).toEqual( + '[{"op":"add","path":"/metadata/annotations","value":{"test":"test"}}]' + ) }) - it('Should only mutate pods when required', async () => { - jest.spyOn(mockAdmissionService, 'admit').mockImplementation((pod: V1Pod) => { - const observer = jsonpatch.observe(pod) - return Promise.resolve(JSON.stringify(jsonpatch.generate(observer))) - }) - const payload = buildCreatePodRequest('busybox') + it("Should only mutate pods when required", async () => { + jest + .spyOn(mockAdmissionService, "admit") + .mockImplementation((pod: V1Pod) => { + const observer = jsonpatch.observe(pod) + return Promise.resolve(JSON.stringify(jsonpatch.generate(observer))) + }) + const payload = buildCreatePodRequest("busybox") const result = await fastify.inject({ - method: 'POST', + method: "POST", payload, - url: '/api/v1/admission' + url: "/api/v1/admission" }) expect(result.statusCode).toBe(200) const responseBody = JSON.parse(result.body) expect(responseBody.response.uid).toEqual(payload.request.uid) expect(responseBody.response.allowed).toBeTruthy() - expect(responseBody.response.patch).toEqual(Buffer.from(JSON.stringify([])).toString('base64')) + expect(responseBody.response.patch).toEqual( + Buffer.from(JSON.stringify([])).toString("base64") + ) }) - it('Should track requests served', async () => { - jest.spyOn(mockAdmissionService, 'admit').mockImplementation((pod) => { + it("Should track requests served", async () => { + jest.spyOn(mockAdmissionService, "admit").mockImplementation((pod) => { const observer = jsonpatch.observe(pod) return Promise.resolve(JSON.stringify(jsonpatch.generate(observer))) }) const testReqResp = await fastify.inject({ - method: 'POST', - payload: buildCreatePodRequest('busybox'), - url: '/api/v1/admission' + method: "POST", + payload: buildCreatePodRequest("busybox"), + url: "/api/v1/admission" }) expect(testReqResp.statusCode).toEqual(200) const metaFetchResult = await fastify.inject({ - method: 'GET', - url: '/api/v1/admission/meta' + method: "GET", + url: "/api/v1/admission/meta" }) const resp = JSON.parse(metaFetchResult.body) expect(resp.requestsServed).toBeGreaterThan(0) diff --git a/test/inversify.config.spec.ts b/test/inversify.config.spec.ts index 91549a2..7ffbb87 100644 --- a/test/inversify.config.spec.ts +++ b/test/inversify.config.spec.ts @@ -1,21 +1,23 @@ -import { describe, it, expect } from '@jest/globals' -import 'reflect-metadata' -import { TYPES } from '../src/types' -import { appContainer } from '../src/inversify.config' -import { CoreV1Api } from '@kubernetes/client-node' +import { describe, it, expect } from "@jest/globals" +import "reflect-metadata" +import { TYPES } from "../src/types" +import { appContainer } from "../src/inversify.config" +import { CoreV1Api } from "@kubernetes/client-node" -describe('inversify.config', () => { - it('binds K8s Api', () => { +describe("inversify.config", () => { + it("binds K8s Api", () => { expect(appContainer.isBound(TYPES.K8S.CoreApi)).toBeTruthy() - expect(appContainer.get(TYPES.K8S.CoreApi)).toBeInstanceOf(CoreV1Api) + expect(appContainer.get(TYPES.K8S.CoreApi)).toBeInstanceOf( + CoreV1Api + ) }) - it('binds K8s Config', () => { + it("binds K8s Config", () => { expect(appContainer.isBound(TYPES.K8S.Config)).toBeTruthy() }) - it('binds Kubernetes Service', () => { + it("binds Kubernetes Service", () => { expect(appContainer.isBound(TYPES.Services.Kubernetes)).toBeTruthy() }) - it('binds Admission Service', () => { + it("binds Admission Service", () => { expect(appContainer.isBound(TYPES.Services.Admission)).toBeTruthy() }) }) diff --git a/test/inversify.fastify.plugin.spec.ts b/test/inversify.fastify.plugin.spec.ts index 8ba3375..aef31ba 100644 --- a/test/inversify.fastify.plugin.spec.ts +++ b/test/inversify.fastify.plugin.spec.ts @@ -1,23 +1,11 @@ -import { describe, it } from '@jest/globals' -import 'reflect-metadata' - -describe('inversify.fastify.plugin', () => { - it('Should expose root container to requests', () => { - - }) - it('Should expose request scope to requests', () => { - - }) - it('Should optionally cleanup scope on response', () => { - - }) - it('Should optionally cleanup scope on close', () => { - - }) - it('Should not cleanup root scope by default', () => { - - }) - it('Should not cleanup request scope by default', () => { - - }) +import { describe, it } from "@jest/globals" +import "reflect-metadata" + +describe("inversify.fastify.plugin", () => { + it("Should expose root container to requests", () => {}) + it("Should expose request scope to requests", () => {}) + it("Should optionally cleanup scope on response", () => {}) + it("Should optionally cleanup scope on close", () => {}) + it("Should not cleanup root scope by default", () => {}) + it("Should not cleanup request scope by default", () => {}) }) diff --git a/test/requests/createPod.json b/test/requests/createPod.json index e0db420..827810f 100644 --- a/test/requests/createPod.json +++ b/test/requests/createPod.json @@ -28,10 +28,7 @@ "operation": "CREATE", "userInfo": { "username": "minikube-user", - "groups": [ - "system:masters", - "system:authenticated" - ] + "groups": ["system:masters", "system:authenticated"] }, "object": { "kind": "Pod", @@ -177,4 +174,4 @@ "fieldManager": "kubectl-run" } } -} \ No newline at end of file +} diff --git a/test/server.spec.ts b/test/server.spec.ts index f318170..b627d30 100644 --- a/test/server.spec.ts +++ b/test/server.spec.ts @@ -1,11 +1,7 @@ -import { describe, it } from '@jest/globals' -import 'reflect-metadata' +import { describe, it } from "@jest/globals" +import "reflect-metadata" -describe('server', () => { - it('Should load all controller routes', () => { - - }) - it('Should bind to specified port', () => { - - }) +describe("server", () => { + it("Should load all controller routes", () => {}) + it("Should bind to specified port", () => {}) }) diff --git a/test/services/admission.spec.ts b/test/services/admission.spec.ts index 9463fd0..11ac26b 100644 --- a/test/services/admission.spec.ts +++ b/test/services/admission.spec.ts @@ -1,49 +1,55 @@ -import { describe, it, expect } from '@jest/globals' -import 'reflect-metadata' -import { Admission } from '../../src/services/admission' -import pino from 'pino' -import { V1Pod } from '@kubernetes/client-node' +import { describe, it, expect } from "@jest/globals" +import "reflect-metadata" +import { Admission } from "../../src/services/admission" +import pino from "pino" +import { V1Pod } from "@kubernetes/client-node" -describe('services/admission', () => { +describe("services/admission", () => { const pinoLogger = pino({ - level: 'error' + level: "error" }) - it('Should mutate to a more secure setting', async () => { - const service = new Admission(pinoLogger, 1001, 1001, 1001, false, 'test') + it("Should mutate to a more secure setting", async () => { + const service = new Admission(pinoLogger, 1001, 1001, 1001, false, "test") const newPod: V1Pod = { spec: { - containers: [{ - name: 'test', - image: 'test' - }] + containers: [ + { + name: "test", + image: "test" + } + ] } } const patch = await service.admit(newPod) - expect(patch).toEqual('[{"op":"add","path":"/spec/containers/0/securityContext","value":{"allowPrivilegeEscalation":false,"privileged":false,"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsGroup":1001,"runAsUser":1001}},{"op":"add","path":"/spec/securityContext","value":{"runAsNonRoot":true,"fsGroup":1001}}]') + expect(patch).toEqual( + '[{"op":"add","path":"/spec/containers/0/securityContext","value":{"allowPrivilegeEscalation":false,"privileged":false,"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsGroup":1001,"runAsUser":1001}},{"op":"add","path":"/spec/securityContext","value":{"runAsNonRoot":true,"fsGroup":1001}}]' + ) }) - it('Should not mutate already secure pod', async () => { - const service = new Admission(pinoLogger, 1001, 1001, 1001, false, 'test') + it("Should not mutate already secure pod", async () => { + const service = new Admission(pinoLogger, 1001, 1001, 1001, false, "test") const newPod: V1Pod = { spec: { securityContext: { runAsNonRoot: true, fsGroup: 1001 }, - containers: [{ - name: 'test', - image: 'test', - securityContext: { - runAsNonRoot: true, - readOnlyRootFilesystem: true, - privileged: false, - allowPrivilegeEscalation: false, - runAsGroup: 1001, - runAsUser: 1001 + containers: [ + { + name: "test", + image: "test", + securityContext: { + runAsNonRoot: true, + readOnlyRootFilesystem: true, + privileged: false, + allowPrivilegeEscalation: false, + runAsGroup: 1001, + runAsUser: 1001 + } } - }] + ] } } const patch = await service.admit(newPod) - expect(patch).toEqual('[]') + expect(patch).toEqual("[]") }) }) diff --git a/test/services/filter.spec.ts b/test/services/filter.spec.ts index 7becd8f..869fa54 100644 --- a/test/services/filter.spec.ts +++ b/test/services/filter.spec.ts @@ -1,90 +1,102 @@ -import { describe, it, expect } from '@jest/globals' -import 'reflect-metadata' -import { Filter } from '../../src/services/filter' -import pino from 'pino' -import { V1Pod } from '@kubernetes/client-node' +import { describe, it, expect } from "@jest/globals" +import "reflect-metadata" +import { Filter } from "../../src/services/filter" +import pino from "pino" +import { V1Pod } from "@kubernetes/client-node" -describe('services/filter', () => { +describe("services/filter", () => { const pinoLogger = pino({ - level: 'error' + level: "error" }) const podFactory = (annotations: Record): V1Pod => { // we don't care about the rest of the pod properties for these tests // provide just enough to exercise the code return { metadata: { - name: 'TEST', - namespace: 'TEST', + name: "TEST", + namespace: "TEST", annotations } } as any } - describe('isIgnored', () => { - it('should resolve true when pod does match', () => { + describe("isIgnored", () => { + it("should resolve true when pod does match", () => { const ignoreSet: Record = { - TEST_1: 'TEST_1_VALUE', - TEST_2: 'TEST_2_VALUE' + TEST_1: "TEST_1_VALUE", + TEST_2: "TEST_2_VALUE" } const filter = new Filter(pinoLogger, [ignoreSet], []) expect(filter.isIgnored(podFactory(ignoreSet))).resolves.toBe(true) }) - it('should resolve false when pod does not match', () => { + it("should resolve false when pod does not match", () => { const ignoreSet: Record = { - TEST_1: 'TEST_1_VALUE', - TEST_2: 'TEST_2_VALUE' + TEST_1: "TEST_1_VALUE", + TEST_2: "TEST_2_VALUE" } const filter = new Filter(pinoLogger, [ignoreSet], []) - expect(filter.isIgnored(podFactory({ - NOT_TEST_1: 'NOT_TEST_1_VALUE', - NOT_TEST_2: 'NOT_TEST_2_VALUE' - })) + expect( + filter.isIgnored( + podFactory({ + NOT_TEST_1: "NOT_TEST_1_VALUE", + NOT_TEST_2: "NOT_TEST_2_VALUE" + }) + ) ).resolves.toBe(false) }) - it('should resolve false when no sets are provided', () => { + it("should resolve false when no sets are provided", () => { const filter = new Filter(pinoLogger, [], []) - expect(filter.isIgnored(podFactory({ - TEST: 'TEST' - })) + expect( + filter.isIgnored( + podFactory({ + TEST: "TEST" + }) + ) ).resolves.toBe(false) }) }) - describe('isTargetted', () => { - it('should resolve true when pod does match', () => { + describe("isTargetted", () => { + it("should resolve true when pod does match", () => { const targetSet: Record = { - TEST_1: 'TEST_1_VALUE', - TEST_2: 'TEST_2_VALUE' + TEST_1: "TEST_1_VALUE", + TEST_2: "TEST_2_VALUE" } const filter = new Filter(pinoLogger, [], [targetSet]) expect(filter.isTargetted(podFactory(targetSet))).resolves.toBe(true) }) - it('should resolve false when pod does not match', () => { + it("should resolve false when pod does not match", () => { const targetSet: Record = { - TEST_1: 'TEST_1_VALUE', - TEST_2: 'TEST_2_VALUE' + TEST_1: "TEST_1_VALUE", + TEST_2: "TEST_2_VALUE" } const filter = new Filter(pinoLogger, [], [targetSet]) - expect(filter.isTargetted(podFactory({ - NOT_TEST_1: 'NOT_TEST_1_VALUE', - NOT_TEST_2: 'NOT_TEST_2_VALUE' - })) + expect( + filter.isTargetted( + podFactory({ + NOT_TEST_1: "NOT_TEST_1_VALUE", + NOT_TEST_2: "NOT_TEST_2_VALUE" + }) + ) ).resolves.toBe(false) }) - it('should resolve true when no sets are provided', () => { + it("should resolve true when no sets are provided", () => { const filter = new Filter(pinoLogger, [], []) - expect(filter.isTargetted(podFactory({ - TEST: 'TEST' - })) + expect( + filter.isTargetted( + podFactory({ + TEST: "TEST" + }) + ) ).resolves.toBe(true) }) }) - describe('isTargetAll', () => { - it('should resolve true when no sets are provided', () => { + describe("isTargetAll", () => { + it("should resolve true when no sets are provided", () => { const filter = new Filter(pinoLogger, [], []) expect(filter.isTargetAll()).toBe(true) }) }) - describe('isIgnoredNone', () => { - it('should resolve true when no sets are provided', () => { + describe("isIgnoredNone", () => { + it("should resolve true when no sets are provided", () => { const filter = new Filter(pinoLogger, [], []) expect(filter.isIgnoreNone()).toBe(true) }) diff --git a/test/services/kubernetes.spec.ts b/test/services/kubernetes.spec.ts new file mode 100644 index 0000000..4c346b8 --- /dev/null +++ b/test/services/kubernetes.spec.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals" +import { + AdmissionregistrationV1Api, + CoreV1Api, + KubeConfig +} from "@kubernetes/client-node" +import pino, { Logger } from "pino" +import "reflect-metadata" +import { Kubernetes } from "../../src/services/kubernetes" + +describe("services/kubernetes", () => { + describe("syncCaBundle", () => { + let mockCore: jest.Mocked + let mockAdmission: jest.Mocked + let logger: Logger + + beforeEach(() => { + const kc = new KubeConfig() + kc.loadFromDefault() + logger = pino({ level: "error" }) + mockCore = jest.mocked(kc.makeApiClient(CoreV1Api)) + mockAdmission = jest.mocked( + kc.makeApiClient(AdmissionregistrationV1Api) + ) + }) + it("should update web hook configuration when caBundle does not match", async () => { + jest + .spyOn(mockCore, "readNamespacedSecret") + .mockImplementation((): any => { + return Promise.resolve({ + body: { + data: { + "ca.crt": "TEST_VAL" + } + } + }) + }) + jest + .spyOn(mockAdmission, "readMutatingWebhookConfiguration") + .mockImplementation((): any => { + return Promise.resolve({ + body: { + webhooks: [ + { + clientConfig: { + caBundle: "NOT_TEST_VAL" + } + } + ] + } + }) + }) + jest + .spyOn(mockAdmission, "patchMutatingWebhookConfiguration") + .mockImplementation((): any => { + return Promise.resolve() + }) + const service = new Kubernetes( + logger, + "TEST_HOOK", + "TEST_SECRET", + "TEST_NAMESPACE", + mockCore, + mockAdmission + ) + const result = await service.syncCaBundle() + expect(result).toBe(true) + expect( + mockAdmission.patchMutatingWebhookConfiguration.mock.calls.length + ).toBe(1) + }) + it("should not update web hook configuration when caBundle matches", async () => { + jest + .spyOn(mockCore, "readNamespacedSecret") + .mockImplementation((): any => { + return Promise.resolve({ + body: { + data: { + "ca.crt": "TEST_VAL" + } + } + }) + }) + jest + .spyOn(mockAdmission, "readMutatingWebhookConfiguration") + .mockImplementation((): any => { + return Promise.resolve({ + body: { + webhooks: [ + { + clientConfig: { + caBundle: "TEST_VAL" + } + } + ] + } + }) + }) + jest + .spyOn(mockAdmission, "patchMutatingWebhookConfiguration") + .mockImplementation((): any => { + return Promise.resolve() + }) + const service = new Kubernetes( + logger, + "TEST_HOOK", + "TEST_SECRET", + "TEST_NAMESPACE", + mockCore, + mockAdmission + ) + const result = await service.syncCaBundle() + expect(result).toBe(true) + expect( + mockAdmission.patchMutatingWebhookConfiguration.mock.calls.length + ).toBe(0) + }) + it("should resolve false on error", async () => { + jest + .spyOn(mockCore, "readNamespacedSecret") + .mockImplementation((): any => { + return Promise.reject(new Error("ERROR!!!!")) + }) + const service = new Kubernetes( + logger, + "TEST_HOOK", + "TEST_SECRET", + "TEST_NAMESPACE", + mockCore, + mockAdmission + ) + const result = await service.syncCaBundle() + expect(result).toBe(false) + }) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index f6e47f0..a1cd9c5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,27 +5,19 @@ "removeComments": true, "resolveJsonModule": false, "declaration": true, - "lib": [ - "ES2022" - ], - "target": "ES2022", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + "lib": ["ES2022"], + "target": "ES2022" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, "types": ["reflect-metadata"], - "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, "moduleResolution": "node", - "sourceMap": true, /* Generates corresponding '.map' file. */ - "outDir": "./dist", /* Redirect output structure to the directory. */ - "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ - "strict": true, /* Enable all strict type-checking options. */ - "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ - "skipLibCheck": true, /* Skip type checking of declaration files. */ + "sourceMap": true /* Generates corresponding '.map' file. */, + "outDir": "./dist" /* Redirect output structure to the directory. */, + "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, + "strict": true /* Enable all strict type-checking options. */, + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + "skipLibCheck": true /* Skip type checking of declaration files. */, "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ }, - "include": [ - "src", - "src/config/*.json" - ], - "exclude": [ - "node_modules", - "test" - ] + "include": ["src", "src/config/*.json"], + "exclude": ["node_modules", "test"] }