From d005322f3e273fe3625396af5bca72e2509ce70a Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 2 Nov 2026 11:39:48 +0100 Subject: [PATCH 1/2] fix(security): stage the initial runtime detection baseline Install and bind only the vendor port-forward rule by stable ID, preserving the other controller output and both switch states. Generate the one missing Rules schema property from the hash-verified pinned chart without changing existing catalog constraints. Part of #4968. --- .github/workflows/ci.yaml | 6 +- docs/runtime-security.md | 28 ++-- .../install-default-runtime-rules.yaml | 26 ++- .../patches/runtime-rule-baseline.yaml | 42 +++++ .../controllers/kustomization.yaml | 10 +- ksail.prod.yaml | 2 + ksail.yaml | 2 + schemas/README.md | 18 +++ schemas/kubescape.io/rules_v1.json | 153 ++++++++++++++++++ scripts/generate-kubescape-rule-schema.sh | 53 ++++++ .../kubescape-rule-schema/schema_test.go | 71 ++++++++ .../test-kubescape-runtime-rules-switch.sh | 81 +++++++--- 12 files changed, 438 insertions(+), 54 deletions(-) create mode 100644 k8s/providers/hetzner/infrastructure/controllers/kubescape/patches/runtime-rule-baseline.yaml create mode 100644 schemas/README.md create mode 100644 schemas/kubescape.io/rules_v1.json create mode 100644 scripts/generate-kubescape-rule-schema.sh create mode 100644 scripts/tests/kubescape-rule-schema/schema_test.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5f81574d4b..1aa91de501 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -665,6 +665,9 @@ jobs: - 'scripts/tests/test-guard-helm-post-renderers.sh' - 'scripts/tests/test-kubescape-storage-placement.sh' - 'scripts/tests/test-kubescape-runtime-rules-switch.sh' + - 'scripts/generate-kubescape-rule-schema.sh' + - 'scripts/tests/kubescape-rule-schema/**' + - 'schemas/**' - 'scripts/build-controller-helm.sh' - 'scripts/prepare-helm-render-input-capture.sh' - 'scripts/render-helm-chart/**' @@ -4127,7 +4130,7 @@ jobs: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} run: | - shellcheck scripts/tests/test-kubescape-runtime-rules-switch.sh + shellcheck scripts/tests/test-kubescape-runtime-rules-switch.sh scripts/generate-kubescape-rule-schema.sh changed_inputs="$(git diff --name-only "$BASE_SHA" HEAD -- \ .github/workflows/ci.yaml \ k8s/bases/infrastructure/controllers/kubescape \ @@ -4136,6 +4139,7 @@ jobs: k8s/providers/hetzner/infrastructure/controllers/kubescape \ k8s/bases/components ksail.prod.yaml \ scripts/tests/test-kubescape-runtime-rules-switch.sh \ + scripts/generate-kubescape-rule-schema.sh schemas \ scripts/build-controller-helm.sh \ scripts/render-helm-chart/go.mod scripts/render-helm-chart/go.sum)" if [[ -n "$changed_inputs" ]]; then diff --git a/docs/runtime-security.md b/docs/runtime-security.md index d743d3d215..ccd63da54f 100644 --- a/docs/runtime-security.md +++ b/docs/runtime-security.md @@ -71,18 +71,28 @@ alone. What it does **not** do: it is **detection-only**. It will tell you a process misbehaved; it will not stop it. -**Detection needs rules, and production does not load them yet.** The node-agent +**Detection needs definitions and an enabled binding.** The node-agent checks only the rules it finds in `Rules` and `RuntimeRuleAlertBinding` objects. The chart installs its default rule set and a binding over every scanned namespace -only when `alertCRD.installDefault` is true. Production declares that switch, -**off**, in +only when `alertCRD.installDefault` is true. Production declares that switch on in [`install-default-runtime-rules.yaml`](../k8s/providers/hetzner/infrastructure/controllers/kubescape/patches/install-default-runtime-rules.yaml), -so the agent keeps building profiles but raises no alerts: an empty alert route -means nothing was checked. Turning the switch on adds exactly those two objects -and sends alerts through the existing Alertmanager (Slack) and stdout -(Coroot) routes. `scripts/tests/test-kubescape-runtime-rules-switch.sh` renders -the pinned chart in both states and refuses an undeclared switch, an on state -that binds no enabled rule, and an on state that changes anything else (#4968). +and appends a post-renderer that retains only the vendor's **R2001** definition, +pod port-forward detection, and binds its stable ID. The pinned rule does not require a learned profile, so incomplete +profiles do not prevent this initial baseline. The scanned namespace scope and the +existing Alertmanager and stdout (Coroot) routes are unchanged. Installing the +definitions and binding changes no RBAC, workload or CRD. + +This baseline is a first stage, not complete runtime coverage. Anomaly rules and +other currently unbound rules need separate execution and alert-volume evidence +before expanding the binding (#4968, #4978). A successful render is not evidence +that an alert reached its destination: rollout acceptance needs non-zero live rule +and binding readback, a controlled port-forward event, and observed delivery. + +`scripts/tests/test-kubescape-runtime-rules-switch.sh` renders the pinned chart +in both switch states, as installs and upgrades. It rejects an enabled detector +with no installed baseline, unknown or additional bound rules, a profile-dependent +or changed port-forward definition, changed namespace scope, and changes to other +objects. Renaming a rule while retaining its ID must preserve the binding. ### Tetragon — *enforcement and precise kernel observability* diff --git a/k8s/providers/hetzner/infrastructure/controllers/kubescape/patches/install-default-runtime-rules.yaml b/k8s/providers/hetzner/infrastructure/controllers/kubescape/patches/install-default-runtime-rules.yaml index 3a5c6466e9..3c3022684c 100644 --- a/k8s/providers/hetzner/infrastructure/controllers/kubescape/patches/install-default-runtime-rules.yaml +++ b/k8s/providers/hetzner/infrastructure/controllers/kubescape/patches/install-default-runtime-rules.yaml @@ -8,23 +8,17 @@ spec: interval: 10m values: alertCRD: - # Release switch for the node-agent's default detection rules (#4968). It is - # OFF: production runs runtime detection with NO rules, so the agent profiles - # workloads but checks nothing, and an empty alert route means nothing was - # checked, not that nothing happened. + # Install the chart's rule definitions for the initial runtime-detection + # baseline (#4968). The appended runtime-rule-baseline post-renderer retains + # and binds only R2001, the profile-independent pod port-forward rule. It does not + # activate the chart's anomaly bindings while profiles remain incomplete. # - # The chart installs its `default-rules` Rules object and the cluster-wide - # `all-rules-all-pods` RuntimeRuleAlertBinding only when this is true, and - # defaults it to false. Turning it on adds exactly those two objects and - # nothing else (no RBAC, workload or CRD change). The binding covers every - # pod outside `excludeNamespaces`, and alerts then flow to the existing - # Alertmanager (Slack) and stdout (Coroot) routes. - # - # It stays off until the rollout is decided: the anomaly rules depend on - # complete application profiles, several workloads have only partial ones, - # and the alert volume on the Slack route has never been measured. + # The switch adds only the Rules and binding objects: RBAC, workloads, + # CRDs, scanned namespaces and existing alert routes stay unchanged. + # Binding by ID also avoids the chart's mismatched "Port forward" name + # (#4978). Broader rule coverage still needs its own measured rollout. # # Keep the value explicit. scripts/tests/test-kubescape-runtime-rules-switch.sh # fails when it is absent, renders the pinned chart in both states, and - # requires the on state to bind at least one enabled rule. - installDefault: false + # requires the on state to bind exactly the supported R2001 baseline. + installDefault: true diff --git a/k8s/providers/hetzner/infrastructure/controllers/kubescape/patches/runtime-rule-baseline.yaml b/k8s/providers/hetzner/infrastructure/controllers/kubescape/patches/runtime-rule-baseline.yaml new file mode 100644 index 0000000000..c8e72f9e26 --- /dev/null +++ b/k8s/providers/hetzner/infrastructure/controllers/kubescape/patches/runtime-rule-baseline.yaml @@ -0,0 +1,42 @@ +# Append rather than replace the release's existing hardening post-renderers. +- op: add + path: /spec/postRenderers/- + value: + kustomize: + patches: + - target: + group: kubescape.io + version: v1 + kind: Rules + name: default-rules + # Retain the vendor's definition without copying its implementation. + # The index is pinned to chart 1.40.5; the test fails closed on a move. + patch: |- + - op: test + path: /spec/rules/27/id + value: R2001 + - op: copy + from: /spec/rules/27 + path: /spec/portForwardBaseline + - op: replace + path: /spec/rules + value: [] + - op: move + from: /spec/portForwardBaseline + path: /spec/rules/- + - target: + group: kubescape.io + version: v1 + kind: RuntimeRuleAlertBinding + name: all-rules-all-pods + patch: |- + - op: test + path: /metadata/name + value: all-rules-all-pods + - op: add + path: /metadata/labels/devantler.tech~1runtime-rule-stage + value: port-forward-baseline + - op: replace + path: /spec/rules + value: + - ruleID: R2001 diff --git a/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml b/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml index c2dd500afc..4e4a4e13a4 100644 --- a/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml +++ b/k8s/providers/hetzner/infrastructure/controllers/kustomization.yaml @@ -160,9 +160,15 @@ patches: # in-cluster Alertmanager (alertmanager/ above) + keep the stdout exporter on, # fanning them out to Slack and Coroot — see file. - path: kubescape/patches/route-runtime-detection-alerts.yaml - # Prod-only release switch for the node-agent's default detection rules - # (#4968). OFF: runtime detection runs with no rules until it is turned on. + # Prod-only initial detection baseline (#4968): install the definitions, then + # bind only the profile-independent port-forward rule by its stable ID. - path: kubescape/patches/install-default-runtime-rules.yaml + - path: kubescape/patches/runtime-rule-baseline.yaml + target: + group: helm.toolkit.fluxcd.io + version: v2 + kind: HelmRelease + name: kubescape # NB: the Coroot CR moved to the `infrastructure` layer (so the operator's # CRD is installed first), so its hetzner patch now lives in # ../coroot/patches/ and is applied by ../kustomization.yaml, not here. diff --git a/ksail.prod.yaml b/ksail.prod.yaml index 8e0c746358..07369b67a4 100644 --- a/ksail.prod.yaml +++ b/ksail.prod.yaml @@ -378,4 +378,6 @@ spec: # compile, which it reports as a missing schema. So stay on the revision # just before it, in both configs. Remove once the catalogue compiles again. schemaLocations: + # Generated one-field correction from the pinned Kubescape Rules CRD. + - schemas/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json - https://raw.githubusercontent.com/datreeio/CRDs-catalog/6ec4c804fcb9b0a90fec9dd468aa57b984be150a/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json diff --git a/ksail.yaml b/ksail.yaml index 6b594655c5..440295256e 100644 --- a/ksail.yaml +++ b/ksail.yaml @@ -68,4 +68,6 @@ spec: # compile, which it reports as a missing schema. So stay on the revision # just before it, in both configs. Remove once the catalogue compiles again. schemaLocations: + # Generated one-field correction from the pinned Kubescape Rules CRD. + - schemas/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json - https://raw.githubusercontent.com/datreeio/CRDs-catalog/6ec4c804fcb9b0a90fec9dd468aa57b984be150a/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json diff --git a/schemas/README.md b/schemas/README.md new file mode 100644 index 0000000000..6a01cff9b0 --- /dev/null +++ b/schemas/README.md @@ -0,0 +1,18 @@ +# Validation schema corrections + +`kubescape.io/rules_v1.json` is generated by +`bash scripts/generate-kubescape-rule-schema.sh --write`. Do not edit it directly. + +The pinned CRDs-catalog revision `6ec4c804fcb9b0a90fec9dd468aa57b984be150a` +omits `profileDataRequired`, which the pinned Kubescape operator chart 1.40.5 +ships in its Rules CRD and uses in its default rule definitions. The generator +verifies both input hashes, copies that one property from the shipped CRD, and +proves every existing catalog constraint is unchanged. Both KSail configurations +consult this local correction before their unchanged catalog fallback. No kind +is skipped and other CRD schemas are unchanged. + +Run `bash scripts/generate-kubescape-rule-schema.sh --check` to verify provenance, +`go test ./scripts/tests/kubescape-rule-schema` for valid and invalid resource +controls, and both normal KSail validation commands for the actual manifests. +The runtime-rule chart regression also checks that this file is reproducible. +A chart-pin change requires auditing and regenerating the correction. diff --git a/schemas/kubescape.io/rules_v1.json b/schemas/kubescape.io/rules_v1.json new file mode 100644 index 0000000000..b844113d25 --- /dev/null +++ b/schemas/kubescape.io/rules_v1.json @@ -0,0 +1,153 @@ +{ + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "spec": { + "additionalProperties": false, + "properties": { + "rules": { + "items": { + "additionalProperties": false, + "properties": { + "agentVersionRequirement": { + "description": "Agent version requirement to evaluate this rule (supports semver ranges like ~1.0, >=1.2.0, etc.)", + "type": "string" + }, + "description": { + "description": "Description of the rule", + "type": "string" + }, + "enabled": { + "description": "Whether the rule is enabled", + "type": "boolean" + }, + "expressions": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Message expression", + "type": "string" + }, + "ruleExpression": { + "items": { + "additionalProperties": false, + "properties": { + "eventType": { + "description": "Type of event this expression handles", + "type": "string" + }, + "expression": { + "description": "The rule expression string", + "type": "string" + } + }, + "required": [ + "eventType", + "expression" + ], + "type": "object" + }, + "type": "array" + }, + "uniqueId": { + "description": "Unique identifier expression", + "type": "string" + } + }, + "required": [ + "message", + "uniqueId", + "ruleExpression" + ], + "type": "object" + }, + "id": { + "description": "Unique identifier for the rule", + "type": "string" + }, + "isTriggerAlert": { + "default": true, + "description": "Whether the rule is a trigger alert", + "type": "boolean" + }, + "mitreTactic": { + "description": "MITRE tactic associated with the rule", + "type": "string" + }, + "mitreTechnique": { + "description": "MITRE technique associated with the rule", + "type": "string" + }, + "name": { + "description": "Name of the rule", + "type": "string" + }, + "profileDataRequired": { + "description": "Per-rule profile fields required for rule-aware projection.", + "type": "object", + "x-kubernetes-preserve-unknown-fields": true + }, + "profileDependency": { + "description": "Profile dependency level (0=Required, 1=Optional, 2=NotRequired)", + "enum": [ + 0, + 1, + 2 + ], + "type": "integer" + }, + "severity": { + "description": "Severity level of the rule", + "type": "integer" + }, + "state": { + "additionalProperties": true, + "description": "State information for the rule", + "type": "object" + }, + "supportPolicy": { + "default": false, + "description": "Whether the rule supports rule policy enforcement", + "type": "boolean" + }, + "tags": { + "description": "Tags associated with the rule", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "enabled", + "id", + "name", + "description", + "expressions", + "profileDependency", + "severity", + "supportPolicy", + "isTriggerAlert", + "mitreTechnique", + "mitreTactic" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "rules" + ], + "type": "object" + } + }, + "type": "object" +} diff --git a/scripts/generate-kubescape-rule-schema.sh b/scripts/generate-kubescape-rule-schema.sh new file mode 100644 index 0000000000..4b46d5960d --- /dev/null +++ b/scripts/generate-kubescape-rule-schema.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Reproduce the narrow Rules schema correction from two immutable upstream inputs. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +mode="${1:---check}" +[[ "$#" -le 1 && ( "$mode" == --check || "$mode" == --write ) ]] || { + printf 'usage: %s [--check|--write]\n' "$0" >&2 + exit 2 +} +scratch="$(mktemp -d "${TMPDIR:-/tmp}/kubescape-rule-schema.XXXXXX")" +trap 'rm -rf -- "$scratch"' EXIT + +catalog_revision=6ec4c804fcb9b0a90fec9dd468aa57b984be150a +catalog_sha256=cb9df542316730cf11399ec9fdbc91993bc07215343a0511926447637b6bfcb6 +chart_version=1.40.5 +chart_sha256=80c6d177b534936f66df7d1a10544bd5e13d48b581a562c480bd16314221aa92 +[[ "$(yq -er '.spec.chart.spec.version' "$root/k8s/bases/infrastructure/controllers/kubescape/helm-release.yaml")" == "$chart_version" ]] || { + printf 'FAIL: audit and regenerate the Rules schema for the changed chart pin\n' >&2 + exit 1 +} +curl --fail --silent --show-error --location --retry 3 \ + "https://raw.githubusercontent.com/datreeio/CRDs-catalog/$catalog_revision/kubescape.io/rules_v1.json" \ + --output "$scratch/catalog.json" +curl --fail --silent --show-error --location --retry 3 \ + "https://github.com/kubescape/helm-charts/releases/download/kubescape-operator-$chart_version/kubescape-operator-$chart_version.tgz" \ + --output "$scratch/chart.tgz" +printf '%s %s\n' "$catalog_sha256" "$scratch/catalog.json" "$chart_sha256" "$scratch/chart.tgz" | + shasum -a 256 --check --status +tar -xOf "$scratch/chart.tgz" kubescape-operator/crds/rules.crd.yaml | + yq -o=json '.spec.versions[] | select(.name == "v1") | + .schema.openAPIV3Schema.properties.spec.properties.rules.items.properties.profileDataRequired' - \ + >"$scratch/property.json" +jq -e '.type == "object" and .["x-kubernetes-preserve-unknown-fields"] == true' "$scratch/property.json" >/dev/null +jq -e '.properties.spec.properties.rules.items.properties | has("profileDataRequired") | not' "$scratch/catalog.json" >/dev/null +jq -S --slurpfile property "$scratch/property.json" \ + '.properties.spec.properties.rules.items.properties.profileDataRequired = $property[0]' \ + "$scratch/catalog.json" >"$scratch/generated.json" +# All pre-existing constraints must survive byte-for-byte after canonicalization. +jq -S 'del(.properties.spec.properties.rules.items.properties.profileDataRequired)' "$scratch/generated.json" >"$scratch/rest.json" +jq -S . "$scratch/catalog.json" >"$scratch/original.json" +cmp "$scratch/original.json" "$scratch/rest.json" +destination="$root/schemas/kubescape.io/rules_v1.json" +if [[ "$mode" == --write ]]; then + mkdir -p "$(dirname "$destination")" + cp "$scratch/generated.json" "$destination" +else + cmp "$scratch/generated.json" "$destination" || { + printf 'FAIL: regenerate the Rules schema with %s --write\n' "$0" >&2 + exit 1 + } +fi +printf 'PASS: Rules schema preserves the pinned catalog and adds only the chart-owned projection field\n' diff --git a/scripts/tests/kubescape-rule-schema/schema_test.go b/scripts/tests/kubescape-rule-schema/schema_test.go new file mode 100644 index 0000000000..59088ec797 --- /dev/null +++ b/scripts/tests/kubescape-rule-schema/schema_test.go @@ -0,0 +1,71 @@ +package kubescaperuleschema_test + +import ( + "bytes" + "os" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +func TestRulesSchema(t *testing.T) { + data, err := os.ReadFile("../../../schemas/kubescape.io/rules_v1.json") + if err != nil { + t.Fatal(err) + } + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + compiler := jsonschema.NewCompiler() + const schemaURL = "https://platform.invalid/schemas/rules_v1.json" + if err := compiler.AddResource(schemaURL, document); err != nil { + t.Fatal(err) + } + schema, err := compiler.Compile(schemaURL) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + change func(map[string]any) + valid bool + }{ + {"profile projection object", func(r map[string]any) { + r["profileDataRequired"] = map[string]any{"execs": []any{"paths"}} + }, true}, + {"optional projection", func(map[string]any) {}, true}, + {"projection wrong type", func(r map[string]any) { r["profileDataRequired"] = "all" }, false}, + {"unknown rule field", func(r map[string]any) { r["unrecognized"] = true }, false}, + {"invalid dependency", func(r map[string]any) { r["profileDependency"] = float64(3) }, false}, + {"missing required field", func(r map[string]any) { delete(r, "enabled") }, false}, + {"unknown expression field", func(r map[string]any) { + r["expressions"].(map[string]any)["unrecognized"] = true + }, false}, + } { + t.Run(tc.name, func(t *testing.T) { + rule := map[string]any{ + "enabled": true, "id": "R2001", "name": "Port forward to pod", + "description": "Port-forward baseline", "profileDependency": float64(2), + "severity": float64(5), "supportPolicy": false, "isTriggerAlert": true, + "mitreTechnique": "T1572", "mitreTactic": "TA0011", + "expressions": map[string]any{ + "message": "port forward", "uniqueId": "event.ID", + "ruleExpression": []any{map[string]any{ + "eventType": "k8s-admission", "expression": "event.Kind == \"PodPortForwardOptions\"", + }}, + }, + } + tc.change(rule) + resource := map[string]any{ + "apiVersion": "kubescape.io/v1", "kind": "Rules", + "metadata": map[string]any{"name": "fixture", "namespace": "kubescape"}, + "spec": map[string]any{"rules": []any{rule}}, + } + err := schema.Validate(resource) + if (err == nil) != tc.valid { + t.Fatalf("valid=%v, validation error=%v", tc.valid, err) + } + }) + } +} diff --git a/scripts/tests/test-kubescape-runtime-rules-switch.sh b/scripts/tests/test-kubescape-runtime-rules-switch.sh index 25c146cd92..2a84db9074 100755 --- a/scripts/tests/test-kubescape-runtime-rules-switch.sh +++ b/scripts/tests/test-kubescape-runtime-rules-switch.sh @@ -7,13 +7,13 @@ # with zero rules: the node agent profiled workloads but checked nothing, and an empty alert # route read exactly like a quiet cluster. This test makes that state visible and the switch safe: # -# 1. Production must declare the switch explicitly. An idle detector is then a reviewed choice -# in Git, never a chart default nobody chose. +# 1. Production must explicitly install the initial port-forward baseline when runtime detection +# is enabled. An enabled detector with no rules is a failure, never a quiet cluster. # 2. The pinned chart is rendered with production's values and post-renderers in BOTH states, # as an install and as an upgrade, whichever state is declared. # 3. Off must render no rule or binding object. On must render exactly the default Rules object -# and the cluster-wide binding, the binding must name only rules that exist and at least one -# enabled rule, and it must cover the same namespaces the scanner does. +# and the cluster-wide binding, each containing only the enabled, profile-independent +# port-forward rule by ID, and cover the same namespaces the scanner does. # 4. On may differ from off by those two objects only, so turning the switch on changes no # RBAC, workload or CRD. # @@ -21,6 +21,7 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +bash "$root/scripts/generate-kubescape-rule-schema.sh" --check scratch="$(mktemp -d "${TMPDIR:-/tmp}/kubescape-runtime-rules.XXXXXX")" trap 'rm -rf -- "$scratch"' EXIT @@ -32,6 +33,11 @@ declared_switch() { if type == "boolean" then tostring else "undeclared (\(type))" end' "$1" } +production_baseline_enabled() { + jq -e '.spec.values.capabilities.runtimeDetection == "enable" and + .spec.values.alertCRD.installDefault == true' "$1" >/dev/null +} + # Problems with an "off" render ($1: a JSON array of every rendered object). off_problems() { jq -r '.[] | select(.kind == "Rules" or .kind == "RuntimeRuleAlertBinding") | @@ -47,28 +53,34 @@ on_problems() { elif ($bindings | length) != 1 then "on renders \($bindings | length) bindings, not 1" else $rules[0] as $set | $bindings[0] as $binding | - [$set.spec.rules[]? | .name] as $names | - [$set.spec.rules[]? | select(.enabled == true) | .name] as $enabled | - [$binding.spec.rules[]? | .ruleName] as $bound | + [$set.spec.rules[]? | select(.enabled == true) | .id] as $enabled | + [$binding.spec.rules[]? | .ruleID] as $bound | (if $set.metadata.name == "default-rules" and $set.metadata.namespace == "kubescape" then empty else "the Rules object is not kubescape/default-rules" end), + (if ($set.spec | keys) == ["rules"] and ($set.spec.rules | length) == 1 + then empty else "the baseline must contain exactly one rule definition and no temporary state" end), (if $binding.metadata.name == "all-rules-all-pods" and $binding.metadata.namespace == null then empty else "the binding is not the cluster-wide all-rules-all-pods" end), (if any($bound[]; IN($enabled[])) then empty else "the binding names no enabled rule" end), + (if $binding.spec.rules == [{ruleID:"R2001"}] and + $binding.metadata.labels["devantler.tech/runtime-rule-stage"] == "port-forward-baseline" + then empty else "the binding is not the bounded port-forward baseline" end), + (if [$set.spec.rules[]? | select(.id == "R2001" and .enabled == true and + .profileDependency == 2 and .expressions.ruleExpression == [{ + eventType:"k8s-admission", expression:"event.Kind == \"PodPortForwardOptions\""}])] | length == 1 + then empty else "the port-forward definition is missing, disabled or profile-dependent" end), (if $binding.spec.namespaceSelector.matchExpressions == [{ key: "kubernetes.io/metadata.name", operator: "NotIn", values: ($exclude | split(","))}] then empty else "the binding does not cover exactly the scanned namespaces" end) end' "$1" } -# Names the binding lists that match no rule in the Rules object. Such an entry checks nothing, -# but the rules it does resolve still run, so it is reported rather than fatal: the pinned chart -# itself binds one rule under a name its Rules object does not use. -unbound_names() { +# A stable ID must resolve against the installed Rules object; names may change. +unbound_rule_ids() { jq -r ' - [.[] | select(.kind == "Rules") | .spec.rules[]? | .name] as $names | - .[] | select(.kind == "RuntimeRuleAlertBinding") | .spec.rules[]? | .ruleName | - select(IN($names[]) | not)' "$1" + [.[] | select(.kind == "Rules") | .spec.rules[]? | .id] as $ids | + .[] | select(.kind == "RuntimeRuleAlertBinding") | .spec.rules[]? | .ruleID | + select(IN($ids[]) | not)' "$1" } # Every rendered object except the two the switch owns, in a stable order. The chart generates @@ -110,7 +122,15 @@ jq '.spec.values.alertCRD.installDefault = "false"' "$scratch/release.json" >"$s [[ "$(declared_switch "$scratch/undeclared.json")" == "undeclared (string)" ]] || fail 'the declaration check accepted a string switch' -detection="$(jq -r '.spec.values.capabilities.runtimeDetection // "unset"' "$scratch/release.json")" +production_baseline_enabled "$scratch/release.json" || + fail 'production runtime detection and its installed rule baseline must be enabled' +for mutation in '.spec.values.capabilities.runtimeDetection = "disable"' \ + '.spec.values.alertCRD.installDefault = false'; do + jq "$mutation" "$scratch/release.json" >"$scratch/disabled-baseline.json" + if production_baseline_enabled "$scratch/disabled-baseline.json"; then + fail 'the production baseline check accepted a disabled detector or rule set' + fi +done exclude="$(jq -er '.spec.values.excludeNamespaces | select(type == "string" and . != "")' "$scratch/release.json")" || fail 'the release must declare the namespaces the scanner excludes' jq -e '.spec.postRenderers | select(length > 0)' "$scratch/release.json" >"$scratch/renderers.json" @@ -174,11 +194,25 @@ for mode in install upgrade; do expect_problem 'off renders Rules default-rules' off_problems "$on" jq 'map(if .kind == "Rules" then .spec.rules |= map(.enabled = false) else . end)' "$on" >"$scratch/mutated.json" expect_problem 'names no enabled rule' on_problems "$scratch/mutated.json" "$exclude" - jq 'map(if .kind == "Rules" then .spec.rules |= map(.name += " (renamed)") else . end)' "$on" >"$scratch/mutated.json" + jq 'map(if .kind == "Rules" then .spec.rules |= map(.id += "-unknown") else . end)' "$on" >"$scratch/mutated.json" expect_problem 'names no enabled rule' on_problems "$scratch/mutated.json" "$exclude" - jq 'map(if .kind == "Rules" then .spec.rules[0].name += " (renamed)" else . end)' "$on" >"$scratch/mutated.json" - expect_problem "$(jq -r '.[] | select(.kind == "Rules") | .spec.rules[0].name' "$on")" \ - unbound_names "$scratch/mutated.json" + jq 'map(if .kind == "Rules" then .spec.rules |= map(if .id == "R2001" then .id = "unknown" else . end) else . end)' "$on" >"$scratch/mutated.json" + expect_problem 'R2001' unbound_rule_ids "$scratch/mutated.json" + jq 'map(if .kind == "Rules" then .spec.rules |= map(.name += " (renamed)") else . end)' "$on" >"$scratch/mutated.json" + [[ -z "$(on_problems "$scratch/mutated.json" "$exclude")" ]] || + fail "$mode: renaming a rule broke its stable-ID binding" + jq 'map(if .kind == "RuntimeRuleAlertBinding" then .spec.rules = [{ruleID:"R0001"}] else . end)' "$on" >"$scratch/mutated.json" + expect_problem 'not the bounded port-forward baseline' on_problems "$scratch/mutated.json" "$exclude" + jq 'map(if .kind == "RuntimeRuleAlertBinding" then .spec.rules += [{ruleID:"R0001"}] else . end)' "$on" >"$scratch/mutated.json" + expect_problem 'not the bounded port-forward baseline' on_problems "$scratch/mutated.json" "$exclude" + jq 'map(if .kind == "Rules" then .spec.rules += [.spec.rules[0] | .id = "R0001"] else . end)' "$on" >"$scratch/mutated.json" + expect_problem 'exactly one rule definition' on_problems "$scratch/mutated.json" "$exclude" + jq 'map(if .kind == "Rules" then .spec.portForwardBaseline = {} else . end)' "$on" >"$scratch/mutated.json" + expect_problem 'no temporary state' on_problems "$scratch/mutated.json" "$exclude" + jq 'map(if .kind == "Rules" then .spec.rules |= map(if .id == "R2001" then .profileDependency = 0 else . end) else . end)' "$on" >"$scratch/mutated.json" + expect_problem 'profile-dependent' on_problems "$scratch/mutated.json" "$exclude" + jq 'map(if .kind == "Rules" then .spec.rules |= map(if .id == "R2001" then .expressions.ruleExpression[0].eventType = "exec" else . end) else . end)' "$on" >"$scratch/mutated.json" + expect_problem 'port-forward definition' on_problems "$scratch/mutated.json" "$exclude" jq 'map(select(.kind != "RuntimeRuleAlertBinding"))' "$on" >"$scratch/mutated.json" expect_problem 'renders 0 bindings' on_problems "$scratch/mutated.json" "$exclude" jq 'map(if .kind == "RuntimeRuleAlertBinding" then del(.spec.namespaceSelector) else . end)' "$on" >"$scratch/mutated.json" @@ -190,10 +224,5 @@ for mode in install upgrade; do fi done -while IFS= read -r name; do - printf 'NOTE: the chart binding names "%s", which matches no rule in its Rules object\n' "$name" -done < <(unbound_names "$scratch/true-install.json") -if [[ "$detection" == enable && "$declared" == false ]]; then - printf 'NOTE: runtime detection is enabled with its default rules switched off (#4968), so it checks nothing until the switch is turned on\n' -fi -printf 'PASS: the Kubescape runtime-rules switch is declared %s and adds only the default rules and their binding\n' "$declared" +[[ -z "$(unbound_rule_ids "$scratch/true-install.json")" ]] || fail 'the binding contains unknown rule IDs' +printf 'PASS: runtime detection installs the bounded port-forward baseline; other resources and both switch states are preserved\n' From 787c934b066780e0155fb6b3940972f7fd6f56bc Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 2 Nov 2026 11:59:21 +0100 Subject: [PATCH 2/2] fix(security): record the bounded runtime baseline authorization surface --- scripts/validate-eks-ci-role-policy/approved-surface.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/validate-eks-ci-role-policy/approved-surface.txt b/scripts/validate-eks-ci-role-policy/approved-surface.txt index a9186cabcc..fdd55754a8 100644 --- a/scripts/validate-eks-ci-role-policy/approved-surface.txt +++ b/scripts/validate-eks-ci-role-policy/approved-surface.txt @@ -76,7 +76,7 @@ helm.toolkit.fluxcd.io/v2|HelmRelease|kube-system|tetragon 5781451a70a8dee38ed71 helm.toolkit.fluxcd.io/v2|HelmRelease|kubescape|alertmanager be4e40e80964e37494dde9c1df0662a58b9b8976d2a57b9efed9c65aebdb9611 -helm.toolkit.fluxcd.io/v2|HelmRelease|kubescape|kubescape 6a352e2f5cb05f56d3558dcb9a11b1a56275b70c62b508f67e9c5366c79ce390 +helm.toolkit.fluxcd.io/v2|HelmRelease|kubescape|kubescape d173c38693c99ad1683f4408829c54293fef90f92b1d2a7d855590e692b65667 helm.toolkit.fluxcd.io/v2|HelmRelease|kyverno|kyverno b910afb7d7bcbb10e14430ec948732454c95e649bcdb71d16a3855fea07e7fbe