diff --git a/package-lock.json b/package-lock.json index 928764573..c2afff889 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8112,6 +8112,12 @@ "integrity": "sha512-WKG4gTr8przEZBiJ5r3s8ZIAoMXNbOgQ+j/d5O4X3x6kZJRLNvyUJuUK/KoG3+8BaOHPhp2m7WC6JKKeovDSzQ==", "dev": true }, + "node_modules/@types/pako": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.1.tgz", + "integrity": "sha512-fXhui1fHdLrUR0KEyQsBzqdi3Z+MitnRcpI2eeFJyzaRdqO2miX/BDz2Hh0VdkBbrWprgcQ+ItFmbdKYdbMjvg==", + "dev": true + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -12819,8 +12825,7 @@ "node_modules/dom-walk": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", - "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==", - "dev": true + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, "node_modules/domain-browser": { "version": "1.2.0", @@ -15631,6 +15636,17 @@ "node": ">=8.0.0" } }, + "node_modules/get-random-values": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-random-values/-/get-random-values-3.0.0.tgz", + "integrity": "sha512-mNznaBdYcpz7UAdnOtDGcLdNwAa79mXl5htEyyZ51YaeAWNf2g4x/2yCVBdNNTbi35wX0Stc2PJXM7G6rcONOA==", + "dependencies": { + "global": "^4.4.0" + }, + "engines": { + "node": "18 || >=20" + } + }, "node_modules/get-stdin": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", @@ -15765,7 +15781,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", - "dev": true, "dependencies": { "min-document": "^2.19.0", "process": "^0.11.10" @@ -18809,7 +18824,6 @@ "version": "2.19.0", "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", - "dev": true, "dependencies": { "dom-walk": "^0.1.0" } @@ -21238,7 +21252,6 @@ "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, "engines": { "node": ">= 0.6.0" } @@ -29049,7 +29062,7 @@ }, "packages/components": { "name": "@monokle/components", - "version": "2.2.0", + "version": "2.3.1", "license": "MIT", "dependencies": { "react-fast-compare": "^3.2.1", @@ -29058,7 +29071,7 @@ "devDependencies": { "@ant-design/icons": "4.7.0", "@babel/core": "7.17.8", - "@monokle/validation": "0.29.1", + "@monokle/validation": "0.30.1", "@rjsf/antd": "5.0.0-beta.11", "@storybook/addon-actions": "6.5.16", "@storybook/addon-essentials": "6.5.16", @@ -29144,10 +29157,10 @@ "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" }, "packages/monaco-kubernetes": { - "version": "0.2.16", + "version": "0.2.17", "license": "MIT", "dependencies": { - "@monokle/validation": "^0.29.0", + "@monokle/validation": "^0.30.0", "@types/json-schema": "^7.0.0", "jsonc-parser": "^3.0.0", "monaco-marker-data-provider": "^1.0.0", @@ -29310,7 +29323,7 @@ }, "packages/synchronizer": { "name": "@monokle/synchronizer", - "version": "0.8.0", + "version": "0.9.1", "license": "MIT", "dependencies": { "@monokle/types": "*", @@ -29830,7 +29843,7 @@ }, "packages/validation": { "name": "@monokle/validation", - "version": "0.29.1", + "version": "0.30.1", "license": "MIT", "dependencies": { "@monokle/types": "*", @@ -29838,9 +29851,11 @@ "@rollup/plugin-virtual": "3.0.1", "ajv": "6.12.6", "change-case": "4.1.2", + "get-random-values": "^3.0.0", "isomorphic-fetch": "3.0.0", "lodash": "4.17.21", "node-fetch": "3.3.0", + "pako": "^2.1.0", "require-from-string": "2.0.2", "rollup": "3.18.0", "uuid": "9.0.0", @@ -29851,6 +29866,7 @@ "@monokle/parser": "*", "@types/isomorphic-fetch": "0.0.36", "@types/lodash": "4.14.185", + "@types/pako": "^2.0.1", "@types/require-from-string": "1.2.1", "@types/uuid": "9.0.1", "esbuild": "0.17.18", @@ -29980,6 +29996,11 @@ "url": "https://opencollective.com/node-fetch" } }, + "packages/validation/node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, "packages/validation/node_modules/rollup": { "version": "3.18.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.18.0.tgz", diff --git a/packages/validation/package.json b/packages/validation/package.json index 18bc275d4..ce30557a2 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -50,10 +50,11 @@ "@monokle/parser": "*", "@types/isomorphic-fetch": "0.0.36", "@types/lodash": "4.14.185", + "@types/pako": "^2.0.1", "@types/require-from-string": "1.2.1", "@types/uuid": "9.0.1", - "rimraf": "3.0.2", "esbuild": "0.17.18", + "rimraf": "3.0.2", "tiny-glob": "0.2.9", "type-fest": "3.0.0", "typescript": "4.8.3", @@ -65,9 +66,11 @@ "@rollup/plugin-virtual": "3.0.1", "ajv": "6.12.6", "change-case": "4.1.2", + "get-random-values": "^3.0.0", "isomorphic-fetch": "3.0.0", "lodash": "4.17.21", "node-fetch": "3.3.0", + "pako": "^2.1.0", "require-from-string": "2.0.2", "rollup": "3.18.0", "uuid": "9.0.0", diff --git a/packages/validation/src/__tests__/MonokleValidator.vap.test.ts b/packages/validation/src/__tests__/MonokleValidator.vap.test.ts new file mode 100644 index 000000000..dad5597d2 --- /dev/null +++ b/packages/validation/src/__tests__/MonokleValidator.vap.test.ts @@ -0,0 +1,56 @@ +import {expect, it} from 'vitest'; +import {MonokleValidator} from '../MonokleValidator.js'; + +import {ResourceParser} from '../common/resourceParser.js'; +import {DefaultPluginLoader} from '../pluginLoaders/PluginLoader'; +import {ValidationConfig} from '@monokle/types'; +import {DisabledFixer, SchemaLoader} from '../node.js'; +import { + NAMESPACE, + VALIDATING_ADMISSION_POLICY, + VALIDATING_ADMISSION_POLICY_BINDING, + DEPLOYMENT, +} from './admissionPolicyValidatorResources.js'; + +it('test basic admission policy', async () => { + const parser = new ResourceParser(); + + const validator = createTestValidator(parser, { + plugins: { + 'admission-policy': true, + }, + }); + + const response = await validator.validate({ + resources: [NAMESPACE, VALIDATING_ADMISSION_POLICY, VALIDATING_ADMISSION_POLICY_BINDING, DEPLOYMENT], + }); + + const hasErrors = response.runs.reduce((sum, r) => sum + r.results.length, 0); + expect(hasErrors).toBe(1); +}); + +function createTestValidator(parser: ResourceParser, config?: ValidationConfig) { + return new MonokleValidator( + { + loader: new DefaultPluginLoader(), + parser, + schemaLoader: new SchemaLoader(), + suppressors: [], + fixer: new DisabledFixer(), + }, + config ?? { + plugins: { + 'yaml-syntax': true, + 'resource-links': true, + 'kubernetes-schema': true, + 'open-policy-agent': true, + }, + settings: { + 'kubernetes-schema': { + schemaVersion: '1.24.2', + }, + debug: true, + }, + } + ); +} diff --git a/packages/validation/src/__tests__/admissionPolicyValidatorResources.ts b/packages/validation/src/__tests__/admissionPolicyValidatorResources.ts new file mode 100644 index 000000000..f741ef4e5 --- /dev/null +++ b/packages/validation/src/__tests__/admissionPolicyValidatorResources.ts @@ -0,0 +1,153 @@ +import {Resource} from '../index.js'; + +export const VALIDATING_ADMISSION_POLICY: Resource = { + fileId: '18acb3eb78d60', + filePath: 'policy.yaml', + fileOffset: 0, + text: 'apiVersion: admissionregistration.k8s.io/v1beta1\nkind: ValidatingAdmissionPolicy\nmetadata:\n name: "demo-policy.example.com"\nspec:\n failurePolicy: Fail\n matchConstraints:\n resourceRules:\n - apiGroups: [ "apps" ]\n apiVersions: [ "v1" ]\n operations: [ "CREATE", "UPDATE" ]\n resources: [ "deployments" ]\n validations:\n - expression: "object.spec.replicas > 3"\n', + apiVersion: 'admissionregistration.k8s.io/v1beta1', + kind: 'ValidatingAdmissionPolicy', + content: { + apiVersion: 'admissionregistration.k8s.io/v1beta1', + kind: 'ValidatingAdmissionPolicy', + metadata: { + name: 'demo-policy.example.com', + }, + spec: { + failurePolicy: 'Fail', + matchConstraints: { + resourceRules: [ + { + apiGroups: ['apps'], + apiVersions: ['v1'], + operations: ['CREATE', 'UPDATE'], + resources: ['deployments'], + }, + ], + }, + validations: [ + { + expression: 'object.spec.replicas > 3', + }, + ], + }, + }, + id: '18acb3eb78d60-0', + name: 'demo-policy.example.com', +}; + +export const VALIDATING_ADMISSION_POLICY_BINDING: Resource = { + fileId: '19b972898cc6be', + filePath: 'policy-binding.yaml', + fileOffset: 0, + text: 'apiVersion: admissionregistration.k8s.io/v1beta1\nkind: ValidatingAdmissionPolicyBinding\nmetadata:\n name: "demo-binding-test.example.com"\nspec:\n policyName: "demo-policy.example.com"\n validationActions: [ Deny ]\n matchResources:\n namespaceSelector:\n matchLabels:\n environment: test\n', + apiVersion: 'admissionregistration.k8s.io/v1beta1', + kind: 'ValidatingAdmissionPolicyBinding', + content: { + apiVersion: 'admissionregistration.k8s.io/v1beta1', + kind: 'ValidatingAdmissionPolicyBinding', + metadata: { + name: 'demo-binding-test.example.com', + }, + spec: { + policyName: 'demo-policy.example.com', + validationActions: ['Deny'], + matchResources: { + namespaceSelector: { + matchLabels: { + environment: 'test', + }, + }, + }, + }, + }, + id: '19b972898cc6be-0', + name: 'demo-binding-test.example.com', +}; + +export const NAMESPACE: Resource = { + fileId: '1926d9cf253e4c', + filePath: 'namespace.yaml', + fileOffset: 0, + text: 'apiVersion: v1\nkind: Namespace\nmetadata:\n name: demo\n labels:\n environment: test\n', + apiVersion: 'v1', + kind: 'Namespace', + content: { + apiVersion: 'v1', + kind: 'Namespace', + metadata: { + name: 'demo', + labels: { + environment: 'test', + }, + }, + }, + id: '1926d9cf253e4c-0', + name: 'demo', +}; + +export const DEPLOYMENT: Resource = { + fileId: '2f92c46b9eb02', + filePath: 'deployment.yaml', + fileOffset: 0, + text: 'apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: nginx-deployment\n namespace: demo\n labels:\n app: nginx\nspec:\n replicas: 5\n selector:\n matchLabels:\n app: nginx\n template:\n metadata:\n labels:\n app: nginx\n spec:\n containers:\n - name: nginx\n image: nginx:1.14.2\n ports:\n - containerPort: 80\n securityContext:\n runAsNonRoot: true\n capabilities:\n drop:\n - ALL\n runAsUser: 10001\n runAsGroup: 10001\n readOnlyRootFilesystem: true\n automountServiceAccountToken: false\n securityContext:\n seccompProfile:\n type: RuntimeDefault\n', + apiVersion: 'apps/v1', + kind: 'Deployment', + content: { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: 'nginx-deployment', + namespace: 'demo', + labels: { + app: 'nginx', + }, + }, + spec: { + replicas: 2, + selector: { + matchLabels: { + app: 'nginx', + }, + }, + template: { + metadata: { + labels: { + app: 'nginx', + }, + }, + spec: { + containers: [ + { + name: 'nginx', + image: 'nginx:1.14.2', + ports: [ + { + containerPort: 80, + }, + ], + securityContext: { + runAsNonRoot: true, + capabilities: { + drop: ['ALL'], + }, + runAsUser: 10001, + runAsGroup: 10001, + readOnlyRootFilesystem: true, + }, + }, + ], + automountServiceAccountToken: false, + securityContext: { + seccompProfile: { + type: 'RuntimeDefault', + }, + }, + }, + }, + }, + }, + id: '2f92c46b9eb02-0', + name: 'nginx-deployment', + namespace: 'demo', +}; diff --git a/packages/validation/src/assets/wasm_exec.ts b/packages/validation/src/assets/wasm_exec.ts new file mode 100644 index 000000000..76b9e554b --- /dev/null +++ b/packages/validation/src/assets/wasm_exec.ts @@ -0,0 +1,649 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +'use strict'; +import getRandomValues from 'get-random-values'; +import fs from 'fs'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder('utf-8'); + +class Go { + importObject: Record; + argv: string[]; + exit: (code: any) => void; + private _exitPromise: Promise; + private _resolveExitPromise: any; + private _pendingEvent: any; + private _scheduledTimeouts: Map; + private _nextCallbackTimeoutID: number; + mem: any; + private _values: any; + private _ids: any; + private _idPool: any; + private _goRefCounts: any; + private _inst: any; + exited: boolean | undefined; + env: any; + + constructor() { + this.argv = ['js']; + this.env = {}; + this.exit = code => { + if (code !== 0) { + console.warn('exit code:', code); + } + }; + this._exitPromise = new Promise(resolve => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr: any, v: any) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + }; + + const setInt32 = (addr: any, v: any) => { + this.mem.setUint32(addr + 0, v, true); + }; + + const getInt64 = (addr: any) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + }; + + const loadValue = (addr: any) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + }; + + const storeValue = (addr: any, v: any) => { + const nanHead = 0x7ff80000; + + if (typeof v === 'number' && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case 'object': + if (v !== null) { + typeFlag = 1; + } + break; + case 'string': + typeFlag = 2; + break; + case 'symbol': + typeFlag = 3; + break; + case 'function': + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + }; + + const loadSlice = (addr: any) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + }; + + const loadSliceOfValues = (addr: any) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + }; + + const loadString = (addr: any) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + }; + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a: any, b: any) => a + b, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + 'runtime.wasmExit': (sp: any) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + 'runtime.wasmWrite': (sp: any) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + 'runtime.resetMemoryDataView': (sp: any) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + 'runtime.nanotime1': (sp: any) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + 'runtime.walltime': (sp: any) => { + sp >>>= 0; + const msec = new Date().getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + 'runtime.scheduleTimeoutEvent': (sp: any) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set( + id, + setTimeout(() => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn('scheduleTimeoutEvent: missed timeout event'); + this._resume(); + } + }, getInt64(sp + 8)) + ); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + 'runtime.clearTimeoutEvent': (sp: any) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + 'runtime.getRandomData': (sp: any) => { + sp >>>= 0; + getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + 'syscall/js.finalizeRef': (sp: any) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + 'syscall/js.stringVal': (sp: any) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + 'syscall/js.valueGet': (sp: any) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + 'syscall/js.valueSet': (sp: any) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + 'syscall/js.valueDelete': (sp: any) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + 'syscall/js.valueIndex': (sp: any) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + 'syscall/js.valueSetIndex': (sp: any) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + 'syscall/js.valueCall': (sp: any) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + 'syscall/js.valueInvoke': (sp: any) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + 'syscall/js.valueNew': (sp: any) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + 'syscall/js.valueLength': (sp: any) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + 'syscall/js.valuePrepareString': (sp: any) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + 'syscall/js.valueLoadString': (sp: any) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + 'syscall/js.valueInstanceOf': (sp: any) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, loadValue(sp + 8) instanceof loadValue(sp + 16) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + 'syscall/js.copyBytesToGo': (sp: any) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + 'syscall/js.copyBytesToJS': (sp: any) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + debug: (value: any) => { + console.log(value); + }, + }, + }; + } + + async run(instance: any) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error('Go.run: WebAssembly.Instance expected'); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ + // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ + // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str: any) => { + const ptr = offset; + const bytes = encoder.encode(str + '\0'); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach(arg => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach(key => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach(ptr => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error('total length of command line and environment variables exceeds limit'); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error('Go program has already exited'); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id: any): any { + const go = this; + return (...arg: any): any => { + const event = { + id: id, + this: this, + args: arg, + result: null, + }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } +} + +export const loadGoGlueCode = () => { + const enosys = () => { + const err = new Error('not implemented'); + (err as any).code = 'ENOSYS'; + return err; + }; + + if (!(globalThis as any).fs) { + let outputBuf = ''; + (globalThis as any).fs = { + constants: {O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1}, // unused + writeSync(fd: any, buf: any) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf('\n'); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd: any, buf: any, offset: any, length: any, position: any, callback: any) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path: any, mode: any, callback: any) { + callback(enosys()); + }, + chown(path: any, uid: any, gid: any, callback: any) { + callback(enosys()); + }, + close(fd: any, callback: any) { + callback(enosys()); + }, + fchmod(fd: any, mode: any, callback: any) { + callback(enosys()); + }, + fchown(fd: any, uid: any, gid: any, callback: any) { + callback(enosys()); + }, + fstat(fd: any, callback: any) { + callback(enosys()); + }, + fsync(fd: any, callback: any) { + callback(null); + }, + ftruncate(fd: any, length: any, callback: any) { + callback(enosys()); + }, + lchown(path: any, uid: any, gid: any, callback: any) { + callback(enosys()); + }, + link(path: any, link: any, callback: any) { + callback(enosys()); + }, + lstat(path: any, callback: any) { + callback(enosys()); + }, + mkdir(path: any, perm: any, callback: any) { + callback(enosys()); + }, + open(path: any, flags: any, mode: any, callback: any) { + callback(enosys()); + }, + read(fd: any, buffer: any, offset: any, length: any, position: any, callback: any) { + callback(enosys()); + }, + readdir(path: any, callback: any) { + callback(enosys()); + }, + readlink(path: any, callback: any) { + callback(enosys()); + }, + rename(from: any, to: any, callback: any) { + callback(enosys()); + }, + rmdir(path: any, callback: any) { + callback(enosys()); + }, + stat(path: any, callback: any) { + callback(enosys()); + }, + symlink(path: any, link: any, callback: any) { + callback(enosys()); + }, + truncate(path: any, length: any, callback: any) { + callback(enosys()); + }, + unlink(path: any, callback: any) { + callback(enosys()); + }, + utimes(path: any, atime: any, mtime: any, callback: any) { + callback(enosys()); + }, + }; + } + + if (!globalThis.process) { + (globalThis as any).process = { + getuid() { + return -1; + }, + getgid() { + return -1; + }, + geteuid() { + return -1; + }, + getegid() { + return -1; + }, + getgroups() { + throw enosys(); + }, + pid: -1, + ppid: -1, + umask() { + throw enosys(); + }, + cwd() { + throw enosys(); + }, + chdir() { + throw enosys(); + }, + }; + } + + if (!globalThis.performance) { + throw new Error('globalThis.performance is not available, polyfill required (performance.now only)'); + } + + if (!globalThis.TextEncoder) { + throw new Error('globalThis.TextEncoder is not available, polyfill required'); + } + + if (!globalThis.TextDecoder) { + throw new Error('globalThis.TextDecoder is not available, polyfill required'); + } + + (globalThis as any).Go = Go; +}; + +export default Go; diff --git a/packages/validation/src/constants.ts b/packages/validation/src/constants.ts index af29d78a2..6ca1e1150 100644 --- a/packages/validation/src/constants.ts +++ b/packages/validation/src/constants.ts @@ -26,6 +26,7 @@ export const CORE_PLUGINS = [ 'resource-links', 'open-policy-agent', 'metadata', + 'admission-policy', ] as const; export const CUSTOM_PLUGINS_URL_BASE = 'https://plugins.monokle.com/validation'; diff --git a/packages/validation/src/pluginLoaders/PluginLoader.ts b/packages/validation/src/pluginLoaders/PluginLoader.ts index 97f24289b..6215ba93b 100644 --- a/packages/validation/src/pluginLoaders/PluginLoader.ts +++ b/packages/validation/src/pluginLoaders/PluginLoader.ts @@ -16,6 +16,7 @@ import { } from '../validators/index.js'; import {PluginContext} from './types.js'; import {RemoteWasmLoader} from '../validators/open-policy-agent/wasmLoader/RemoteWasmLoader.browser.js'; +import {AdmissionPolicyValidator} from '../validators/admission-policy/validator.js'; export interface PluginLoader { load(plugin: string, ctx: PluginContext, settings?: Record): Plugin | Promise; @@ -60,6 +61,10 @@ export class DefaultPluginLoader implements PluginLoader { return new OpenPolicyAgentValidator(parser, wasmLoader); }); + this.register('admission-policy', ({parser}) => { + return new AdmissionPolicyValidator(parser); + }); + this.register(DEV_MODE_TOKEN, ({parser, fixer}) => { return new DevCustomValidator(parser, fixer); }); diff --git a/packages/validation/src/validators/admission-policy/index.ts b/packages/validation/src/validators/admission-policy/index.ts new file mode 100644 index 000000000..631c46887 --- /dev/null +++ b/packages/validation/src/validators/admission-policy/index.ts @@ -0,0 +1 @@ +export * from './validator.js'; diff --git a/packages/validation/src/validators/admission-policy/loadWasm.ts b/packages/validation/src/validators/admission-policy/loadWasm.ts new file mode 100644 index 000000000..3c8417648 --- /dev/null +++ b/packages/validation/src/validators/admission-policy/loadWasm.ts @@ -0,0 +1,20 @@ +import Go, {loadGoGlueCode} from '../../assets/wasm_exec.js'; +import pako from 'pako'; + +export async function loadWasm() { + try { + loadGoGlueCode(); + const go = new Go(); + + let buffer = pako.ungzip(await (await fetch('http://plugins.monokle.com/validation/cel.wasm.gz')).arrayBuffer()); + + if (buffer[0] === 0x1f && buffer[1] === 0x8b) { + buffer = pako.ungzip(buffer); + } + + const result = await WebAssembly.instantiate(buffer.buffer, go.importObject); + go.run(result.instance); + } catch (err: any) { + console.log(err); + } +} diff --git a/packages/validation/src/validators/admission-policy/rules.ts b/packages/validation/src/validators/admission-policy/rules.ts new file mode 100644 index 000000000..90654d8c8 --- /dev/null +++ b/packages/validation/src/validators/admission-policy/rules.ts @@ -0,0 +1,12 @@ +import {RuleMetadata} from '../../common/sarif.js'; + +export const ADMISSION_POLICY_RULES: RuleMetadata[] = [ + { + id: 'VAP001', + name: 'admission-policy-violated', + shortDescription: {text: 'Admission policy conditions violated'}, + help: { + text: 'Check whether the admission policy conditions are met.', + }, + }, +]; diff --git a/packages/validation/src/validators/admission-policy/types.ts b/packages/validation/src/validators/admission-policy/types.ts new file mode 100644 index 000000000..1084f7082 --- /dev/null +++ b/packages/validation/src/validators/admission-policy/types.ts @@ -0,0 +1,8 @@ +import {Resource} from '../../common/types.js'; + +export type Expression = { + expression: string; + message: string; +}; + +export type PolicyExpressionsAndFilteredResources = Record; diff --git a/packages/validation/src/validators/admission-policy/validator.ts b/packages/validation/src/validators/admission-policy/validator.ts new file mode 100644 index 000000000..d84b5f46d --- /dev/null +++ b/packages/validation/src/validators/admission-policy/validator.ts @@ -0,0 +1,200 @@ +import {AbstractPlugin} from '../../common/AbstractPlugin.js'; +import {Resource, ValidateOptions} from '../../common/types.js'; +import {ResourceParser} from '../../common/resourceParser.js'; +import {ValidationResult} from '../../common/sarif.js'; +import {loadWasm} from './loadWasm.js'; +import {Expression, PolicyExpressionsAndFilteredResources} from './types.js'; +import * as YAML from 'yaml'; +import {isKustomizationPatch, isKustomizationResource} from '../../references/utils/kustomizeRefs.js'; +import {ADMISSION_POLICY_RULES} from './rules.js'; +import {findJsonPointerNode} from '../../utils/findJsonPointerNode.js'; +import {createLocations} from '../../utils/createLocations.js'; +import {isDefined} from '../../utils/isDefined.js'; + +const KNOWN_RESOURCE_KINDS_PLURAL: Record = { + Deployment: 'deployments', +}; + +export class AdmissionPolicyValidator extends AbstractPlugin { + private loadedWasm: boolean | undefined; + + constructor(private resourceParser: ResourceParser) { + super( + { + id: 'AP', + name: 'admission-policy', + displayName: 'Admission Policy', + description: + 'Ensure that your manifests comply with the conditions specified in the Validating Admission Policies.', + }, + ADMISSION_POLICY_RULES + ); + } + + override async configurePlugin(): Promise { + if (this.loadedWasm === true) return; + await loadWasm(); + this.loadedWasm = true; + } + + async doValidate(resources: Resource[], options: ValidateOptions): Promise { + const results: ValidationResult[] = []; + + const resourcesToBeValidated = this.getResourcesToBeValidated(resources); + + for (const [policyName, {resources: filteredResources, expressions}] of Object.entries(resourcesToBeValidated)) { + for (const resource of filteredResources) { + for (const expression of expressions) { + const errors = await this.validateResource(resource, expression); + + results.push(...errors); + } + } + } + + return results; + } + + private async validateResource(resource: Resource, {message, expression}: Expression): Promise { + const output = (globalThis as any).eval(expression, YAML.stringify({object: resource.content})).output; + + if (output === 'true' || output.includes('ERROR:')) { + return []; + } + + return [ + this.adaptToValidationResult(resource, ['kind'], 'VAP001', message ?? 'Admission policy conditions violated'), + ].filter(isDefined); + } + + private getPolicies(resources: Resource[]): Resource[] { + return resources.filter(r => r.kind === 'ValidatingAdmissionPolicy'); + } + + private getPoliciesBindings(resources: Resource[]): Resource[] { + return resources.filter(r => r.kind === 'ValidatingAdmissionPolicyBinding'); + } + + private getResourcesToBeValidated(resources: Resource[]): PolicyExpressionsAndFilteredResources { + let filteredResources = resources.filter(r => !isKustomizationPatch(r) && !isKustomizationResource(r)); + + const policyResources = this.getPolicies(resources); + + return this.policyFilter(policyResources, this.policyBindingFilter(filteredResources)); + } + + private policyBindingFilter(resources: Resource[]): Record { + const policyFilteredResources: Record = {}; + + const policiesBindings = this.getPoliciesBindings(resources); + + for (const policyBinding of policiesBindings) { + const policyName = policyBinding.content?.spec?.policyName; + + if (!policyName) continue; + + const namespaceMatchLabels: Record = + policyBinding.content?.spec?.matchResources?.namespaceSelector?.matchLabels; + + if (!namespaceMatchLabels) continue; + + const namespacesResources = resources.filter(r => r.kind === 'Namespace'); + + const filteredNamespaces = namespacesResources.filter(n => { + for (const key of Object.keys(namespaceMatchLabels)) { + if (n.content?.metadata?.labels?.[key] !== namespaceMatchLabels[key]) { + return false; + } + } + + return true; + }); + + const filteredResources = resources.filter(r => filteredNamespaces.find(n => n.name === r.namespace)); + + if (!filteredResources.length) continue; + + policyFilteredResources[policyName] = filteredResources; + } + + return policyFilteredResources; + } + + private policyFilter( + policies: Resource[], + policyFilteredResources: Record + ): PolicyExpressionsAndFilteredResources { + const filteredResourcesWithExpressions: PolicyExpressionsAndFilteredResources = {}; + + for (const [policyName, resources] of Object.entries(policyFilteredResources)) { + const policy = policies.find(p => p.name === policyName); + + if (!policy) continue; + + const validations = policy.content?.spec?.validations; + + if (!validations?.length) continue; + + const policyResourceRules = policy.content?.spec?.matchConstraints?.resourceRules; + + if (!policyResourceRules?.length) continue; + + const filteredResourcesByPolicy = resources.filter(r => { + for (const resourceRule of policyResourceRules) { + const {apiGroups, apiVersions, resources: policyResources} = resourceRule; + + const [resourceApiGroup, resourceApiVersion] = r.apiVersion.split('/'); + + if (apiGroups?.length) { + if (!apiGroups.includes(resourceApiGroup)) { + return false; + } + } + + if (apiVersions?.length) { + if (!apiVersions.includes(resourceApiVersion)) { + return false; + } + } + + if (policyResources?.length) { + if (!policyResources.includes(KNOWN_RESOURCE_KINDS_PLURAL[r.kind])) { + return false; + } + } + } + + return true; + }); + + if (!filteredResourcesByPolicy.length) continue; + + filteredResourcesWithExpressions[policy.name] = { + resources: filteredResourcesByPolicy, + expressions: validations.map((v: any) => ({ + expression: v.expression, + message: v.message, + })), + }; + } + + return filteredResourcesWithExpressions; + } + + private adaptToValidationResult(resource: Resource, path: string[], ruleId: string, errText: string) { + const {parsedDoc} = this.resourceParser.parse(resource); + + const valueNode = findJsonPointerNode(parsedDoc, path); + + const region = this.resourceParser.parseErrorRegion(resource, valueNode.range); + + const locations = createLocations(resource, region); + + return this.createValidationResult(ruleId, { + message: { + text: errText, + }, + locations, + }); + } +}