diff --git a/.github/workflows/experimental-signing.yaml b/.github/workflows/experimental-signing.yaml new file mode 100644 index 0000000000..35e48fe47f --- /dev/null +++ b/.github/workflows/experimental-signing.yaml @@ -0,0 +1,42 @@ +name: Experimental signing build isolation + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + signing: + runs-on: ubuntu-latest + strategy: + matrix: + tags: ["", experimental_signing] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Test signing integration and baseline + env: + BUILD_TAGS: ${{ matrix.tags }} + run: | + go test -tags "$BUILD_TAGS" ./pkg/config ./pkg/healthmanager ./pkg/objectcache/containerprofilecache ./pkg/rulebindingmanager/cache ./pkg/rulemanager/rulecreator ./pkg/rulemanager/ruleswatcher ./pkg/rulemanager/types/v1 ./pkg/storage/... + if [ -n "$BUILD_TAGS" ]; then + go test -tags "$BUILD_TAGS" ./pkg/signature/... ./pkg/compactyaml ./cmd/... + go build -tags "$BUILD_TAGS" -o /tmp/sign-object ./cmd/sign-object + fi + - name: Build agent and prove default isolation + env: + BUILD_TAGS: ${{ matrix.tags }} + run: | + go build -tags "$BUILD_TAGS" -o /tmp/node-agent ./cmd + if [ -z "$BUILD_TAGS" ]; then + go list -deps ./cmd > /tmp/default-deps + if grep -E 'github.com/kubescape/node-agent/pkg/signature|github.com/sigstore/(cosign|fulcio)' /tmp/default-deps; then + echo 'Experimental signing dependencies leaked into the default build' + exit 1 + fi + fi diff --git a/Makefile b/Makefile index 231f2725a7..fc76910fee 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ DOCKERFILE_PATH=./build/Dockerfile BINARY_NAME=node-agent +# Opt in with BUILD_TAGS=experimental_signing. Empty by default. +BUILD_TAGS?= IMAGE?=quay.io/kubescape/$(BINARY_NAME) # GADGETS are pulled unmodified from upstream IG. trace_open is intentionally NOT @@ -15,7 +17,7 @@ TAG?=test # TAG?=v0.0.1 binary: - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o $(BINARY_NAME) ./cmd/main.go + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags "$(BUILD_TAGS)" -o $(BINARY_NAME) ./cmd .PHONY: check-legacy-packages check-legacy-packages: @@ -28,10 +30,10 @@ check-ig-pin: ./scripts/check-inspektor-gadget-pin.sh go.mod docker-build-only: - docker buildx build --platform linux/amd64 -t $(IMAGE):$(TAG) -f $(DOCKERFILE_PATH) --load . + docker buildx build --build-arg BUILD_TAGS="$(BUILD_TAGS)" --platform linux/amd64 -t $(IMAGE):$(TAG) -f $(DOCKERFILE_PATH) --load . docker-build: gadgets - docker buildx build --platform linux/amd64 -t $(IMAGE):$(TAG) -f $(DOCKERFILE_PATH) --load . + docker buildx build --build-arg BUILD_TAGS="$(BUILD_TAGS)" --platform linux/amd64 -t $(IMAGE):$(TAG) -f $(DOCKERFILE_PATH) --load . docker-push: docker-build docker push $(IMAGE):$(TAG) diff --git a/README.md b/README.md index be39b42c94..73ee6da32d 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ git clone https://github.com/kubescape/node-agent.git cd node-agent # Build the binary -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o node-agent ./cmd/main.go +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o node-agent ./cmd # Set required environment variables export NODE_NAME=$(hostname) @@ -520,7 +520,7 @@ go test ./tests/... "type": "go", "request": "launch", "mode": "auto", - "program": "${workspaceFolder}/cmd/main.go", + "program": "${workspaceFolder}/cmd", "env": { "NODE_NAME": "", "KUBECONFIG": "" diff --git a/build/Dockerfile b/build/Dockerfile index 757bbba378..df07e12346 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -3,11 +3,12 @@ FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS builder ENV GO111MODULE=on CGO_ENABLED=0 WORKDIR /work ARG TARGETOS TARGETARCH +ARG BUILD_TAGS="" RUN --mount=target=. \ --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg \ - GOOS=$TARGETOS GOARCH=$TARGETARCH GOEXPERIMENT=greenteagc go build -o /out/node-agent -ldflags="-s -w" ./cmd/main.go + GOOS=$TARGETOS GOARCH=$TARGETARCH GOEXPERIMENT=greenteagc go build -tags "$BUILD_TAGS" -o /out/node-agent -ldflags="-s -w" ./cmd RUN --mount=target=. \ --mount=type=cache,target=/root/.cache/go-build \ diff --git a/build/Dockerfile.debug b/build/Dockerfile.debug index b21938e5af..743c7bc5c0 100644 --- a/build/Dockerfile.debug +++ b/build/Dockerfile.debug @@ -3,12 +3,13 @@ FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS builder ENV GO111MODULE=on CGO_ENABLED=0 WORKDIR /work ARG TARGETOS TARGETARCH +ARG BUILD_TAGS="" RUN go install github.com/go-delve/delve/cmd/dlv@latest RUN --mount=target=. \ --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg \ - GOOS=$TARGETOS GOARCH=$TARGETARCH GOEXPERIMENT=greenteagc go build -o /out/node-agent ./cmd/main.go + GOOS=$TARGETOS GOARCH=$TARGETARCH GOEXPERIMENT=greenteagc go build -tags "$BUILD_TAGS" -o /out/node-agent ./cmd FROM gcr.io/distroless/static-debian13:debug diff --git a/cmd/main.go b/cmd/main.go index 3a2f0fd9cf..e9f14169e6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -115,7 +115,12 @@ func main() { } } - clusterData, err := utilsmetadata.LoadConfig("/etc/config/clusterData.json") + clusterDataPath, cleanupClusterData, err := verifiedClusterDataPath() + if err != nil { + logger.L().Ctx(ctx).Fatal("verify clusterData error", helpers.Error(err)) + } + defer cleanupClusterData() + clusterData, err := utilsmetadata.LoadConfig(clusterDataPath) if err != nil { logger.L().Ctx(ctx).Fatal("load clusterData error", helpers.Error(err)) } @@ -209,6 +214,7 @@ func main() { // Start the health manager healthManager := healthmanager.NewHealthManager() + configureSigningHealth(healthManager) healthManager.Start(ctx) // Create clients @@ -283,10 +289,11 @@ func main() { } var ruleBindingCache *rulebindingcachev1.RBCache + var rulesWatcher *ruleswatcher.RulesWatcherImpl if cfg.EnableRuntimeDetection { ruleCreator := rulecreator.NewRuleCreator() ruleBindingCache = rulebindingcachev1.NewCache(cfg, k8sClient, ruleCreator) - rulesWatcher := ruleswatcher.NewRulesWatcher(k8sClient, ruleCreator, func() { + rulesWatcher = ruleswatcher.NewRulesWatcher(k8sClient, ruleCreator, func() { ruleBindingCache.RefreshRuleBindingsRules() }) dWatcher.AddAdaptor(rulesWatcher) @@ -357,6 +364,7 @@ func main() { ruleBindingCache.AddNotifier(&ruleBindingNotify) cpc := containerprofilecache.NewContainerProfileCache(cfg, storageClient, k8sObjectCache, metricsProvider) + configureSigning(ctx, cfg, cpc, rulesWatcher, ruleBindingCache, exporter) cpc.Start(ctx) if cpm, ok := containerProfileManager.(*containerprofilemanagerv1.ContainerProfileManager); ok { cpm.SetCompletionNotifier(cpc) diff --git a/cmd/sign-object/Dockerfile b/cmd/sign-object/Dockerfile new file mode 100644 index 0000000000..e70b1cd3b7 --- /dev/null +++ b/cmd/sign-object/Dockerfile @@ -0,0 +1,31 @@ +FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS builder + +ENV GO111MODULE=on CGO_ENABLED=0 +WORKDIR /src +ARG TARGETOS TARGETARCH + +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg \ + go mod download + +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg \ + GOOS=$TARGETOS GOARCH=$TARGETARCH go build -tags experimental_signing -trimpath -ldflags="-s -w" -o /sign-object ./cmd/sign-object + +# Small alpine base + su-exec (pinned by digest). The entrypoint drops to the +# owner of the bind-mounted /work, so `docker run -v "$PWD:/work" …` writes +# user-owned files with NO --user flag; sign-object never runs as root when a +# non-root /work is mounted. +FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d +RUN apk add --no-cache su-exec +LABEL org.opencontainers.image.title="sign-object" \ + org.opencontainers.image.description="CLI to sign and verify Kubescape ApplicationProfile / NetworkNeighborhood / Rules objects (cosign, key-based or keyless)." \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.source="https://github.com/kubescape/node-agent" +COPY --from=builder /sign-object /usr/local/bin/sign-object +COPY cmd/sign-object/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh +WORKDIR /work +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/cmd/sign-object/entrypoint.sh b/cmd/sign-object/entrypoint.sh new file mode 100644 index 0000000000..b7f157d437 --- /dev/null +++ b/cmd/sign-object/entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Run sign-object as the owner of the bind-mounted /work, so files it writes +# (keys, signed profiles) are owned by the invoking user — no `--user` flag +# needed. Falls back to running directly if /work isn't a bind mount or is +# already root-owned. +set -e +if [ -d /work ]; then + uid="$(stat -c %u /work 2>/dev/null || echo 0)" + gid="$(stat -c %g /work 2>/dev/null || echo 0)" + if [ "$uid" != "0" ]; then + exec su-exec "${uid}:${gid}" sign-object "$@" + fi +fi +exec sign-object "$@" diff --git a/cmd/sign-object/main.go b/cmd/sign-object/main.go new file mode 100644 index 0000000000..86fe3e9352 --- /dev/null +++ b/cmd/sign-object/main.go @@ -0,0 +1,723 @@ +//go:build experimental_signing + +package main + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "flag" + "fmt" + "os" + "strings" + + k8syaml "k8s.io/apimachinery/pkg/util/yaml" + + "github.com/kubescape/node-agent/pkg/compactyaml" + rulebindingtypesv1 "github.com/kubescape/node-agent/pkg/rulebindingmanager/types/v1" + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +var ( + configPurpose string + inputFile string + outputFile string + keyFile string + objectType string + useKeyless bool + embedContent bool + verbose bool + strict bool + jsonOutput bool + publicOnly bool + policyFile string + command string +) + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + command = os.Args[1] + + argsRewritten := false + if command == "-h" || command == "--help" { + printUsage() + os.Exit(0) + } + if strings.HasPrefix(command, "-") { + command = "sign" + argsRewritten = true + } + + switch command { + case "sign", "": + parseSignFlags() + if argsRewritten { + os.Args = append([]string{"sign-object"}, os.Args[1:]...) + } + case "verify": + parseVerifyFlags() + os.Args = append([]string{"sign-object verify"}, os.Args[2:]...) + case "generate-keypair": + parseGenerateFlags() + os.Args = append([]string{"sign-object generate-keypair"}, os.Args[2:]...) + case "extract-signature": + parseExtractFlags() + os.Args = append([]string{"sign-object extract-signature"}, os.Args[2:]...) + case "sign-policy": + parseSignPolicyFlags() + os.Args = append([]string{"sign-object sign-policy"}, os.Args[2:]...) + case "sign-config": + parseSignConfigFlags() + os.Args = append([]string{"sign-object sign-config"}, os.Args[2:]...) + case "help", "--help", "-h": + printUsage() + os.Exit(0) + default: + fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) + printUsage() + os.Exit(1) + } + + if err := runCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func parseSignFlags() { + fs := flag.NewFlagSet("sign-object sign", flag.ExitOnError) + fs.StringVar(&inputFile, "file", "", "Input object YAML file (required)") + fs.StringVar(&outputFile, "output", "", "Output file for signed object (required)") + fs.StringVar(&keyFile, "key", "", "Path to private key file") + fs.StringVar(&objectType, "type", "auto", "Object type: containerprofile, seccompprofile, rules, rulebinding, or auto") + fs.BoolVar(&useKeyless, "keyless", false, "Use keyless signing (OIDC)") + fs.BoolVar(&embedContent, "embed-content", true, "Embed the canonical signed content in the object annotations, making the signature independent of server-side spec normalisation (recommended for shippable artifacts; disable for legacy sign-after-roundtrip flows)") + fs.BoolVar(&verbose, "verbose", false, "Enable verbose logging") + + offset := 2 + if len(os.Args) > 1 && strings.HasPrefix(os.Args[1], "-") { + offset = 1 + } + + if err := fs.Parse(os.Args[offset:]); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + if inputFile == "" { + fmt.Fprintln(os.Stderr, "Error: --file is required") + fs.PrintDefaults() + os.Exit(1) + } + + if outputFile == "" { + fmt.Fprintln(os.Stderr, "Error: --output is required") + fs.PrintDefaults() + os.Exit(1) + } + + if !useKeyless && keyFile == "" { + fmt.Fprintln(os.Stderr, "Error: either --keyless or --key must be specified") + fs.PrintDefaults() + os.Exit(1) + } +} + +func parseVerifyFlags() { + fs := flag.NewFlagSet("sign-object verify", flag.ExitOnError) + fs.StringVar(&inputFile, "file", "", "Signed object YAML file (required)") + fs.StringVar(&objectType, "type", "auto", "Object type: containerprofile, seccompprofile, rules, rulebinding, or auto") + fs.BoolVar(&strict, "strict", true, "Require trusted issuer/identity") + fs.BoolVar(&verbose, "verbose", false, "Enable verbose logging") + + if err := fs.Parse(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + if inputFile == "" { + fmt.Fprintln(os.Stderr, "Error: --file is required") + fs.PrintDefaults() + os.Exit(1) + } +} + +func parseGenerateFlags() { + fs := flag.NewFlagSet("sign-object generate-keypair", flag.ExitOnError) + fs.StringVar(&outputFile, "output", "", "Output PEM file") + fs.BoolVar(&publicOnly, "public-only", false, "Only output public key") + + if err := fs.Parse(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + if outputFile == "" { + fmt.Fprintln(os.Stderr, "Error: --output is required") + fs.PrintDefaults() + os.Exit(1) + } +} + +func parseExtractFlags() { + fs := flag.NewFlagSet("sign-object extract-signature", flag.ExitOnError) + fs.StringVar(&inputFile, "file", "", "Signed object YAML file (required)") + fs.StringVar(&objectType, "type", "auto", "Object type: containerprofile, seccompprofile, rules, rulebinding, or auto") + fs.BoolVar(&jsonOutput, "json", false, "Output as JSON") + + if err := fs.Parse(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + if inputFile == "" { + fmt.Fprintln(os.Stderr, "Error: --file is required") + fs.PrintDefaults() + os.Exit(1) + } +} + +func runCommand() error { + switch command { + case "sign", "": + return runSign() + case "verify": + return runVerify() + case "generate-keypair": + return runGenerateKeyPair() + case "extract-signature": + return runExtractSignature() + case "sign-policy": + return runSignPolicy() + case "sign-config": + return runSignConfig() + default: + return fmt.Errorf("unknown command: %s", command) + } +} + +func parseSignPolicyFlags() { + fs := flag.NewFlagSet("sign-object sign-policy", flag.ExitOnError) + fs.StringVar(&policyFile, "policy", "", "Input plain trust-policy JSON file (required)") + fs.StringVar(&keyFile, "key", "", "Path to ROOT EC private key PEM (required)") + fs.StringVar(&outputFile, "output", "", "Output file for signed trust policy artifact (required)") + fs.BoolVar(&verbose, "verbose", false, "Enable verbose logging") + + if err := fs.Parse(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + if policyFile == "" { + fmt.Fprintln(os.Stderr, "Error: --policy is required") + fs.PrintDefaults() + os.Exit(1) + } + if keyFile == "" { + fmt.Fprintln(os.Stderr, "Error: --key is required") + fs.PrintDefaults() + os.Exit(1) + } + if outputFile == "" { + fmt.Fprintln(os.Stderr, "Error: --output is required") + fs.PrintDefaults() + os.Exit(1) + } +} + +func runSignPolicy() error { + policyData, err := os.ReadFile(policyFile) + if err != nil { + return fmt.Errorf("failed to read policy file: %w", err) + } + + var policy bundle.TrustPolicy + if err := json.Unmarshal(policyData, &policy); err != nil { + return fmt.Errorf("failed to parse trust policy JSON: %w", err) + } + + rootKey, err := loadRootPrivateKey(keyFile) + if err != nil { + return err + } + + signed, err := bundle.SignTrustPolicy(&policy, rootKey) + if err != nil { + return fmt.Errorf("failed to sign trust policy: %w", err) + } + + if err := os.WriteFile(outputFile, signed, 0644); err != nil { + return fmt.Errorf("failed to write output file: %w", err) + } + + fmt.Printf("✓ Trust policy signed successfully\n") + fmt.Printf("✓ Signed trust policy written to: %s\n", outputFile) + return nil +} + +func parseSignConfigFlags() { + fs := flag.NewFlagSet("sign-object sign-config", flag.ExitOnError) + fs.StringVar(&configPurpose, "purpose", "", "Signed artifact purpose: node-agent-config or cluster-data (required)") + fs.StringVar(&inputFile, "file", "", "Input plain config JSON file, e.g. config.json or clusterData.json (required)") + fs.StringVar(&keyFile, "key", "", "Path to ROOT EC private key PEM (required)") + fs.StringVar(&outputFile, "output", "", "Output file for the signed config artifact (required)") + fs.BoolVar(&verbose, "verbose", false, "Enable verbose logging") + + if err := fs.Parse(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + if inputFile == "" { + fmt.Fprintln(os.Stderr, "Error: --file is required") + fs.PrintDefaults() + os.Exit(1) + } + if keyFile == "" { + fmt.Fprintln(os.Stderr, "Error: --key is required") + fs.PrintDefaults() + os.Exit(1) + } + if outputFile == "" { + fmt.Fprintln(os.Stderr, "Error: --output is required") + fs.PrintDefaults() + os.Exit(1) + } +} + +func runSignConfig() error { + configData, err := os.ReadFile(inputFile) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + rootKey, err := loadRootPrivateKey(keyFile) + if err != nil { + return err + } + + signed, err := bundle.SignConfig(configData, bundle.ConfigPurpose(configPurpose), rootKey) + if err != nil { + return fmt.Errorf("failed to sign config: %w", err) + } + + if err := os.WriteFile(outputFile, signed, 0644); err != nil { + return fmt.Errorf("failed to write output file: %w", err) + } + + fmt.Printf("\u2713 Config signed successfully\n") + fmt.Printf("\u2713 Signed config written to: %s\n", outputFile) + return nil +} + +func loadRootPrivateKey(path string) (*ecdsa.PrivateKey, error) { + keyData, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read private key file: %w", err) + } + + block, _ := pem.Decode(keyData) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block from key file") + } + + rootKey, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse EC private key: %w", err) + } + return rootKey, nil +} + +func runSign() error { + data, err := os.ReadFile(inputFile) + if err != nil { + return fmt.Errorf("failed to read input file: %w", err) + } + + if verbose { + fmt.Printf("Reading profile from: %s\n", inputFile) + fmt.Printf("Profile size: %d bytes\n", len(data)) + } + + profileAdapter, err := detectObjectType(objectType, data) + if err != nil { + return fmt.Errorf("failed to detect profile type: %w", err) + } + + if verbose { + fmt.Printf("Detected object type: %s\n", getObjectName(profileAdapter)) + } + + var signErr error + if useKeyless { + if verbose { + fmt.Println("Using keyless signing (OIDC)") + } + signErr = signature.SignObject(profileAdapter, signature.WithKeyless(true), signature.WithEmbedContent(embedContent)) + } else { + if verbose { + fmt.Printf("Using local key from: %s\n", keyFile) + } + + keyData, err := os.ReadFile(keyFile) + if err != nil { + return fmt.Errorf("failed to read private key file: %w", err) + } + + block, _ := pem.Decode(keyData) + if block == nil { + return fmt.Errorf("failed to decode PEM block from key file") + } + + privateKey, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse EC private key: %w", err) + } + + signErr = signature.SignObject(profileAdapter, signature.WithPrivateKey(privateKey), signature.WithEmbedContent(embedContent)) + } + + if signErr != nil { + return fmt.Errorf("failed to sign profile: %w", signErr) + } + + sig, err := signature.GetObjectSignature(profileAdapter) + if err != nil { + return fmt.Errorf("failed to get signature: %w", err) + } + + fmt.Printf("✓ Profile signed successfully\n") + fmt.Printf(" Issuer: %s\n", sig.Issuer) + fmt.Printf(" Identity: %s\n", sig.Identity) + fmt.Printf(" Timestamp: %d\n", sig.Timestamp) + + profileBytes, err := compactyaml.Marshal(profileAdapter.GetUpdatedObject()) + if err != nil { + return fmt.Errorf("failed to marshal signed object: %w", err) + } + + if err := os.WriteFile(outputFile, profileBytes, 0644); err != nil { + return fmt.Errorf("failed to write output file: %w", err) + } + + fmt.Printf("✓ Signed profile written to: %s\n", outputFile) + return nil +} + +func runVerify() error { + data, err := os.ReadFile(inputFile) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + if verbose { + fmt.Printf("Reading profile from: %s\n", inputFile) + } + + profileAdapter, err := detectObjectType(objectType, data) + if err != nil { + return fmt.Errorf("failed to detect profile type: %w", err) + } + + sig, err := signature.GetObjectSignature(profileAdapter) + if err != nil { + return fmt.Errorf("profile is not signed: %w", err) + } + + fmt.Printf("Signature found:\n") + fmt.Printf(" Issuer: %s\n", sig.Issuer) + fmt.Printf(" Identity: %s\n", sig.Identity) + fmt.Printf(" Timestamp: %d\n", sig.Timestamp) + + var verifyErr error + if strict { + if verbose { + fmt.Println("Verifying with strict mode (keyless signatures must have issuer/identity)") + } + verifyErr = signature.VerifyObjectStrict(profileAdapter) + } else { + if verbose { + fmt.Println("Verifying in non-strict mode (allowing untrusted signatures)") + } + verifyErr = signature.VerifyObjectAllowUntrusted(profileAdapter) + } + + if verifyErr != nil { + return fmt.Errorf("signature verification failed: %w", verifyErr) + } + + fmt.Printf("✓ Signature verification successful\n") + return nil +} + +func runGenerateKeyPair() error { + adapter, err := signature.NewCosignAdapter(false) + if err != nil { + return fmt.Errorf("failed to create adapter: %w", err) + } + + pubKeyBytes, err := adapter.GetPublicKeyPEM() + if err != nil { + return fmt.Errorf("failed to get public key: %w", err) + } + + if publicOnly { + if err := os.WriteFile(outputFile, pubKeyBytes, 0644); err != nil { + return fmt.Errorf("failed to write public key file: %w", err) + } + + fmt.Printf("✓ Public key written to: %s\n", outputFile) + return nil + } + + privKeyBytes, err := adapter.GetPrivateKeyPEM() + if err != nil { + return fmt.Errorf("failed to get private key: %w", err) + } + + if err := os.WriteFile(outputFile, privKeyBytes, 0600); err != nil { + return fmt.Errorf("failed to write private key file: %w", err) + } + + pubKeyFile := outputFile + ".pub" + if err := os.WriteFile(pubKeyFile, pubKeyBytes, 0644); err != nil { + return fmt.Errorf("failed to write public key file: %w", err) + } + + fmt.Printf("✓ Private key written to: %s\n", outputFile) + fmt.Printf("✓ Public key written to: %s\n", pubKeyFile) + return nil +} + +func runExtractSignature() error { + data, err := os.ReadFile(inputFile) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + profileAdapter, err := detectObjectType(objectType, data) + if err != nil { + return fmt.Errorf("failed to detect profile type: %w", err) + } + + sig, err := signature.GetObjectSignature(profileAdapter) + if err != nil { + return fmt.Errorf("profile is not signed: %w", err) + } + + sigInfo := map[string]interface{}{ + "signature_size": len(sig.Signature), + "certificate_size": len(sig.Certificate), + "issuer": sig.Issuer, + "identity": sig.Identity, + "timestamp": sig.Timestamp, + "signature_base64": base64.StdEncoding.EncodeToString(sig.Signature), + "certificate_base64": base64.StdEncoding.EncodeToString(sig.Certificate), + } + + if jsonOutput { + jsonData, err := json.MarshalIndent(sigInfo, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonData)) + } else { + fmt.Println("Signature Information:") + fmt.Printf(" Issuer: %s\n", sig.Issuer) + fmt.Printf(" Identity: %s\n", sig.Identity) + fmt.Printf(" Timestamp: %d\n", sig.Timestamp) + fmt.Printf(" Signature Size: %d bytes\n", len(sig.Signature)) + fmt.Printf(" Certificate Size: %d bytes\n", len(sig.Certificate)) + + block, _ := pem.Decode(sig.Certificate) + if block != nil { + fmt.Printf(" Certificate Type: %s\n", block.Type) + } + } + + return nil +} + +func detectObjectType(objectType string, data []byte) (signature.SignableObject, error) { + var decoded map[string]interface{} + if err := k8syaml.Unmarshal(data, &decoded); err != nil { + return nil, fmt.Errorf("failed to unmarshal YAML: %w", err) + } + + kind, _ := decoded["kind"].(string) + apiVersion, _ := decoded["apiVersion"].(string) + + if verbose { + fmt.Printf("Detected API: %s, Kind: %s\n", apiVersion, kind) + } + + if objectType != "auto" { + switch strings.ToLower(objectType) { + case "containerprofile", "container-profile", "cp": + return loadContainerProfile(data) + case "seccompprofile", "seccomp-profile", "sp": + return loadSeccompProfile(data) + case "rules", "rule", "r": + return loadRules(data) + case "rulebinding", "rule-binding", "runtimerulealertbinding", "rb": + return loadRuleBinding(data) + default: + return nil, fmt.Errorf("unknown object type: %s", objectType) + } + } + + if strings.Contains(strings.ToLower(apiVersion), "softwarecomposition") { + switch strings.ToLower(kind) { + case "containerprofile", "container-profile": + return loadContainerProfile(data) + case "seccompprofile", "seccomp-profile": + return loadSeccompProfile(data) + } + } + + if strings.Contains(strings.ToLower(apiVersion), "kubescape.io") { + switch strings.ToLower(kind) { + case "rules": + return loadRules(data) + case "runtimerulealertbinding": + return loadRuleBinding(data) + } + } + + return nil, fmt.Errorf("unable to auto-detect object type") +} + +func loadContainerProfile(data []byte) (signature.SignableObject, error) { + var profile v1beta1.ContainerProfile + if err := k8syaml.Unmarshal(data, &profile); err != nil { + return nil, fmt.Errorf("failed to unmarshal ContainerProfile: %w", err) + } + return profiles.NewContainerProfileAdapter(&profile), nil +} + +func loadSeccompProfile(data []byte) (signature.SignableObject, error) { + var profile v1beta1.SeccompProfile + if err := k8syaml.Unmarshal(data, &profile); err != nil { + return nil, fmt.Errorf("failed to unmarshal SeccompProfile: %w", err) + } + return profiles.NewSeccompProfileAdapter(&profile), nil +} + +func loadRules(data []byte) (signature.SignableObject, error) { + var rules rulemanagertypesv1.Rules + if err := k8syaml.Unmarshal(data, &rules); err != nil { + return nil, fmt.Errorf("failed to unmarshal Rules: %w", err) + } + return profiles.NewRulesAdapter(&rules), nil +} + +func loadRuleBinding(data []byte) (signature.SignableObject, error) { + var binding rulebindingtypesv1.RuntimeAlertRuleBinding + if err := k8syaml.Unmarshal(data, &binding); err != nil { + return nil, fmt.Errorf("failed to unmarshal RuntimeRuleAlertBinding: %w", err) + } + return profiles.NewRuleBindingAdapter(&binding), nil +} + +func getObjectName(profile signature.SignableObject) string { + if _, ok := profile.(*profiles.ContainerProfileAdapter); ok { + return "ContainerProfile" + } + if _, ok := profile.(*profiles.SeccompProfileAdapter); ok { + return "SeccompProfile" + } + if _, ok := profile.(*profiles.RulesAdapter); ok { + return "Rules" + } + if _, ok := profile.(*profiles.RuleBindingAdapter); ok { + return "RuntimeRuleAlertBinding" + } + return "Unknown" +} + +func printUsage() { + fmt.Println(`sign-object - Sign and verify Kubernetes security objects + +USAGE: + sign-object [flags] + +COMMANDS: + sign Sign a profile (default command) + verify Verify a signed object + generate-keypair Generate a new ECDSA key pair + extract-signature Extract signature info from a profile + sign-policy Sign a bundle trust policy with the root key + sign-config Sign a node-agent config file with the root key + help Show this help message + +SIGN FLAGS: + --file Input object YAML file (required) + --output Output file for signed object (required) + --keyless Use keyless signing (OIDC) + --key Path to private key file + --type Object type: containerprofile, seccompprofile, rules, rulebinding, or auto (default: auto) + --verbose Enable verbose logging + +VERIFY FLAGS: + --file Signed object YAML file (required) + --type Object type: containerprofile, seccompprofile, rules, rulebinding, or auto (default: auto) + --strict Require trusted issuer/identity (default: true) + --verbose Enable verbose logging + +GENERATE-KEYPAIR FLAGS: + --output Output PEM file for private key (required) + --public-only Only output public key (no private key) + +EXTRACT-SIGNATURE FLAGS: + --file Signed object YAML file (required) + --type Object type: containerprofile, seccompprofile, rules, rulebinding, or auto (default: auto) + --json Output as JSON + +SIGN-POLICY FLAGS: + --policy Input plain trust-policy JSON file (required) + --key Path to ROOT EC private key PEM (required) + --output Output file for signed trust policy artifact (required) + +SIGN-CONFIG FLAGS: + --file Input plain config JSON file, e.g. config.json or clusterData.json (required) + --purpose node-agent-config or cluster-data (required) + --key Path to ROOT EC private key PEM (required) + --output Output file for the signed config artifact (required) + +EXAMPLES: + # Sign with keyless (OIDC) + sign-object --keyless --file object.yaml --output signed-object.yaml + + # Sign with local key + sign-object --key my-key.pem --file object.yaml --output signed-object.yaml + + # Verify a signed object + sign-object verify --file signed-object.yaml + + # Generate a key pair (writes my-key.pem and my-key.pem.pub) + sign-object generate-keypair --output my-key.pem + + # Generate only public key + sign-object generate-keypair --output my-key.pem --public-only + + # Extract signature information + sign-object extract-signature --file signed-object.yaml + + # Sign a bundle trust policy with the root key + sign-object sign-policy --policy trust-policy.json --key root.pem --output trust-policy.signed.json + + # Sign a node-agent config file with the root key + sign-object sign-config --purpose node-agent-config --file config.json --key root.pem --output config.signed.json + +For more information, see: docs/signing/README.md`) +} diff --git a/cmd/signing.go b/cmd/signing.go new file mode 100644 index 0000000000..4d1ce55c25 --- /dev/null +++ b/cmd/signing.go @@ -0,0 +1,162 @@ +//go:build experimental_signing + +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/exporters" + "github.com/kubescape/node-agent/pkg/healthmanager" + "github.com/kubescape/node-agent/pkg/objectcache/containerprofilecache" + rulebindingcachev1 "github.com/kubescape/node-agent/pkg/rulebindingmanager/cache" + "github.com/kubescape/node-agent/pkg/rulemanager/ruleswatcher" + "github.com/kubescape/node-agent/pkg/signature/bundle" +) + +const signedClusterDataPath = "/etc/config/clusterData.signed.json" + +func materialiseVerifiedClusterData(verified []byte) (string, error) { + dir, err := os.MkdirTemp("", "node-agent-clusterdata-") + if err != nil { + return "", err + } + path := filepath.Join(dir, "clusterData.json") + if err := os.WriteFile(path, verified, 0600); err != nil { + os.RemoveAll(dir) + return "", err + } + return path, nil +} + +func verifiedClusterDataPath() (string, func(), error) { + verified, signed, err := bundle.LoadSignedConfigIfPresent(signedClusterDataPath, bundle.ClusterDataPurpose) + if err != nil { + return "", func() {}, err + } + if !signed { + return "/etc/config/clusterData.json", func() {}, nil + } + path, err := materialiseVerifiedClusterData(verified) + if err != nil { + return "", func() {}, err + } + return path, func() { _ = os.RemoveAll(filepath.Dir(path)) }, nil +} +func configureSigningHealth(h *healthmanager.HealthManager) { + h.SetPolicyStatus(bundle.PolicyStatusSnapshot) +} +func configureSigning(ctx context.Context, cfg config.Config, cpc *containerprofilecache.ContainerProfileCacheImpl, rulesWatcher *ruleswatcher.RulesWatcherImpl, ruleBindingCache *rulebindingcachev1.RBCache, exporter exporters.Exporter) { + cpc.SetTamperAlertExporter(exporter) + if cfg.BundleTrustPolicyPath != "" { + // The root anchor is resolved ONLY from the fixed mount or the + // compiled default, never from a config-supplied path, so editing + // the (mutable) node-agent ConfigMap cannot redirect the anchor. + var prevPolicy *bundle.TrustPolicy + applyTrustPolicy := func(policy *bundle.TrustPolicy, rootFp string, mounted bool, resync bool) { + cpc.SetBundleConfig(policy) + if policy.Enforcing() { + logger.L().Info("signed bundle overlays enabled in ENFORCE mode: unsigned and unverifiable artifacts are refused") + } else { + logger.L().Info("signed bundle overlays enabled in alert mode") + } + if mounted { + logger.L().Warning("signed bundle overlays anchored to a MOUNTED root public key: the trust anchor is cluster-mutable, protect it with an immutable ConfigMap and tight RBAC", helpers.String("fingerprint", rootFp)) + } else if bundle.IsDemoRoot(rootFp) { + logger.L().Warning("signed bundle overlays anchored to the PUBLISHED DEMO root key: this authenticates nothing, mount a real root before relying on signatures") + } + // The same trust policy also governs signed Rules fragments; at + // boot this runs before dWatcher.Start, so the watcher has not + // yet synced anything; SetTrustPolicy is mutex-guarded regardless. + if rulesWatcher != nil { + rulesWatcher.SetTrustPolicy(policy) + if policy.RuleSigningEnabled() { + logger.L().Info("signed rule fragments enabled") + } + if resync { + rulesWatcher.ResyncNow(ctx) + } + } + if !policy.RuleSigningEnabled() { + logger.L().Warning("rule signing DISABLED: the trust policy in force carries no ruleClasses; ANY Rules object in ANY namespace will load without a signature check") + } + if bundle.RuleAdmissionUnauthenticated(policy, rootFp) { + logger.L().Warning("trust policy configures rule signing but the trust anchor is the published demo root key: rule admission is NOT authenticated; mount a real root before relying on signed rules") + } + if ruleBindingCache != nil { + ruleBindingCache.SetTrustPolicy(policy) + if policy.BindingSigningEnabled() { + logger.L().Info("signed rule bindings enabled") + } + if resync { + // Bindings re-check admission on their next binding + // event, not on reload — documented limitation. + logger.L().Info("rule bindings re-evaluate under the reloaded policy on their next binding event") + } + } + if prevPolicy != nil { + ruleOff := prevPolicy.RuleSigningEnabled() && !policy.RuleSigningEnabled() + bindOff := prevPolicy.BindingSigningEnabled() && !policy.BindingSigningEnabled() + modeDown := prevPolicy.Enforcing() && !policy.Enforcing() + if ruleOff || bindOff || modeDown { + logger.L().Warning("trust policy reloaded with REDUCED scope", + helpers.String("ruleSigning", fmt.Sprintf("%v->%v", prevPolicy.RuleSigningEnabled(), policy.RuleSigningEnabled())), + helpers.String("bindingSigning", fmt.Sprintf("%v->%v", prevPolicy.BindingSigningEnabled(), policy.BindingSigningEnabled())), + helpers.String("mode", fmt.Sprintf("%s->%s", prevPolicy.EffectiveMode(), policy.EffectiveMode()))) + } + } + prevPolicy = policy + } + + // The reloader runs whether or not the boot load succeeded: a bad + // mounted policy must not require a restart to recover from, and + // crash-looping instead would hand ConfigMap writers a fleet-wide + // kill switch. An invalid boot policy leaves signing DISABLED + // (scream-and-poll) until a verifiable artifact is mounted. + // Startup uses the same single-read verification path as later reloads. + // The in-force policy and digest therefore describe the same artifact, + // even if kubelet swaps the ConfigMap while startup is running. + reloader := bundle.NewPolicyReloader(cfg.BundleTrustPolicyPath, nil, 0) + boot := reloader.Poll() + if boot.Err != nil { + logger.L().Warning("trust policy invalid at startup: signed bundle overlays DISABLED until a valid policy is mounted; re-checking every reload interval", + helpers.String("path", cfg.BundleTrustPolicyPath), helpers.Error(boot.Err)) + } else if boot.Applied != nil { + applyTrustPolicy(boot.Applied, boot.RootFP, boot.Mounted, false) + bundle.SetInForcePolicy(boot.Applied, boot.ArtifactDigest, boot.RootFP, boot.Mounted) + logger.L().Info("trust policy in force", helpers.String("inForceDigest", boot.ArtifactDigest), helpers.String("mode", string(boot.Applied.EffectiveMode()))) + } + go reloader.Watch(ctx, bundle.DefaultPolicyReloadInterval, func(ev bundle.PolicyReloadEvent) { + if ev.Err != nil { + bundle.RecordPolicyRefusal(ev.ArtifactDigest) + logger.L().Error("trust policy reload REFUSED: keeping the policy already in force", + helpers.String("refusedDigest", ev.ArtifactDigest), + helpers.String("inForceDigest", ev.InForceDigest), + helpers.String("rootFingerprint", ev.RootFP), + helpers.String("hint", "the mounted ConfigMap now contains the REFUSED artifact; what the cluster shows is not what is enforced until a verifiable policy is applied"), + helpers.Error(ev.Err)) + return + } + if ev.Applied == nil { + return + } + applyTrustPolicy(ev.Applied, ev.RootFP, ev.Mounted, true) + bundle.SetInForcePolicy(ev.Applied, ev.ArtifactDigest, ev.RootFP, ev.Mounted) + ruleSigning := "off" + if ev.Applied.RuleSigningEnabled() { + ruleSigning = "on" + } + logger.L().Info("trust policy reloaded without restart", + helpers.String("inForceDigest", ev.ArtifactDigest), + helpers.String("mode", string(ev.Applied.EffectiveMode())), + helpers.String("ruleSigning", ruleSigning), + helpers.Int("ruleClasses", len(ev.Applied.RuleClasses)), + helpers.Int("bindingClasses", len(ev.Applied.BindingClasses))) + }) + } +} diff --git a/cmd/signing_default.go b/cmd/signing_default.go new file mode 100644 index 0000000000..eeea3f68d9 --- /dev/null +++ b/cmd/signing_default.go @@ -0,0 +1,20 @@ +//go:build !experimental_signing + +package main + +import ( + "context" + "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/exporters" + "github.com/kubescape/node-agent/pkg/healthmanager" + "github.com/kubescape/node-agent/pkg/objectcache/containerprofilecache" + rulebindingcachev1 "github.com/kubescape/node-agent/pkg/rulebindingmanager/cache" + "github.com/kubescape/node-agent/pkg/rulemanager/ruleswatcher" +) + +func verifiedClusterDataPath() (string, func(), error) { + return "/etc/config/clusterData.json", func() {}, nil +} +func configureSigningHealth(h *healthmanager.HealthManager) {} +func configureSigning(ctx context.Context, cfg config.Config, cpc *containerprofilecache.ContainerProfileCacheImpl, rulesWatcher *ruleswatcher.RulesWatcherImpl, ruleBindingCache *rulebindingcachev1.RBCache, exporter exporters.Exporter) { +} diff --git a/docs/features/experimental-signing.md b/docs/features/experimental-signing.md new file mode 100644 index 0000000000..b7bf5e5919 --- /dev/null +++ b/docs/features/experimental-signing.md @@ -0,0 +1,79 @@ +# Experimental artifact signing + +Artifact signing is an opt-in build feature. Regular node-agent builds exclude +signature verification, trust-policy reload, signed configuration loading, and +`/policyz`. Setting signing configuration in a regular build does not enable it. + +Build the experimental agent with: + +```sh +make binary BUILD_TAGS=experimental_signing +# Or: +go build -tags experimental_signing -o node-agent ./cmd +make docker-build-only BUILD_TAGS=experimental_signing +``` + +Build the optional signing CLI with `go build -tags experimental_signing -o +sign-object ./cmd/sign-object`. Compile the `./cmd` package rather than the +single `cmd/main.go` file so build-specific startup helpers are included. + +In an experimental build, `enableSignatureVerification` enables legacy signed +profile verification. Set `bundleTrustPolicyPath` to a mounted root-signed trust +policy to configure signed profile bundles, Rules, and rule bindings. The root +anchor comes from `/etc/bundle/root.pub` or the compiled root; the published demo +root is unsuitable for authenticating production artifacts. + +The agent loads `config.signed.json` from the configuration directory and +`/etc/config/clusterData.signed.json` when present. Invalid signed configuration +is rejected instead of falling back to unsigned content. With no signed files, +the existing unsigned configuration paths remain available. + +Signed configuration binds its purpose to its signature. Use +`sign-object sign-config --purpose node-agent-config --file config.json --key root.pem --output config.signed.json` +for agent settings and +`sign-object sign-config --purpose cluster-data --file clusterData.json --key root.pem --output clusterData.signed.json` +for cluster metadata. Each consumer rejects the other purpose even when signed +by the same root. Existing signed configuration artifacts without a purpose must +be re-signed; missing or modified purposes are rejected. + +Trust-policy reload runs periodically. An invalid policy at startup leaves +signing disabled until a valid policy is mounted. A rejected reload preserves +the policy already in force. Rules are resynchronized after policy reload; +bindings re-evaluate on their next binding event. `/policyz` on the health port +reports policy identity and counts, without exposing signer lists or policy +contents, and returns 404 before a policy is in force. + +Flat profiles use the legacy signature check, which accepts self-signed +certificates and detects content changes; it does not authorize the signer +against bundle fragment classes. Use signed bundles with a real trust root when +signer authorization is required. This remains experimental. + +Bundle fragments currently support flat profile specs only. Fragments with +nonempty `containers`, `initContainers`, or `ephemeralContainers` are rejected +before assembly, including when these fields occur in embedded signed content. +Standalone signed grouped profiles remain supported by legacy verification. + +Stored-spec divergence is logged as a diagnostic; it does not emit R1017. Signed +embedded content governs instead of the mutable stored spec. A verified embedded +spec that cannot be decoded is refused in both modes. Signing refusal +clears the previously admitted flat profile and retains its reference for +recovery. Failed bundle reassembly retains a composite only while the policy that verified +it remains in force. Unverified cached content is cleared on assembly failure; +a policy replacement requires fresh verification. While enforcing, source-fetch +errors retain only previously verified profiles or eligible verified composites. +Refresh uses the original pod label to discover bundles and retains the resolved +flat-profile reference for recovery. R1018 is emitted only after a profile is +actually adopted. + +This feature does not require RogueArtifact CRDs or the RogueArtifact writer +from PR #121. R1017 remains owned by that separate work; signing does not register +or emit an R1017 rule. + +Run both build variants when changing this feature: + +```sh +go test ./pkg/config ./pkg/healthmanager ./pkg/objectcache/containerprofilecache ./pkg/rulebindingmanager/cache ./pkg/rulemanager/rulecreator ./pkg/rulemanager/ruleswatcher ./pkg/rulemanager/types/v1 +go test -tags experimental_signing ./pkg/config ./pkg/healthmanager ./pkg/signature/... +go build -o /tmp/node-agent-default ./cmd +go build -tags experimental_signing -o /tmp/node-agent-signing ./cmd +``` diff --git a/go.mod b/go.mod index 55a9999584..daa37c8b59 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb github.com/go-openapi/strfmt v0.27.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/cel-go v0.29.0 github.com/google/go-containerregistry v0.21.2 github.com/google/uuid v1.6.0 @@ -46,6 +47,9 @@ require ( github.com/prometheus/alertmanager v0.27.0 github.com/prometheus/client_golang v1.24.1 github.com/prometheus/procfs v0.22.0 + github.com/sigstore/cosign/v3 v3.0.0 + github.com/sigstore/fulcio v1.7.1 + github.com/sigstore/sigstore v1.10.4 github.com/sirupsen/logrus v1.9.4 github.com/spf13/afero v1.15.0 github.com/spf13/viper v1.21.0 @@ -139,7 +143,7 @@ require ( github.com/aquasecurity/go-version v0.0.1 // indirect github.com/armosec/gojay v1.2.17 // indirect github.com/armosec/utils-go v0.0.58 // indirect - github.com/aws/aws-sdk-go v1.55.7 // indirect + github.com/aws/aws-sdk-go v1.55.8 // indirect github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect @@ -173,6 +177,10 @@ require ( github.com/bodgit/sevenzip v1.6.1 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/briandowns/spinner v1.23.2 // indirect + github.com/buildkite/agent/v3 v3.107.2 // indirect + github.com/buildkite/go-pipeline v0.16.0 // indirect + github.com/buildkite/interpolate v0.1.5 // indirect + github.com/buildkite/roko v1.4.0 // indirect github.com/campoy/embedmd v1.0.0 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect @@ -274,6 +282,7 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/licensecheck v0.3.1 // indirect github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect github.com/google/s2a-go v0.1.9 // indirect @@ -298,7 +307,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jinzhu/copier v0.4.0 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 // indirect github.com/josharian/native v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 // indirect @@ -347,6 +356,7 @@ require ( github.com/nwaples/rardecode v1.1.3 // indirect github.com/nwaples/rardecode/v2 v2.2.0 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect + github.com/oleiade/reflections v1.1.0 // indirect github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/ll v0.0.9 // indirect github.com/olekukonko/tablewriter v1.0.9 // indirect @@ -356,12 +366,14 @@ require ( github.com/opencontainers/selinux v1.13.1 // indirect github.com/packetcap/go-pcap v0.0.0-20250723190045-d00b185f30b7 // indirect github.com/pborman/indent v1.2.1 // indirect + github.com/pborman/uuid v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/profile v1.7.0 // indirect github.com/pkg/xattr v0.4.12 // indirect @@ -370,7 +382,7 @@ require ( github.com/prometheus/client_model v0.6.3 // indirect github.com/prometheus/common v0.72.0 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect - github.com/puzpuzpuz/xsync/v2 v2.4.1 // indirect + github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c // indirect @@ -386,7 +398,6 @@ require ( github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sigstore/protobuf-specs v0.5.0 // indirect - github.com/sigstore/sigstore v1.10.4 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -401,7 +412,9 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/sylabs/sif/v2 v2.24.0 // indirect github.com/sylabs/squashfs v1.0.6 // indirect + github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect github.com/therootcompany/xz v1.0.1 // indirect + github.com/theupdateframework/go-tuf v0.7.0 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/uptrace/opentelemetry-go-extra/otelutil v0.4.0 // indirect github.com/uptrace/opentelemetry-go-extra/otelzap v0.4.0 // indirect diff --git a/go.sum b/go.sum index fd86cf4927..3cc5b458d9 100644 --- a/go.sum +++ b/go.sum @@ -213,8 +213,8 @@ github.com/armosec/utils-k8s-go v0.0.35 h1:CliNObhAca5UYl84m5OQecOTm9ZfMFI8648pY github.com/armosec/utils-k8s-go v0.0.35/go.mod h1:iHwR/KhMFtdd8Px1oYexLZYOHqmdknfGTZ8b7sZS0Ms= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= -github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= +github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= @@ -288,6 +288,14 @@ github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1l github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/buildkite/agent/v3 v3.107.2 h1:f04MkWVwSro9iwzCgfPsMsKEIY3Pm0TGp6M8dP3lQgk= +github.com/buildkite/agent/v3 v3.107.2/go.mod h1:6m3kUdqUKwcJDx8J/8w/eX5df73ak7zKfHR0uYpgFag= +github.com/buildkite/go-pipeline v0.16.0 h1:wEgWUMRAgSg1ZnWOoA3AovtYYdTvN0dLY1zwUWmPP+4= +github.com/buildkite/go-pipeline v0.16.0/go.mod h1:VE37qY3X5pmAKKUMoDZvPsHOQuyakB9cmXj9Qn6QasA= +github.com/buildkite/interpolate v0.1.5 h1:v2Ji3voik69UZlbfoqzx+qfcsOKLA61nHdU79VV+tPU= +github.com/buildkite/interpolate v0.1.5/go.mod h1:dHnrwHew5O8VNOAgMDpwRlFnhL5VSN6M1bHVmRZ9Ccc= +github.com/buildkite/roko v1.4.0 h1:DxixoCdpNqxu4/1lXrXbfsKbJSd7r1qoxtef/TT2J80= +github.com/buildkite/roko v1.4.0/go.mod h1:0vbODqUFEcVf4v2xVXRfZZRsqJVsCCHTG/TBRByGK4E= github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= @@ -495,7 +503,9 @@ github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -607,8 +617,12 @@ github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5 github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/go-restruct/restruct v1.2.0-alpha h1:2Lp474S/9660+SJjpVxoKuWX09JsXHSrdV7Nv3/gkvc= github.com/go-restruct/restruct v1.2.0-alpha/go.mod h1:KqrpKpn4M8OLznErihXTGLlsXFGeLxHUrLRRI/1YjGk= +github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= +github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= @@ -633,6 +647,8 @@ github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxU github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -672,6 +688,7 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= @@ -703,7 +720,11 @@ github.com/google/go-containerregistry v0.21.2 h1:vYaMU4nU55JJGFC9JR/s8NZcTjbE9D github.com/google/go-containerregistry v0.21.2/go.mod h1:ctO5aCaewH4AK1AumSF5DPW+0+R+d2FmylMJdp5G7p0= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/licensecheck v0.3.1 h1:QoxgoDkaeC4nFrtGN1jV7IPmDCHFNIVh54e5hSt6sPs= github.com/google/licensecheck v0.3.1/go.mod h1:ORkR35t/JjW+emNKtfJDII0zlciG9JgbT7SmsohlHmY= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= @@ -724,6 +745,7 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= @@ -734,6 +756,7 @@ github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxV github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -820,6 +843,7 @@ github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= @@ -857,8 +881,8 @@ github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a8 github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joncrlsn/dque v0.0.0-20241024143830-7723fd131a64 h1:fmH2K7R8pZJ0wVvJyGFmDnECuAE3NLjfAoJkN9mtfc8= @@ -1084,8 +1108,13 @@ github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9l github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/oleiade/reflections v1.1.0 h1:D+I/UsXQB4esMathlt0kkZRJZdUDmhv5zGi/HOwYTWo= +github.com/oleiade/reflections v1.1.0/go.mod h1:mCxx0QseeVCHs5Um5HhJeCKVC7AwS8kO67tky4rdisA= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= @@ -1094,9 +1123,18 @@ github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTH github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= github.com/olvrng/ujson v1.1.0 h1:8xVUzVlqwdMVWh5d1UHBtLQ1D50nxoPuPEq9Wozs8oA= github.com/olvrng/ujson v1.1.0/go.mod h1:Mz4G3RODTUfbkKyvi0lgmPx/7vd3Saksk+1jgk8s9xo= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/opcoder0/capabilities v0.0.0-20221222060822-17fd73bffd2a h1:sbMMqulR2c6d2aeqOg5kzWv87unK0O4V78Dl1+YG4ys= @@ -1124,6 +1162,8 @@ github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144T github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pborman/indent v1.2.1 h1:lFiviAbISHv3Rf0jcuh489bi06hj98JsVMtIDZQb9yM= github.com/pborman/indent v1.2.1/go.mod h1:FitS+t35kIYtB5xWTZAPhnmrxcciEEOdbyrrpz5K6Vw= +github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= @@ -1140,6 +1180,8 @@ github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -1183,8 +1225,8 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= -github.com/puzpuzpuz/xsync/v2 v2.4.1 h1:aGdE1C/HaR/QC6YAFdtZXi60Df8/qBIrs8PKrzkItcM= -github.com/puzpuzpuz/xsync/v2 v2.4.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU= +github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= +github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -1256,6 +1298,10 @@ github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1l github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= +github.com/sigstore/cosign/v3 v3.0.0 h1:xwBJLIUAFHkN+IZG2iMXQFk/C7W1MGqH7WacwvG5AAY= +github.com/sigstore/cosign/v3 v3.0.0/go.mod h1:eVpNV8ub+kTPS+vWporZcj6560r8QttqFK1MQPNojMc= +github.com/sigstore/fulcio v1.7.1 h1:RcoW20Nz49IGeZyu3y9QYhyyV3ZKQ85T+FXPKkvE+aQ= +github.com/sigstore/fulcio v1.7.1/go.mod h1:7lYY+hsd8Dt+IvKQRC+KEhWpCZ/GlmNvwIa5JhypMS8= github.com/sigstore/protobuf-specs v0.5.0 h1:F8YTI65xOHw70NrvPwJ5PhAzsvTnuJMGLkA4FIkofAY= github.com/sigstore/protobuf-specs v0.5.0/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= github.com/sigstore/sigstore v1.10.4 h1:ytOmxMgLdcUed3w1SbbZOgcxqwMG61lh1TmZLN+WeZE= @@ -1313,6 +1359,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -1328,11 +1375,15 @@ github.com/sylabs/sif/v2 v2.24.0 h1:1wB5uMDUQYjk8AckTySaDcP9YnpMb1LyDRr1Jt9A10w= github.com/sylabs/sif/v2 v2.24.0/go.mod h1:DbXWqWZ1hdLSU+K9ipdds5AmZeHWsyxCOj/oQakBa88= github.com/sylabs/squashfs v1.0.6 h1:PvJcDzxr+vIm2kH56mEMbaOzvGu79gK7P7IX+R7BDZI= github.com/sylabs/squashfs v1.0.6/go.mod h1:DlDeUawVXLWAsSRa085Eo0ZenGzAB32JdAUFaB0LZfE= +github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d h1:vfofYNRScrDdvS342BElfbETmL1Aiz3i2t0zfRj16Hs= +github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= github.com/terminalstatic/go-xsd-validate v0.1.6 h1:TenYeQ3eY631qNi1/cTmLH/s2slHPRKTTHT+XSHkepo= github.com/terminalstatic/go-xsd-validate v0.1.6/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw= github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= @@ -1383,6 +1434,16 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= +github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= +github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1586,6 +1647,7 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= @@ -1602,6 +1664,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -1609,6 +1672,8 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -1680,11 +1745,14 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1712,6 +1780,7 @@ golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201218084310-7d0127a74742/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210110051926-789bb1bd4061/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210123111255-9b0068b26619/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1744,9 +1813,11 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1840,6 +1911,7 @@ golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82u golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= @@ -1858,6 +1930,7 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -2041,11 +2114,14 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/mcuadros/go-syslog.v2 v2.3.0 h1:kcsiS+WsTKyIEPABJBJtoG0KkOS6yzvJ+/eZlhD79kk= gopkg.in/mcuadros/go-syslog.v2 v2.3.0/go.mod h1:l5LPIyOOyIdQquNg+oU6Z3524YwrcqEm0aKH+5zpt2U= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -2054,6 +2130,7 @@ gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/config/config.go b/pkg/config/config.go index ebaf399804..52e1efabcc 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,7 +1,6 @@ package config import ( - "errors" "fmt" "os" "slices" @@ -85,6 +84,9 @@ func NormalizeDNSSuffix(s string) string { } type Config struct { + // Signing options are effective only in experimental_signing builds. + EnableSignatureVerification bool `mapstructure:"enableSignatureVerification"` + BundleTrustPolicyPath string `mapstructure:"bundleTrustPolicyPath"` BlockEvents bool `mapstructure:"blockEvents"` CelConfigCache cache.FunctionCacheConfig `mapstructure:"celConfigCache"` ContainerEolNotificationBuffer int `mapstructure:"containerEolNotificationBuffer"` @@ -324,11 +326,8 @@ func LoadConfigOptional(path string, errNotFound bool) (Config, error) { viper.AutomaticEnv() - if err := viper.ReadInConfig(); err != nil { - var notFound viper.ConfigFileNotFoundError - if !errors.As(err, ¬Found) || errNotFound { - return Config{}, err - } + if err := readConfig(path, errNotFound); err != nil { + return Config{}, err } var config Config diff --git a/pkg/config/config_default.go b/pkg/config/config_default.go new file mode 100644 index 0000000000..96ad77c05d --- /dev/null +++ b/pkg/config/config_default.go @@ -0,0 +1,19 @@ +//go:build !experimental_signing + +package config + +import ( + "errors" + + "github.com/spf13/viper" +) + +func readConfig(_ string, errNotFound bool) error { + if err := viper.ReadInConfig(); err != nil { + var notFound viper.ConfigFileNotFoundError + if !errors.As(err, ¬Found) || errNotFound { + return err + } + } + return nil +} diff --git a/pkg/config/config_default_test.go b/pkg/config/config_default_test.go new file mode 100644 index 0000000000..b3324e5159 --- /dev/null +++ b/pkg/config/config_default_test.go @@ -0,0 +1,23 @@ +//go:build !experimental_signing + +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestDefaultBuildIgnoresSignedConfig(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"nodeName":"unsigned"}`), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.signed.json"), []byte(`{}`), 0600)) + cfg, err := LoadConfig(dir) + require.NoError(t, err) + require.Equal(t, "unsigned", cfg.NodeName) +} diff --git a/pkg/config/config_signing.go b/pkg/config/config_signing.go new file mode 100644 index 0000000000..cd810e78ba --- /dev/null +++ b/pkg/config/config_signing.go @@ -0,0 +1,51 @@ +//go:build experimental_signing + +package config + +import ( + "bytes" + "errors" + "fmt" + "path/filepath" + + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/spf13/viper" +) + +const SignedConfigFileName = "config.signed.json" + +func readSignedConfig(dir string) ([]byte, bool, error) { + signedPath := filepath.Join(dir, SignedConfigFileName) + verified, present, err := bundle.LoadSignedConfigIfPresent(signedPath, bundle.NodeAgentConfigPurpose) + if err != nil { + logger.L().Warning("refusing tampered or wrongly-signed node-agent config", + helpers.String("path", signedPath), + helpers.Error(err)) + return nil, true, fmt.Errorf("refused signed config %q: %w", signedPath, err) + } + if present { + logger.L().Info("loaded root-signed node-agent config", helpers.String("path", signedPath)) + } + return verified, present, nil +} + +func readConfig(path string, errNotFound bool) error { + viper.SetDefault("enableSignatureVerification", false) + viper.SetDefault("bundleTrustPolicyPath", "") + verified, signed, err := readSignedConfig(path) + if err != nil { + return err + } + if signed { + return viper.ReadConfig(bytes.NewReader(verified)) + } + if err := viper.ReadInConfig(); err != nil { + var notFound viper.ConfigFileNotFoundError + if !errors.As(err, ¬Found) || errNotFound { + return err + } + } + return nil +} diff --git a/pkg/config/config_signing_test.go b/pkg/config/config_signing_test.go new file mode 100644 index 0000000000..1f3b8fdca3 --- /dev/null +++ b/pkg/config/config_signing_test.go @@ -0,0 +1,32 @@ +//go:build experimental_signing + +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestSignedConfigRejectsInvalidArtifactBeforeUnsignedFallback(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"nodeName":"unsigned"}`), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, SignedConfigFileName), []byte(`{}`), 0600)) + _, err := LoadConfig(dir) + require.ErrorContains(t, err, "refused signed config") +} + +func TestBundleTrustPolicyPathFromEnvironmentOnly(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + path := filepath.Join(t.TempDir(), "policy.signed.json") + t.Setenv("BUNDLETRUSTPOLICYPATH", path) + cfg, err := LoadConfigOptional(t.TempDir(), false) + require.NoError(t, err) + require.Equal(t, path, cfg.BundleTrustPolicyPath) +} diff --git a/pkg/healthmanager/health_default.go b/pkg/healthmanager/health_default.go new file mode 100644 index 0000000000..a7d7bd2918 --- /dev/null +++ b/pkg/healthmanager/health_default.go @@ -0,0 +1,7 @@ +//go:build !experimental_signing + +package healthmanager + +type policyHealth struct{} + +func (h *HealthManager) registerPolicyHandler() {} diff --git a/pkg/healthmanager/health_manager.go b/pkg/healthmanager/health_manager.go index 4ed933ed36..7e05c9f509 100644 --- a/pkg/healthmanager/health_manager.go +++ b/pkg/healthmanager/health_manager.go @@ -13,6 +13,7 @@ import ( ) type HealthManager struct { + policyHealth containerWatcher containerwatcher.ContainerWatcher port int } @@ -31,6 +32,7 @@ func (h *HealthManager) Start(ctx context.Context) { go func() { http.HandleFunc("/livez", h.livenessProbe) http.HandleFunc("/readyz", h.readinessProbe) + h.registerPolicyHandler() srv := &http.Server{ Addr: fmt.Sprintf(":%d", h.port), WriteTimeout: 15 * time.Second, diff --git a/pkg/healthmanager/health_signing.go b/pkg/healthmanager/health_signing.go new file mode 100644 index 0000000000..1549a1d937 --- /dev/null +++ b/pkg/healthmanager/health_signing.go @@ -0,0 +1,31 @@ +//go:build experimental_signing + +package healthmanager + +import ( + "encoding/json" + "net/http" + + "github.com/kubescape/node-agent/pkg/signature/bundle" +) + +type policyHealth struct{ policyStatus func() *bundle.PolicyStatus } + +func (h *HealthManager) registerPolicyHandler() { http.HandleFunc("/policyz", h.policyzHandler) } +func (h *HealthManager) SetPolicyStatus(getter func() *bundle.PolicyStatus) { + h.policyStatus = getter +} + +func (h *HealthManager) policyzHandler(w http.ResponseWriter, _ *http.Request) { + if h.policyStatus == nil { + http.NotFound(w, nil) + return + } + s := h.policyStatus() + if s == nil { + http.NotFound(w, nil) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s) +} diff --git a/pkg/healthmanager/policyz_test.go b/pkg/healthmanager/policyz_test.go new file mode 100644 index 0000000000..72e64c37e0 --- /dev/null +++ b/pkg/healthmanager/policyz_test.go @@ -0,0 +1,53 @@ +//go:build experimental_signing + +package healthmanager + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/stretchr/testify/require" +) + +func TestPolicyz_NotFoundWhenUnset(t *testing.T) { + h := NewHealthManager() + rec := httptest.NewRecorder() + h.policyzHandler(rec, nil) + require.Equal(t, 404, rec.Code) + + h.SetPolicyStatus(func() *bundle.PolicyStatus { return nil }) + rec = httptest.NewRecorder() + h.policyzHandler(rec, nil) + require.Equal(t, 404, rec.Code, "nil snapshot must 404, not serve an empty status") +} + +func TestPolicyz_ServesIdentityWithoutPolicyBody(t *testing.T) { + h := NewHealthManager() + h.SetPolicyStatus(func() *bundle.PolicyStatus { + return &bundle.PolicyStatus{ + InForceDigest: "abc123", + Mode: "enforce", + RuleClassCount: 2, + RulesAdmitted: 1, + RulesRejected: 3, + } + }) + rec := httptest.NewRecorder() + h.policyzHandler(rec, nil) + require.Equal(t, 200, rec.Code) + + var got map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, "abc123", got["inForceDigest"]) + require.Equal(t, "enforce", got["mode"]) + require.EqualValues(t, 3, got["rulesRejected"]) + + body := rec.Body.String() + for _, forbidden := range []string{"signers", "allowedRuleIDs", "allowedSpecPaths", "classes\""} { + require.False(t, strings.Contains(body, forbidden), + "policyz must not leak policy contents, found %q in %s", forbidden, body) + } +} diff --git a/pkg/objectcache/containerprofilecache/bundle_fragment_cap_test.go b/pkg/objectcache/containerprofilecache/bundle_fragment_cap_test.go new file mode 100644 index 0000000000..e7917324f7 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/bundle_fragment_cap_test.go @@ -0,0 +1,63 @@ +//go:build experimental_signing + +package containerprofilecache + +import ( + "fmt" + "testing" + + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/node-agent/pkg/storage" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" +) + +// W8: the per-bundle fragment cap — maxBundleFragments verified members +// assemble and enforce; ONE fragment over the cap fails the WHOLE bundle +// closed (assembly aborts, nothing is enforced from a stack an attacker can +// inflate). Multi-stacking has a hard, tested ceiling. +func TestBundleFragmentCap_BoundaryAndOverflow(t *testing.T) { + vendor := bkey(t) + + build := func(n int) []*v1beta1.ContainerProfile { + frags := make([]*v1beta1.ContainerProfile, 0, n) + for i := 0; i < n; i++ { + frags = append(frags, bfragIn(t, fmt.Sprintf("cap-frag-%03d", i), "base", "default", "capped", + v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: fmt.Sprintf("/bin/tool-%03d", i)}}, + }, vendor)) + } + return frags + } + policyFor := func(frag *v1beta1.ContainerProfile) *bundle.TrustPolicy { + id, err := bundle.SignerID(frag) + require.NoError(t, err) + return &bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{id}, AllowedSpecPaths: []string{"execs"}}, + }} + } + + t.Run("at_the_cap_the_stack_enforces", func(t *testing.T) { + frags := build(maxBundleFragments) + mock := &storage.StorageHttpClientMock{ContainerProfiles: frags} + c, k8s := newTestCache(t, mock) + c.SetBundleConfig(policyFor(frags[0])) + primeSharedData(t, k8s, "container-cap-ok", "wlid://cluster-a/namespace-default/deployment-cap") + bindToProfile(t, c, "container-cap-ok", "capped") + require.True(t, execEnforced(c, "container-cap-ok", "/bin/tool-000"), + "a stack of exactly maxBundleFragments members must assemble and enforce") + require.True(t, execEnforced(c, "container-cap-ok", fmt.Sprintf("/bin/tool-%03d", maxBundleFragments-1)), + "the last member's grant must be part of the union") + }) + + t.Run("one_over_the_cap_fails_the_whole_bundle_closed", func(t *testing.T) { + frags := build(maxBundleFragments + 1) + mock := &storage.StorageHttpClientMock{ContainerProfiles: frags} + c, k8s := newTestCache(t, mock) + c.SetBundleConfig(policyFor(frags[0])) + primeSharedData(t, k8s, "container-cap-over", "wlid://cluster-a/namespace-default/deployment-cap") + bindToProfile(t, c, "container-cap-over", "capped") + require.False(t, execEnforced(c, "container-cap-over", "/bin/tool-000"), + "an inflated stack must not enforce ANY member — the cap fails the bundle closed") + }) +} diff --git a/pkg/objectcache/containerprofilecache/bundle_overlay_signing.go b/pkg/objectcache/containerprofilecache/bundle_overlay_signing.go new file mode 100644 index 0000000000..e3bf2bcf98 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/bundle_overlay_signing.go @@ -0,0 +1,306 @@ +//go:build experimental_signing + +package containerprofilecache + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// SetBundleConfig enables signed-bundle overlays. node-agent only needs the +// trust policy (the public-key fingerprints per fragment class): it VERIFIES +// fragment signatures, it never signs. No private key exists on the cluster. +// When the trust policy is set bundle assembly runs; otherwise the cache uses +// the single-CP overlay path. Wired from cmd/main.go after config load. +// maxBundleFragments caps the fragments assembled per bundle per reconcile tick. +const maxBundleFragments = 64 + +func (c *ContainerProfileCacheImpl) SetBundleConfig(policy *bundle.TrustPolicy) { + c.bundleTrustPolicy.Store(policy) +} + +func (c *ContainerProfileCacheImpl) bundlesEnabled() bool { + return c.bundleTrustPolicy.Load() != nil +} + +// signingEnforced reports whether unsigned/unverifiable user objects must be +// refused. Enforce mode enforces; the legacy requireSignedObjects flag still +// enforces for deployments that set it. +func (c *ContainerProfileCacheImpl) signingEnforced() bool { + if p := c.bundleTrustPolicy.Load(); p != nil && p.Enforcing() { + return true + } + return c.cfg.EnableSignatureVerification +} + +// assembleUserBundle resolves a user-defined-profile label that names a signed +// bundle: it lists the bundle's fragments, verifies each against the trust +// policy, and deterministically assembles the admissible fragments into an +// in-memory composite. The composite is assembled from the just-verified +// fragments and is trusted in-process by the reconciler (no on-cluster +// signing). +// +// Returns: +// - (composite, nil) when a bundle was assembled — use it as the overlay; +// - (nil, nil) when bundles are disabled, or no class-labeled fragments +// exist for this name (fall back to the single-CP path); +// - (nil, err) when fragments exist but fail verification/assembly. A +// tampered fragment additionally raises R1016 (deduped). +func (c *ContainerProfileCacheImpl) assembleUserBundle(ctx context.Context, ns, bundleName, wlid string) (*v1beta1.ContainerProfile, error) { + return c.assembleUserBundleWithPolicy(ctx, ns, bundleName, wlid, c.bundleTrustPolicy.Load()) +} + +func (c *ContainerProfileCacheImpl) assembleUserBundleWithPolicy(ctx context.Context, ns, bundleName, wlid string, policy *bundle.TrustPolicy) (*v1beta1.ContainerProfile, error) { + if policy == nil || bundleName == "" { + return nil, nil + } + + var ( + list *v1beta1.ContainerProfileList + lerr error + ) + _ = c.refreshRPC(ctx, func(rctx context.Context) error { + lister, ok := c.storageClient.(interface { + ListContainerProfiles(context.Context, string, metav1.ListOptions) (*v1beta1.ContainerProfileList, error) + }) + if !ok { + lerr = fmt.Errorf("profile client does not support signed bundle listing") + return lerr + } + list, lerr = lister.ListContainerProfiles(rctx, ns, metav1.ListOptions{ + LabelSelector: bundle.LabelBundle + "=" + bundleName, + }) + return lerr + }) + if lerr != nil || list == nil { + return nil, lerr + } + + // The storage server's List serves items from its metadata table WITHOUT + // loading the payload — the returned objects are spec-stripped. Hashing those + // would flag every signed fragment as tampered. Use the List only to discover + // the fragment set (names + labels round-trip fine) and GET each fragment for + // its full spec. + // + // The storage server also IGNORES the List label selector, returning every + // CP in the namespace — so the bundle membership MUST be re-checked here. + // Without this, any user-defined-profile name would assemble ALL fragments + // in the namespace (observed live: a client workload's profile lookup + // assembled the server's fragments). + var frags []*v1beta1.ContainerProfile + for i := range list.Items { + item := &list.Items[i] + if item.Labels[bundle.LabelBundle] != bundleName { + continue + } + if _, ok := item.Labels[bundle.LabelFragmentClass]; !ok { + continue + } + // Bound the fragment count: assembly runs every reconcile tick (cosign + // verify + gunzip + JSON per fragment), so an attacker who can create CPs + // in the namespace could otherwise drive per-tick CPU/RPC without limit. + if len(frags) >= maxBundleFragments { + return nil, fmt.Errorf("bundle %q exceeds %d fragments", bundleName, maxBundleFragments) + } + var ( + full *v1beta1.ContainerProfile + gerr error + ) + _ = c.refreshRPC(ctx, func(rctx context.Context) error { + full, gerr = c.storageClient.GetContainerProfile(rctx, ns, item.Name) + return gerr + }) + if gerr != nil || full == nil { + // Transient fetch failure: fail this assembly attempt as operational + // (NOT tamper — no R1016); the caller retries on the next tick. + return nil, fmt.Errorf("fetch bundle fragment %q: %w", item.Name, gerr) + } + frags = append(frags, full) + } + if len(frags) == 0 { + return nil, nil // not a bundle — single-CP path handles it + } + for _, f := range frags { + c.reportSpecDivergence("bundle/"+ns+"/"+bundleName+"/"+f.Name, f, bundleName, ns) + } + + bundleKey := ns + "/" + bundleName + composite, manifest, dropped, err := bundle.AssembleAndVerifyPartial(bundleName, ns, frags, *policy) + + // Invariant: only a slot that was ever an admitted member (present in + // bundleVersions) can be a genuine tamper — a non-verifying object's cert is + // attacker-suppliable. Never-admitted drop = non-member, skip. Known slot now + // failing = member tamper, fail closed. + var knownBad []bundle.DroppedFragment + nonMembers := 0 + for _, d := range dropped { + slot := ns + "/" + bundleName + "/" + string(d.Class) + "/" + d.Name + if _, known := c.bundleVersions.Load(slot); known { + knownBad = append(knownBad, d) + } else { + nonMembers++ + } + } + + if len(knownBad) > 0 { + tamper := false + for _, d := range knownBad { + if errors.Is(d.Reason, bundle.ErrFragmentTampered) { + tamper = true + } + } + if tamper { + // Edge-triggered per bundle: alert once per observed clean→tamper + // transition, re-arm on the next clean assembly (below). + if _, was := c.bundleTampered.LoadOrStore(bundleKey, struct{}{}); !was { + c.emitTamperAlert(bundleName, ns, wlid, "ContainerProfile bundle", knownBad[0].Reason) + } + } + names := make([]string, 0, len(knownBad)) + for _, d := range knownBad { + names = append(names, d.Name) + } + logger.L().Warning("signed bundle overlay refused: a verified member no longer verifies; keeping the last verified composite", + helpers.String("bundle", bundleName), + helpers.String("namespace", ns), + helpers.String("members", strings.Join(names, ",")), + helpers.Error(knownBad[0].Reason)) + return nil, knownBad[0].Reason + } + + // Non-members are dropped, not fatal. Dedup the alert per BUNDLE (never per + // object), so a spamming writer cannot flood. + if nonMembers > 0 { + if _, was := c.bundleNonMembers.LoadOrStore(bundleKey, struct{}{}); !was { + logger.L().Warning("signed bundle overlay: dropped non-member object(s) labelled into the bundle by an unauthorised writer", + helpers.String("bundle", bundleName), + helpers.String("namespace", ns), + helpers.Int("dropped", nonMembers)) + } + } else { + c.bundleNonMembers.Delete(bundleKey) + } + + if err != nil { + // Nothing admissible remained (ErrEmptyBundle) or an internal assembly + // error — not a member-tamper, so no R1016. + logger.L().Warning("signed bundle overlay not assembled", + helpers.String("bundle", bundleName), + helpers.String("namespace", ns), + helpers.Int("fragments", len(frags)), + helpers.Error(err)) + return nil, err + } + // Rollback guard: a replayed older-but-valid fragment is refused (not a + // tamper, no R1016), keeping the last verified composite. + if verr := c.checkAndAdvanceVersions(ns, bundleName, manifest.Leaves); verr != nil { + logger.L().Warning("signed bundle overlay refused: fragment rollback", + helpers.String("bundle", bundleName), + helpers.String("namespace", ns), + helpers.Error(verr)) + return nil, verr + } + + // A clean assembly re-arms the edge-trigger so a later tamper alerts again. + c.bundleTampered.Delete(bundleKey) + + // The composite is in-memory only (never stored), so it has no server + // ResourceVersion. Use the Merkle root as its RV: it is a stable content hash + // of the admissible leaf set, so the reconciler's RV fast-skip naturally + // skips rebuilds while the fragments are unchanged and rebuilds when any + // fragment (and hence the root) changes. + composite.ResourceVersion = manifest.Root + if composite.Annotations == nil { + composite.Annotations = map[string]string{} + } + composite.Annotations[helpersv1.SyncChecksumMetadataKey] = manifest.Root + + // The composite is NOT signed on-cluster: node-agent only verifies fragment + // signatures (offline vendor/operator keys). The composite is verified by + // construction from the just-verified fragments and is trusted in-process by + // the reconciler (it bypasses the flat verifyUserContainerProfile path). + + // Log root TRANSITIONS at Info (first assembly, fragment-set change) so the + // bundle lifecycle is visible at default log level without drowning the log + // in per-tick lines; unchanged re-assemblies stay at Debug. + rootKey := ns + "/" + bundleName + if prev, _ := c.bundleRoots.Load(rootKey); prev != manifest.Root { + c.bundleRoots.Store(rootKey, manifest.Root) + logger.L().Info("assembled signed bundle overlay", + helpers.String("bundle", bundleName), + helpers.String("namespace", ns), + helpers.Int("fragments", len(frags)), + helpers.String("root", manifest.Root)) + c.reportShadowedProfile(ctx, ns, bundleName, len(frags)) + } else { + logger.L().Debug("assembled signed bundle overlay", + helpers.String("bundle", bundleName), + helpers.String("namespace", ns), + helpers.Int("fragments", len(frags)), + helpers.String("root", manifest.Root)) + } + return composite, nil +} + +// reportShadowedProfile makes outcome 2 of user-defined-profile resolution +// visible: a verifying bundle shadows the identically named classical +// ContainerProfile, which is otherwise silent — kubectl shows both objects +// with nothing indicating which is in force. Runs only on root transitions +// (the caller's gate), so steady state costs no storage reads. A same-named +// object that is itself a bundle member is not shadowed. Observability only: +// lookup errors report nothing. +func (c *ContainerProfileCacheImpl) reportShadowedProfile(ctx context.Context, ns, bundleName string, fragments int) { + var shadowed *v1beta1.ContainerProfile + _ = c.refreshRPC(ctx, func(rctx context.Context) error { + cp, err := c.storageClient.GetContainerProfile(rctx, ns, bundleName) + if err == nil { + shadowed = cp + } + return nil + }) + if shadowed == nil || shadowed.Labels[bundle.LabelBundle] == bundleName { + return + } + logger.L().Info("signed bundle overlay shadows a ContainerProfile of the same name: the bundle is enforced, the named profile is not", + helpers.String("bundle", bundleName), + helpers.String("namespace", ns), + helpers.String("profile", bundleName), + helpers.Int("fragments", fragments)) +} + +// checkAndAdvanceVersions enforces per-slot monotonic versions, slot = +// (namespace, bundle, class, name). A rollback anywhere rejects the whole +// assembly before any high-water-mark advances. Marks are in-memory and reset on +// restart (a rollback right after restart is not caught). +func (c *ContainerProfileCacheImpl) checkAndAdvanceVersions(ns, bundleName string, leaves []bundle.LeafRef) error { + slot := func(lf bundle.LeafRef) string { + return ns + "/" + bundleName + "/" + string(lf.Class) + "/" + lf.Name + } + for _, lf := range leaves { + if prev, ok := c.bundleVersions.Load(slot(lf)); ok && lf.Version < prev.(int64) { + return fmt.Errorf("%w: %s version %d < accepted %d", bundle.ErrFragmentRollback, slot(lf), lf.Version, prev.(int64)) + } + } + for _, lf := range leaves { + key := slot(lf) + for { + prev, loaded := c.bundleVersions.LoadOrStore(key, lf.Version) + if !loaded || lf.Version <= prev.(int64) { + break + } + if c.bundleVersions.CompareAndSwap(key, prev, lf.Version) { + break + } + } + } + return nil +} diff --git a/pkg/objectcache/containerprofilecache/bundle_overlay_test.go b/pkg/objectcache/containerprofilecache/bundle_overlay_test.go new file mode 100644 index 0000000000..4efd39aa3e --- /dev/null +++ b/pkg/objectcache/containerprofilecache/bundle_overlay_test.go @@ -0,0 +1,637 @@ +//go:build experimental_signing + +package containerprofilecache + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + "time" + + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/node-agent/pkg/storage" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func bkey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return k +} + +func bfrag(t *testing.T, name, class string, spec v1beta1.ContainerProfileSpec, key *ecdsa.PrivateKey) *v1beta1.ContainerProfile { + t.Helper() + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "redis", + Labels: map[string]string{ + bundle.LabelBundle: "redis", + bundle.LabelFragmentClass: class, + }, + }, + Spec: spec, + } + if err := signature.SignObject(profiles.NewContainerProfileAdapter(cp), signature.WithPrivateKey(key)); err != nil { + t.Fatalf("sign %s: %v", name, err) + } + return cp +} + +func newBundleCacheClient(mock storage.ProfileClient, policy *bundle.TrustPolicy, strict bool) *ContainerProfileCacheImpl { + c := &ContainerProfileCacheImpl{ + storageClient: mock, + rpcBudget: time.Second, + cfg: config.Config{EnableSignatureVerification: strict}, + } + c.SetBundleConfig(policy) + return c +} + +// end-to-end runtime path: list fragments from the (mock) store, verify each +// fragment, assemble the composite in-memory, and hand back a usable composite. +// node-agent does NOT sign the composite — it only verifies fragments. +func TestAssembleUserBundle_Runtime_HappyPath(t *testing.T) { + vendor, operator := bkey(t), bkey(t) + + port := int32(6379) + base := bfrag(t, "redis", "base", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/opt/bitnami/redis/bin/redis-server", Args: []string{"redis-server"}}}, + }, vendor) + adm := bfrag(t, "redis-ingress-client", "admission", v1beta1.ContainerProfileSpec{ + Ingress: []v1beta1.NetworkNeighbor{{ + Identifier: "allowed-client", + Type: "internal", + PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "redis-client"}}, + Ports: []v1beta1.NetworkPort{{Name: "TCP-6379", Port: &port, Protocol: "TCP"}}, + }}, + }, operator) + + vendorID, err := bundle.SignerID(base) + if err != nil { + t.Fatal(err) + } + operatorID, err := bundle.SignerID(adm) + if err != nil { + t.Fatal(err) + } + policy := &bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{vendorID}, AllowedSpecPaths: []string{"execs"}}, + bundle.ClassAdmission: {Signers: []string{operatorID}, AllowedSpecPaths: []string{"ingress"}}, + }} + + mock := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{base, adm}} + c := newBundleCacheClient(mock, policy, true) + + composite, err := c.assembleUserBundle(context.Background(), "redis", "redis", "wlid://c/cluster/redis/Pod/redis") + if err != nil { + t.Fatalf("assembleUserBundle: %v", err) + } + if composite == nil { + t.Fatal("expected a composite, got nil") + } + if len(composite.Spec.Execs) != 1 || len(composite.Spec.Ingress) != 1 { + t.Errorf("composite not assembled: execs=%d ingress=%d", len(composite.Spec.Execs), len(composite.Spec.Ingress)) + } + // The composite is NOT signed on-cluster (node-agent only verifies fragments): + // it is verified by construction and carries the assembled spec directly. + // The composite's ResourceVersion is the bundle's Merkle root (stable content + // hash) so the reconciler RV fast-skip works; re-assembly of unchanged + // fragments must yield the same RV. + if composite.ResourceVersion == "" { + t.Error("composite.ResourceVersion must carry the bundle Merkle root") + } + again, err := c.assembleUserBundle(context.Background(), "redis", "redis", "wlid://c/cluster/redis/Pod/redis") + if err != nil || again == nil { + t.Fatalf("re-assembly failed: %v", err) + } + if again.ResourceVersion != composite.ResourceVersion { + t.Errorf("re-assembly of unchanged fragments changed the root RV: %s vs %s", again.ResourceVersion, composite.ResourceVersion) + } +} + +// A single (non-fragment) user CP → assembleUserBundle returns (nil,nil) so the +// caller falls back to the single-CP path. +func TestAssembleUserBundle_Runtime_NotABundle(t *testing.T) { + single := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "redis", Namespace: "redis"}, // no bundle/class labels + } + mock := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{single}} + c := newBundleCacheClient(mock, &bundle.TrustPolicy{}, false) + + composite, err := c.assembleUserBundle(context.Background(), "redis", "redis", "wlid") + if err != nil || composite != nil { + t.Errorf("want (nil,nil) for a non-bundle; got (%v,%v)", composite, err) + } +} + +// A tampered fragment → error + R1016 emitted. +func TestAssembleUserBundle_Runtime_TamperedEmitsR1016(t *testing.T) { + vendor, operator := bkey(t), bkey(t) + base := bfrag(t, "redis", "base", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/redis-server", Args: []string{"redis-server"}}}, + }, vendor) + adm := bfrag(t, "redis-ingress-client", "admission", v1beta1.ContainerProfileSpec{ + Ingress: []v1beta1.NetworkNeighbor{{Identifier: "allowed-client", Type: "internal"}}, + }, operator) + vendorID, _ := bundle.SignerID(base) + operatorID, _ := bundle.SignerID(adm) + policy := &bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{vendorID}, AllowedSpecPaths: []string{"execs"}}, + bundle.ClassAdmission: {Signers: []string{operatorID}, AllowedSpecPaths: []string{"ingress"}}, + }} + + mock := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{base, adm}} + exporter := &captureExporter{} + c := newBundleCacheClient(mock, policy, true) + c.SetTamperAlertExporter(exporter) + + // 1. A clean assembly first establishes base as a KNOWN verified member. + // R1016 fires only for a member that verified before and later fails — a + // tampered-from-first-sight object is an unattributable non-member (skipped), + // not a tamper, so tamper must be observed as a clean→bad transition. + if _, err := c.assembleUserBundle(context.Background(), "redis", "redis", "wlid://c/cluster/redis/Pod/redis"); err != nil { + t.Fatalf("clean assembly must succeed first: %v", err) + } + + // 2. Tamper the stored base after it was verified. + base.Spec.Execs[0].Path = "/bin/evil" + + // 3. Re-assemble: base is a known member that no longer verifies → refuse + + // R1016. + composite, err := c.assembleUserBundle(context.Background(), "redis", "redis", "wlid://c/cluster/redis/Pod/redis") + if err == nil || composite != nil { + t.Fatalf("want error + nil composite on tamper of a known member; got (%v,%v)", composite, err) + } + if got := len(exporter.ruleAlerts()); got != 1 { + t.Errorf("want 1 R1016 alert on tampered bundle; got %d", got) + } +} + +// strippedListClient simulates the real storage server: List serves items from +// the metadata table WITHOUT the payload (spec-stripped); only Get returns the +// full object. Regression for the bug where assembly hashed the stripped items +// and flagged every signed fragment as tampered. +type strippedListClient struct { + *storage.StorageHttpClientMock +} + +func (s *strippedListClient) ListContainerProfiles(ctx context.Context, namespace string, opts metav1.ListOptions) (*v1beta1.ContainerProfileList, error) { + full, err := s.StorageHttpClientMock.ListContainerProfiles(ctx, namespace, opts) + if err != nil { + return nil, err + } + out := &v1beta1.ContainerProfileList{} + for _, item := range full.Items { + stripped := v1beta1.ContainerProfile{ObjectMeta: *item.ObjectMeta.DeepCopy()} + out.Items = append(out.Items, stripped) + } + return out, nil +} + +func TestAssembleUserBundle_Runtime_SpecStrippedList(t *testing.T) { + vendor, operator := bkey(t), bkey(t) + base := bfrag(t, "redis", "base", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/redis-server", Args: []string{"redis-server"}}}, + }, vendor) + adm := bfrag(t, "redis-ingress-client", "admission", v1beta1.ContainerProfileSpec{ + Ingress: []v1beta1.NetworkNeighbor{{Identifier: "allowed-client", Type: "internal"}}, + }, operator) + vendorID, _ := bundle.SignerID(base) + operatorID, _ := bundle.SignerID(adm) + policy := &bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{vendorID}, AllowedSpecPaths: []string{"execs"}}, + bundle.ClassAdmission: {Signers: []string{operatorID}, AllowedSpecPaths: []string{"ingress"}}, + }} + + mock := &strippedListClient{&storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{base, adm}}} + c := newBundleCacheClient(mock, policy, true) + + composite, err := c.assembleUserBundle(context.Background(), "redis", "redis", "wlid://c/cluster/redis/Pod/redis") + if err != nil { + t.Fatalf("assembly must survive a spec-stripped List (fetch full fragments via Get): %v", err) + } + if composite == nil || len(composite.Spec.Execs) != 1 || len(composite.Spec.Ingress) != 1 { + t.Fatalf("composite not assembled from full fragments: %+v", composite) + } +} + +// selectorIgnoringClient simulates the real storage server's List, which +// IGNORES the label selector and returns every CP in the namespace. Regression +// for the cross-bundle pickup bug: without client-side membership filtering, +// any bundle name assembled ALL class-labeled fragments in the namespace. +type selectorIgnoringClient struct { + *storage.StorageHttpClientMock +} + +func (s *selectorIgnoringClient) ListContainerProfiles(ctx context.Context, namespace string, _ metav1.ListOptions) (*v1beta1.ContainerProfileList, error) { + return s.StorageHttpClientMock.ListContainerProfiles(ctx, namespace, metav1.ListOptions{}) +} + +func TestAssembleUserBundle_Runtime_SelectorIgnoredByStorage(t *testing.T) { + vendor, operator := bkey(t), bkey(t) + base := bfrag(t, "redis", "base", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/redis-server", Args: []string{"redis-server"}}}, + }, vendor) + adm := bfrag(t, "redis-ingress-client", "admission", v1beta1.ContainerProfileSpec{ + Ingress: []v1beta1.NetworkNeighbor{{Identifier: "allowed-client", Type: "internal"}}, + }, operator) + vendorID, _ := bundle.SignerID(base) + operatorID, _ := bundle.SignerID(adm) + policy := &bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{vendorID}, AllowedSpecPaths: []string{"execs"}}, + bundle.ClassAdmission: {Signers: []string{operatorID}, AllowedSpecPaths: []string{"ingress"}}, + }} + + mock := &selectorIgnoringClient{&storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{base, adm}}} + c := newBundleCacheClient(mock, policy, true) + + // Looking up a DIFFERENT bundle name in the same namespace must find NO + // fragments (fall back to the single-CP path), not assemble redis's. + composite, err := c.assembleUserBundle(context.Background(), "redis", "redis-client", "wlid://c/cluster/redis/Pod/client") + if err != nil || composite != nil { + t.Fatalf("foreign bundle name must resolve to no fragments; got (%v, %v)", composite, err) + } + + // The real bundle still assembles from exactly its own fragments. + composite, err = c.assembleUserBundle(context.Background(), "redis", "redis", "wlid://c/cluster/redis/Pod/redis") + if err != nil || composite == nil { + t.Fatalf("redis bundle must assemble: %v", err) + } + m, err := bundle.ManifestFromComposite(composite) + if err != nil || len(m.Leaves) != 2 { + t.Fatalf("composite must contain exactly redis's 2 fragments, got %v (%v)", m, err) + } +} + +// TestAssembleUserBundle_R1016_ReAlertsAfterRecovery pins that R1016 re-fires +// on a NEW tamper after a clean recovery — even when the tampered fragment set +// fingerprint recurs (delete/recreate resets resourceVersions). Regression for +// the dedup bug where a fingerprint key was stored but never cleared. +func TestAssembleUserBundle_R1016_ReAlertsAfterRecovery(t *testing.T) { + vendor := bkey(t) + mkOverlay := func(rv, path string) *v1beta1.ContainerProfile { + cp := bfrag(t, "redis", "base", v1beta1.ContainerProfileSpec{Execs: []v1beta1.ExecCalls{{Path: path}}}, vendor) + cp.ResourceVersion = rv + return cp + } + base := mkOverlay("1", "/bin/ok") + vendorID, _ := bundle.SignerID(base) + policy := &bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{vendorID}, AllowedSpecPaths: []string{"execs"}}, + }} + exporter := &captureExporter{} + + tamper := func(cp *v1beta1.ContainerProfile) { + cp.Spec.Execs = append(cp.Spec.Execs, v1beta1.ExecCalls{Path: "/bin/evil"}) // break the signature + } + + assembleWith := func(cp *v1beta1.ContainerProfile, c *ContainerProfileCacheImpl) { + mock := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{cp}} + c.storageClient = mock + c.assembleUserBundle(context.Background(), "redis", "redis", "wlid://c/cluster/redis/Pod/redis") + } + + c := &ContainerProfileCacheImpl{rpcBudget: time.Second, cfg: config.Config{EnableSignatureVerification: true}} + c.SetBundleConfig(policy) + c.SetTamperAlertExporter(exporter) + + // 0. A clean assembly first makes the base slot a KNOWN verified member, so a + // later verification failure is an attributable member-tamper (R1016) rather + // than an unattributable non-member drop. + assembleWith(mkOverlay("1", "/bin/ok"), c) + if got := len(exporter.ruleAlerts()); got != 0 { + t.Fatalf("clean establish: want 0 R1016, got %d", got) + } + // 1. tamper the known member → 1 alert + t1 := mkOverlay("2", "/bin/ok") + tamper(t1) + assembleWith(t1, c) + if got := len(exporter.ruleAlerts()); got != 1 { + t.Fatalf("first tamper: want 1 R1016, got %d", got) + } + // 2. clean recovery → edge-trigger cleared, no new alert + assembleWith(mkOverlay("3", "/bin/ok"), c) + if got := len(exporter.ruleAlerts()); got != 1 { + t.Fatalf("recovery must not add an alert: want 1, got %d", got) + } + // 3. NEW tamper after recovery → must re-alert + t2 := mkOverlay("4", "/bin/ok") + tamper(t2) + assembleWith(t2, c) + if got := len(exporter.ruleAlerts()); got != 2 { + t.Fatalf("re-tamper after recovery: want 2 R1016, got %d", got) + } +} + +// bfragIn is bfrag with an explicit namespace and bundle name. +func bfragIn(t *testing.T, name, class, ns, bundleName string, spec v1beta1.ContainerProfileSpec, key *ecdsa.PrivateKey) *v1beta1.ContainerProfile { + t.Helper() + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + Labels: map[string]string{ + bundle.LabelBundle: bundleName, + bundle.LabelFragmentClass: class, + }, + }, + Spec: spec, + } + if err := signature.SignObject(profiles.NewContainerProfileAdapter(cp), signature.WithPrivateKey(key)); err != nil { + t.Fatalf("sign %s: %v", name, err) + } + return cp +} + +// TestBundleComposite_IsEnforceableByTheRuleEngine pins the contract a bundle +// composite must satisfy to be enforced, end to end through the cache: from a +// pod carrying kubescape.io/user-defined-profile= all the way to what +// the rule engine reads. +// +// The three things the rule engine actually consumes: +// +// 1. GetProjectedContainerProfile(containerID) != nil — rule_manager gates +// every rule with profileDependency=Required on this ("profile_incomplete" +// suppression otherwise). R0001 is such a rule, so a nil projection makes +// it silent for EVERY comm, not just unlisted ones. +// 2. GetContainerProfileState == Completed/Full — a composite carries no +// learning-lifecycle annotations, so the terminal state has to be forced +// by the cache, not read off the object. +// 3. The projected Execs set is the union of the admissible fragments, and +// nothing else — the union is what makes an overlay-only exec quiet and an +// unlisted exec alert. +// +// It also pins the inverse: the composite must NOT carry kubescape.io/status. +// tryPopulateEntry treats any label-referenced CP that has that annotation as a +// LEARNED profile and drops it, so "stamping completion annotations on the +// composite" would disable the bundle path outright rather than enable it. +func TestBundleComposite_IsEnforceableByTheRuleEngine(t *testing.T) { + vendor, operator := bkey(t), bkey(t) + + base := bfragIn(t, "b37-base", "base", "default", "b37", v1beta1.ContainerProfileSpec{ + Architectures: []string{"amd64"}, + Execs: []v1beta1.ExecCalls{{Path: "/bin/sleep"}, {Path: "/usr/bin/curl"}}, + Syscalls: []string{"close", "read"}, + }, vendor) + overlay := bfragIn(t, "b37-overlay", "overlay", "default", "b37", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/usr/bin/id"}}, + }, operator) + + vendorID, err := bundle.SignerID(base) + require.NoError(t, err) + operatorID, err := bundle.SignerID(overlay) + require.NoError(t, err) + policy := &bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{vendorID}, AllowedSpecPaths: []string{"architectures", "execs", "syscalls"}}, + bundle.ClassOverlay: {Signers: []string{operatorID}, AllowedSpecPaths: []string{"execs"}}, + }} + + mock := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{base, overlay}} + c, k8s := newTestCache(t, mock) + c.SetBundleConfig(policy) + + id := "container-b37" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-b37") + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "b37"} + require.NoError(t, c.addContainer(ev, context.Background())) + + assertComposite := func(stage string) { + t.Helper() + pcp := c.GetProjectedContainerProfile(id) + require.NotNil(t, pcp, "%s: profile-dependent rules are gated on a non-nil projection", stage) + + state := c.GetContainerProfileState(id) + require.NotNil(t, state, "%s: state must be published", stage) + require.Equal(t, helpersv1.Completed, state.Status, "%s: composite must read as Completed", stage) + require.Equal(t, helpersv1.Full, state.Completion, "%s: composite must read as Full", stage) + + for _, p := range []string{"/bin/sleep", "/usr/bin/curl", "/usr/bin/id"} { + _, ok := pcp.Execs.Values[p] + require.True(t, ok, "%s: %s must be in the projected union", stage, p) + } + _, unlisted := pcp.Execs.Values["/bin/ls"] + require.False(t, unlisted, "%s: an exec in no fragment must not be in the projection", stage) + } + + assertComposite("after add") + + // The composite must stay enforceable across reconciler refresh ticks: the + // refresh path rebuilds from its own sources and must not degrade the entry + // to a learning/partial state. + c.refreshAllEntries(context.Background()) + assertComposite("after refresh") + + entry, ok := c.entries.Load(id) + require.True(t, ok) + require.NotNil(t, entry.UserCPRef, "the bundle name must be recorded so the reconciler re-assembles it") + require.Equal(t, "b37", entry.UserCPRef.Name) + + // The composite is an AUTHORED profile: it must carry no learning-lifecycle + // annotation, or tryPopulateEntry would discard it as a learned CP. + composite, err := c.assembleUserBundle(context.Background(), "default", "b37", "wlid://c/cluster/default/Pod/b37") + require.NoError(t, err) + require.NotNil(t, composite) + require.NotContains(t, composite.Annotations, helpersv1.StatusMetadataKey, + "a composite carrying kubescape.io/status would be dropped as a learned profile") + require.NotContains(t, composite.Annotations, helpersv1.CompletionMetadataKey, + "completion annotations belong to learned profiles only") +} + +func TestAssembleUserBundle_SyncChecksumTracksFragmentSet(t *testing.T) { + vendor, operator := bkey(t), bkey(t) + base := bfrag(t, "redis", "base", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/redis-server", Args: []string{"redis-server"}}}, + }, vendor) + overlay := bfrag(t, "redis-ops", "overlay", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/usr/bin/id", Args: []string{"id"}}}, + }, operator) + + vendorID, err := bundle.SignerID(base) + if err != nil { + t.Fatal(err) + } + operatorID, err := bundle.SignerID(overlay) + if err != nil { + t.Fatal(err) + } + policy := &bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{vendorID}, AllowedSpecPaths: []string{"execs"}}, + bundle.ClassOverlay: {Signers: []string{operatorID}, AllowedSpecPaths: []string{"execs"}}, + }} + + assemble := func(frags ...*v1beta1.ContainerProfile) *v1beta1.ContainerProfile { + mock := &storage.StorageHttpClientMock{ContainerProfiles: frags} + c := newBundleCacheClient(mock, policy, true) + composite, err := c.assembleUserBundle(context.Background(), "redis", "redis", "wlid://c/cluster/redis/Pod/redis") + if err != nil { + t.Fatalf("assembleUserBundle: %v", err) + } + if composite == nil { + t.Fatal("expected a composite") + } + return composite + } + + baseOnly := assemble(base) + if got := baseOnly.Annotations[helpersv1.SyncChecksumMetadataKey]; got != baseOnly.ResourceVersion || got == "" { + t.Fatalf("base-only SyncChecksum %q must equal the Merkle-root RV %q", got, baseOnly.ResourceVersion) + } + + withOverlay := assemble(base, overlay) + if got := withOverlay.Annotations[helpersv1.SyncChecksumMetadataKey]; got != withOverlay.ResourceVersion || got == "" { + t.Fatalf("with-overlay SyncChecksum %q must equal the Merkle-root RV %q", got, withOverlay.ResourceVersion) + } + + if baseOnly.Annotations[helpersv1.SyncChecksumMetadataKey] == withOverlay.Annotations[helpersv1.SyncChecksumMetadataKey] { + t.Fatal("SyncChecksum must change when the fragment set changes, else the CEL was_executed cache never invalidates") + } +} + +func TestBundleTamperAfterLoad_KeepsLastVerifiedProfile(t *testing.T) { + vendor, operator := bkey(t), bkey(t) + + base := bfragIn(t, "keep-base", "base", "default", "keepb", v1beta1.ContainerProfileSpec{ + Architectures: []string{"amd64"}, + Execs: []v1beta1.ExecCalls{{Path: "/bin/sleep"}, {Path: "/usr/bin/curl"}}, + }, vendor) + overlay := bfragIn(t, "keep-overlay", "overlay", "default", "keepb", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/usr/bin/id"}}, + }, operator) + + vendorID, err := bundle.SignerID(base) + require.NoError(t, err) + operatorID, err := bundle.SignerID(overlay) + require.NoError(t, err) + policy := &bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{vendorID}, AllowedSpecPaths: []string{"architectures", "execs"}}, + bundle.ClassOverlay: {Signers: []string{operatorID}, AllowedSpecPaths: []string{"execs"}}, + }} + + mock := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{base, overlay}} + c, k8s := newTestCache(t, mock) + c.SetBundleConfig(policy) + + id := "container-keepb" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-keepb") + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "keepb"} + require.NoError(t, c.addContainer(ev, context.Background())) + + before := c.GetProjectedContainerProfile(id) + require.NotNil(t, before) + for _, p := range []string{"/bin/sleep", "/usr/bin/curl", "/usr/bin/id"} { + _, ok := before.Execs.Values[p] + require.True(t, ok, "%s must be enforced before the tamper", p) + } + + // Successful revalidation updates provenance even if the source RVs match + // and refresh can keep the existing projection. + revalidatedPolicy := *policy + c.SetBundleConfig(&revalidatedPolicy) + c.refreshAllEntries(context.Background()) + revalidated, ok := c.entries.Load(id) + require.True(t, ok) + require.Same(t, &revalidatedPolicy, revalidated.verifiedBundlePolicy) + + overlay.Spec.Execs = append(overlay.Spec.Execs, v1beta1.ExecCalls{Path: "/bin/backdoor"}) + + c.refreshAllEntries(context.Background()) + + after := c.GetProjectedContainerProfile(id) + require.NotNil(t, after, "a tampered fragment must not leave the container without a profile") + for _, p := range []string{"/bin/sleep", "/usr/bin/curl", "/usr/bin/id"} { + _, ok := after.Execs.Values[p] + require.True(t, ok, "%s must still be enforced from the last verified composite", p) + } + _, injected := after.Execs.Values["/bin/backdoor"] + require.False(t, injected, "the tampered content must be refused") + + state := c.GetContainerProfileState(id) + require.NotNil(t, state) + require.Equal(t, helpersv1.Completed, state.Status, "the retained profile must stay enforceable") + + // A replacement policy invalidates the provenance until the sources can be + // verified again, even when the fragment resource versions do not change. + replacement := *policy + replacement.Mode = bundle.ModeEnforce + c.SetBundleConfig(&replacement) + c.refreshAllEntries(context.Background()) + refused, ok := c.entries.Load(id) + require.True(t, ok) + require.Empty(t, refused.UserCPRV) + require.NotNil(t, refused.UserCPRef) + require.NotEqual(t, helpersv1.Completed, refused.State.Status) + + overlay.Spec.Execs = overlay.Spec.Execs[:len(overlay.Spec.Execs)-1] + c.refreshAllEntries(context.Background()) + recovered, ok := c.entries.Load(id) + require.True(t, ok) + require.Same(t, &replacement, recovered.verifiedBundlePolicy) + require.Equal(t, helpersv1.Completed, recovered.State.Status) + overlay.Spec.Execs = append(overlay.Spec.Execs, v1beta1.ExecCalls{Path: "/bin/backdoor"}) + c.refreshAllEntries(context.Background()) + require.Same(t, recovered.Projected, c.GetProjectedContainerProfile(id)) +} + +// TestAssembleUserBundle_NonMemberInjection_Skipped pins the F1 fix: an object a +// namespace writer labels into the bundle but does not (and cannot) sign with a +// class-trusted key is a NON-MEMBER. It is skipped, the bundle still assembles +// from the genuine members, and it raises no R1016 (it was never a verified +// member, so a tamper alert would be unattributable). Previously such an object +// failed the whole bundle closed — a denial-of-service any namespace writer +// could trigger. +func TestAssembleUserBundle_NonMemberInjection_Skipped(t *testing.T) { + vendor := bkey(t) + base := bfrag(t, "redis", "base", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/redis-server", Args: []string{"redis-server"}}}, + }, vendor) + vendorID, _ := bundle.SignerID(base) + policy := &bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{vendorID}, AllowedSpecPaths: []string{"execs"}}, + }} + + // An UNSIGNED object an attacker labelled into the bundle. + rogue := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rogue", + Namespace: "redis", + Labels: map[string]string{ + bundle.LabelBundle: "redis", + bundle.LabelFragmentClass: "base", + }, + }, + Spec: v1beta1.ContainerProfileSpec{Execs: []v1beta1.ExecCalls{{Path: "/bin/evil"}}}, + } + + mock := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{base, rogue}} + exporter := &captureExporter{} + c := newBundleCacheClient(mock, policy, true) + c.SetTamperAlertExporter(exporter) + + composite, err := c.assembleUserBundle(context.Background(), "redis", "redis", "wlid://c/cluster/redis/Pod/redis") + if err != nil || composite == nil { + t.Fatalf("bundle must assemble from genuine members despite an injected non-member; got (%v,%v)", composite, err) + } + // The rogue exec must NOT be in the composite. + for _, e := range composite.Spec.Execs { + if e.Path == "/bin/evil" { + t.Fatal("the injected non-member's content leaked into the composite") + } + } + if got := len(exporter.ruleAlerts()); got != 0 { + t.Errorf("an injected non-member is not a tamper of a verified member; want 0 R1016, got %d", got) + } +} diff --git a/pkg/objectcache/containerprofilecache/bundle_policy_race_test.go b/pkg/objectcache/containerprofilecache/bundle_policy_race_test.go new file mode 100644 index 0000000000..b78f4f9176 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/bundle_policy_race_test.go @@ -0,0 +1,54 @@ +//go:build experimental_signing + +package containerprofilecache + +import ( + "context" + "sync" + "testing" + + "github.com/kubescape/node-agent/pkg/signature/bundle" +) + +// The trust policy is written by the reload goroutine and read on every +// reconciler tick; run with -race. +func TestSetBundleConfig_ConcurrentWithReaders(t *testing.T) { + c := &ContainerProfileCacheImpl{} + policies := []*bundle.TrustPolicy{ + nil, + {Mode: bundle.ModeAlert}, + {Mode: bundle.ModeEnforce}, + {RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{bundle.RuleClassBase: {}}}, + } + + stop := make(chan struct{}) + var writer sync.WaitGroup + writer.Add(1) + go func() { + defer writer.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + c.SetBundleConfig(policies[i%len(policies)]) + } + } + }() + + var readers sync.WaitGroup + for r := 0; r < 4; r++ { + readers.Add(1) + go func() { + defer readers.Done() + for i := 0; i < 20000; i++ { + _ = c.bundlesEnabled() + _ = c.signingEnforced() + _, _ = c.assembleUserBundle(context.Background(), "ns", "", "wlid") + } + }() + } + readers.Wait() + close(stop) + writer.Wait() +} diff --git a/pkg/objectcache/containerprofilecache/bundle_shadowing_test.go b/pkg/objectcache/containerprofilecache/bundle_shadowing_test.go new file mode 100644 index 0000000000..f34d925f4a --- /dev/null +++ b/pkg/objectcache/containerprofilecache/bundle_shadowing_test.go @@ -0,0 +1,206 @@ +//go:build experimental_signing + +package containerprofilecache + +import ( + "context" + "strings" + "testing" + + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/node-agent/pkg/storage" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// classicalCP builds a user-defined ContainerProfile of the pre-bundle kind: no +// bundle label, no fragment-class label, no signature. +func classicalCP(name, ns, exec string) *v1beta1.ContainerProfile { + return &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: v1beta1.ContainerProfileSpec{ + Architectures: []string{"amd64"}, + Execs: []v1beta1.ExecCalls{{Path: exec}}, + }, + } +} + +func bindToProfile(t *testing.T, c *ContainerProfileCacheImpl, id, profile string) { + t.Helper() + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: profile} + require.NoError(t, c.addContainer(ev, context.Background())) +} + +func execEnforced(c *ContainerProfileCacheImpl, id, path string) bool { + p := c.GetProjectedContainerProfile(id) + if p == nil { + return false + } + _, ok := p.Execs.Values[path] + return ok +} + +// With bundles enabled but no fragment carrying the name, the label must still +// resolve the ContainerProfile of that name. This is the compatibility path for +// every user-defined profile that predates bundles. +func TestClassicalProfile_ResolvedByName_WhenNoBundleFragmentsExist(t *testing.T) { + solo := classicalCP("solo-nginx", "default", "/bin/classical-only") + + mock := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{solo}} + c, k8s := newTestCache(t, mock) + c.SetBundleConfig(&bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{"key:absent"}, AllowedSpecPaths: []string{"execs"}}, + }}) + + id := "container-solo" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-solo") + bindToProfile(t, c, id, "solo") + + require.True(t, execEnforced(c, id, "/bin/classical-only"), + "a classical profile must still be resolved by name when no bundle of that name exists") +} + +// When verifying fragments carry the same name, the composite is used and the +// identically named classical ContainerProfile is never read. +func TestClassicalProfile_ShadowedByVerifyingBundle(t *testing.T) { + vendor := bkey(t) + base := bfragIn(t, "shadow-base", "base", "default", "shadowed", v1beta1.ContainerProfileSpec{ + Architectures: []string{"amd64"}, + Execs: []v1beta1.ExecCalls{{Path: "/bin/from-fragment"}}, + }, vendor) + vendorID, err := bundle.SignerID(base) + require.NoError(t, err) + + classical := classicalCP("shadowed-nginx", "default", "/bin/classical-only") + + mock := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{classical, base}} + c, k8s := newTestCache(t, mock) + c.SetBundleConfig(&bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{vendorID}, AllowedSpecPaths: []string{"architectures", "execs"}}, + }}) + + id := "container-shadowed" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-shadowed") + bindToProfile(t, c, id, "shadowed") + + require.True(t, execEnforced(c, id, "/bin/from-fragment"), + "the composite assembled from the bundle must be enforced") + require.False(t, execEnforced(c, id, "/bin/classical-only"), + "the identically named classical profile must not be read while the bundle assembles") +} + +// A fragment set that fails verification must not fall back to the identically +// named classical ContainerProfile. A container with no prior projection is left +// without a user-defined profile rather than silently dropping to the unsigned +// object, so the create verb on containerprofiles can deny a profile but never +// substitute one. +func TestUnverifiableBundle_DoesNotFallBackToClassicalProfile(t *testing.T) { + unsigned := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denied-fragment", + Namespace: "default", + Labels: map[string]string{ + bundle.LabelBundle: "denied", + bundle.LabelFragmentClass: "base", + }, + }, + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/injected"}}, + }, + } + classical := classicalCP("denied-nginx", "default", "/bin/classical-only") + + mock := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{classical, unsigned}} + c, k8s := newTestCache(t, mock) + c.SetBundleConfig(&bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{"key:absent"}, AllowedSpecPaths: []string{"execs"}}, + }}) + + id := "container-denied" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-denied") + bindToProfile(t, c, id, "denied") + + require.False(t, execEnforced(c, id, "/bin/classical-only"), + "an unverifiable fragment set must not fall back to the identically named classical profile") + require.False(t, execEnforced(c, id, "/bin/injected"), + "the unverifiable fragment's own content must never be enforced") +} + +// Outcome 2 made visible: a verifying bundle shadowing a BARE same-named +// classical profile logs once per root transition — and only then (AC #67-1/2). +func TestShadowLog_OncePerRootTransition(t *testing.T) { + vendor := bkey(t) + base := bfragIn(t, "shadow2-base", "base", "default", "shadowed2", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/from-fragment"}}, + }, vendor) + vendorID, err := bundle.SignerID(base) + require.NoError(t, err) + classicalBare := classicalCP("shadowed2", "default", "/bin/classical-only") + + mock := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{classicalBare, base}} + c, k8s := newTestCache(t, mock) + c.SetBundleConfig(&bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{vendorID}, AllowedSpecPaths: []string{"execs"}}, + }}) + + primeSharedData(t, k8s, "c-shadow-1", "wlid://cluster-a/namespace-default/deployment-shadowed2") + primeSharedData(t, k8s, "c-shadow-2", "wlid://cluster-a/namespace-default/deployment-shadowed2") + out := captureLogs(t, func() { + bindToProfile(t, c, "c-shadow-1", "shadowed2") + bindToProfile(t, c, "c-shadow-2", "shadowed2") + }) + require.Equal(t, 1, strings.Count(out, "shadows a ContainerProfile of the same name"), + "exactly one shadow log per root transition, none for unchanged re-assembly:\n%s", out) +} + +// No same-named classical profile → no shadow log (AC #67-2). +func TestShadowLog_AbsentWithoutSameNamedProfile(t *testing.T) { + vendor := bkey(t) + base := bfragIn(t, "noshadow-base", "base", "default", "noshadow", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/from-fragment"}}, + }, vendor) + vendorID, err := bundle.SignerID(base) + require.NoError(t, err) + + mock := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{base}} + c, k8s := newTestCache(t, mock) + c.SetBundleConfig(&bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{vendorID}, AllowedSpecPaths: []string{"execs"}}, + }}) + + primeSharedData(t, k8s, "c-noshadow", "wlid://cluster-a/namespace-default/deployment-noshadow") + out := captureLogs(t, func() { bindToProfile(t, c, "c-noshadow", "noshadow") }) + require.NotContains(t, out, "shadows a ContainerProfile", + "no shadow log without a same-named classical profile") +} + +// Outcome 3 says what actually happened: NO fallback, container without a +// user-defined profile — not the generic legacy-AP/NN wording (AC #67-5). +func TestUnverifiableBundle_WarnsNoFallbackWording(t *testing.T) { + unsigned := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "denied2-fragment", + Namespace: "default", + Labels: map[string]string{ + bundle.LabelBundle: "denied2", + bundle.LabelFragmentClass: "base", + }, + }, + Spec: v1beta1.ContainerProfileSpec{Execs: []v1beta1.ExecCalls{{Path: "/bin/injected"}}}, + } + mock := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{unsigned}} + c, k8s := newTestCache(t, mock) + c.SetBundleConfig(&bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{ + bundle.ClassBase: {Signers: []string{"key:absent"}, AllowedSpecPaths: []string{"execs"}}, + }}) + + primeSharedData(t, k8s, "c-denied2", "wlid://cluster-a/namespace-default/deployment-denied2") + out := captureLogs(t, func() { bindToProfile(t, c, "c-denied2", "denied2") }) + require.Contains(t, out, "NO fallback to a ContainerProfile of the same name", + "outcome 3 must state the no-fallback consequence") + require.NotContains(t, out, "legacy ApplicationProfile", + "outcome 3 must not be reported with the generic legacy wording") +} diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache.go b/pkg/objectcache/containerprofilecache/containerprofilecache.go index 410ce1aa7c..71dfef5efa 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache.go @@ -53,6 +53,8 @@ type namespacedName struct { // ContainerProfile is not retained after projection — only the compact form is // stored so the raw pointer can be GC'd. type CachedContainerProfile struct { + signingBinding + signingProvenance Projected *objectcache.ProjectedContainerProfile SpecHash string // mirrors Projected.SpecHash; used for staleness checks State *objectcache.ProfileState @@ -120,6 +122,7 @@ type pendingContainer struct { // ContainerProfileCacheImpl is the unified container-keyed cache for ContainerProfile objects. type ContainerProfileCacheImpl struct { + signingState cfg config.Config entries maps.SafeMap[string, *CachedContainerProfile] pending maps.SafeMap[string, *pendingContainer] @@ -441,7 +444,15 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // single-container convention and the safest retry target when the // per-container fetch does not cleanly succeed. resolvedOverlayName := overlayName - if hasOverlay && overlayName != "" { + bundleCP, bundleHandled, provenance, bundleErr := c.loadSignedBundle(ctx, ns, overlayName, sharedData.Wlid) + if bundleErr != nil { + logger.L().Warning("signed bundle failed verification: NO fallback to a ContainerProfile of the same name", helpers.String("bundle", overlayName), helpers.Error(bundleErr)) + return false + } + if bundleHandled { + userDefinedCP = bundleCP + } + if !bundleHandled && hasOverlay && overlayName != "" { // Per-container binding (review finding on node-agent#864): a // multi-container pod shares one label value but each container is // profiled independently, so its authored ContainerProfile is published @@ -486,7 +497,21 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( } } + if !bundleHandled { + var refused bool + userDefinedCP, refused, provenance = c.admitSignedProfile(userDefinedCP, sharedData.Wlid) + if refused { + return false + } + } userDefinedCP = resolveAuthoredSection(userDefinedCP, container.Runtime.ContainerName, resolvedOverlayName, containerID, ns) + if userDefinedCP == nil { + provenance = signingProvenance{} + } + + if c.requiresSignedProfile() && userDefinedCP == nil { + return false + } // Only cache profiles whose status is terminal (Completed or TooLarge). // Learning/ready profiles are still being written; caching them would let @@ -567,6 +592,8 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( } entry := c.buildEntry(cp, pod, container, sharedData) + entry.signingProvenance = provenance + c.setSigningBinding(entry, overlayName) // Override CPName with the real consolidated-CP slug. buildEntry sets // CPName from cp.Name, but when cp was synthesized above (no consolidated // CP in storage yet), cp.Name is the workloadName/overlayName — NOT the @@ -613,6 +640,7 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( } c.entries.Set(containerID, entry) + c.noteAdoptedProfile(provenance, userDefinedCP, sharedData.Wlid) c.pending.Delete(containerID) c.metricsManager.SetContainerProfileCacheEntries("container", float64(c.entries.Len())) c.metricsManager.SetContainerProfileCacheEntries("pending", float64(c.pending.Len())) diff --git a/pkg/objectcache/containerprofilecache/embedded_decode_signing_test.go b/pkg/objectcache/containerprofilecache/embedded_decode_signing_test.go new file mode 100644 index 0000000000..9ed92c0a40 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/embedded_decode_signing_test.go @@ -0,0 +1,64 @@ +//go:build experimental_signing + +package containerprofilecache + +import ( + "testing" + + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/stretchr/testify/require" +) + +// Override only the signed payload so the signature is valid even though its +// embedded spec cannot be interpreted as a ContainerProfileSpec. +type invalidEmbeddedProfile struct { + *profiles.ContainerProfileAdapter + spec interface{} +} + +func (p invalidEmbeddedProfile) GetContent() interface{} { + return map[string]interface{}{ + "apiVersion": "spdx.softwarecomposition.kubescape.io/v1beta1", + "kind": "ContainerProfile", + "metadata": map[string]interface{}{"name": p.GetName()}, + "spec": p.spec, + } +} + +func TestAdmissionRejectsInvalidVerifiedEmbeddedSpec(t *testing.T) { + for _, enforce := range []bool{false, true} { + mode := "permissive" + if enforce { + mode = "enforcing" + } + t.Run(mode, func(t *testing.T) { + for _, tc := range []struct { + name string + spec interface{} + }{ + {"wrong-type", "not a profile spec"}, + {"wrong-field-type", map[string]interface{}{"execs": "not an array"}}, + {"null", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + cp := signedCP("invalid-embedded", "test", "1") + original := cp.Spec.DeepCopy() + adapter := profiles.NewContainerProfileAdapter(cp) + require.NoError(t, signature.SignObject(invalidEmbeddedProfile{adapter, tc.spec}, signature.WithKeyless(false), signature.WithEmbedContent(true))) + require.NoError(t, signature.VerifyObjectAllowUntrusted(adapter), "this is an embedded schema failure, not a cryptographic failure") + cache := &ContainerProfileCacheImpl{} + cache.cfg.EnableSignatureVerification = enforce + exporter := &captureExporter{} + cache.SetTamperAlertExporter(exporter) + admitted, refused, _ := cache.admitSignedProfile(cp, "") + require.True(t, refused, "invalid signed content must never fall back to the mutable stored spec") + require.Nil(t, admitted) + require.False(t, cache.verifyUserContainerProfile(cp.DeepCopy(), ""), "the boolean verification helper must refuse invalid embedded content too") + require.Equal(t, original, &cp.Spec, "admission must preserve the storage client's object") + require.Empty(t, exporter.ruleAlerts(), "a valid signature with an invalid schema is not tampering") + }) + } + }) + } +} diff --git a/pkg/objectcache/containerprofilecache/grouped_signature_test.go b/pkg/objectcache/containerprofilecache/grouped_signature_test.go new file mode 100644 index 0000000000..b805f1df80 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/grouped_signature_test.go @@ -0,0 +1,62 @@ +//go:build experimental_signing + +// Pins the A5-A8 verification object: the signature binds the RAW fetched +// grouped document; resolveAuthoredContainerSection rebuilds the spec, so +// verifying the flattened view always mismatches (observed live: clean +// tamper-app refused with a false R1016 within 1s of signing). +package containerprofilecache + +import ( + "errors" + "testing" + + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func signedGroupedCP(t *testing.T) *v1beta1.ContainerProfile { + t.Helper() + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tamper-app", Namespace: "prod", ResourceVersion: "7", + Annotations: map[string]string{"kubescape.io/managed-by": "User"}, + }, + Spec: v1beta1.ContainerProfileSpec{ + Architectures: []string{"amd64"}, + Containers: []v1beta1.ContainerProfileContainer{{ + Name: "app", + Execs: []v1beta1.ExecCalls{{Path: "/bin/sleep", Args: []string{"/bin/sleep", "3600"}}}, + }}, + }, + } + require.NoError(t, signature.SignObjectDisableKeyless(profiles.NewContainerProfileAdapter(cp))) + return cp +} + +func TestVerify_GroupedDoc_RawVerifiesFlattenedDoesNot(t *testing.T) { + raw := signedGroupedCP(t) + require.NoError(t, signature.VerifyObjectAllowUntrusted(profiles.NewContainerProfileAdapter(raw)), + "the raw signed grouped doc must verify clean") + + flat := resolveAuthoredContainerSection(raw, "app") + require.NotNil(t, flat) + err := signature.VerifyObjectAllowUntrusted(profiles.NewContainerProfileAdapter(flat)) + require.Error(t, err, "flattening rebuilds the spec — the signed digest cannot match") + require.True(t, errors.Is(err, signature.ErrSignatureMismatch), + "the flattened-view failure is a digest mismatch, which is why verify MUST run on the raw doc") +} + +func TestVerify_GroupedDoc_VerdictOKOnRaw_TamperStillCaught(t *testing.T) { + c := &ContainerProfileCacheImpl{} + raw := signedGroupedCP(t) + require.Equal(t, verifyOK, c.verifyUserContainerProfileVerdict(raw, "wlid://c/ns-prod/deployment-d"), + "a clean grouped doc must be verifyOK on the raw object — no false R1016 (A7)") + + raw.Spec.Containers[0].Execs = append(raw.Spec.Containers[0].Execs, v1beta1.ExecCalls{Path: "/bin/evil"}) + raw.ResourceVersion = "8" + require.Equal(t, verifyTampered, c.verifyUserContainerProfileVerdict(raw, "wlid://c/ns-prod/deployment-d"), + "a post-signing section edit must still classify as tamper (A7)") +} diff --git a/pkg/objectcache/containerprofilecache/reconciler.go b/pkg/objectcache/containerprofilecache/reconciler.go index 09fe29da5d..1d10012533 100644 --- a/pkg/objectcache/containerprofilecache/reconciler.go +++ b/pkg/objectcache/containerprofilecache/reconciler.go @@ -408,7 +408,9 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri // // Attached per call, never to the shared ctx: the authored-CP fetch below // derives from the same ctx and must never carry the learned CP's checksum. - validatorEligible := e.UserCPRef == nil && e.UserCPRV == "" && e.SpecHash == preFetchSpecHash && + // Signing enforcement must re-evaluate learned-only entries after a policy + // reload; a checksum match must not bypass that admission decision. + validatorEligible := !c.requiresSignedProfile() && e.UserCPRef == nil && e.UserCPRV == "" && e.SpecHash == preFetchSpecHash && e.State != nil && e.State.Status == helpersv1.Completed && e.State.Completion == helpersv1.Full validatorOffered := false requestMode := conditionalFetchModeMissing @@ -455,6 +457,7 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri logger.L().Warning("refreshOneEntry: source returned unchanged without an offered checksum; keeping cached entry as a transient failure", helpers.String("containerID", id), helpers.String("cpName", e.CPName)) + c.handleProfileFetchError(id, e) return } c.metricsManager.ReportContainerProfileConditionalFetchResponse(conditionalFetchOutcomeUnchanged) @@ -476,6 +479,7 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri helpers.String("containerID", id), helpers.String("cpName", e.CPName), helpers.Error(cpErr)) + c.handleProfileFetchError(id, e) return } // Re-fetch the user-defined ContainerProfile (migrated "new way") FIRST, when @@ -489,7 +493,16 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri // fetched. Otherwise a learned CP stuck in a non-terminal status ("ready") // would freeze authored-CP edits out of the cache forever. var userDefinedCP *v1beta1.ContainerProfile - if e.UserCPRef != nil { + bundleName := c.signingBundleName(e) + bundleCP, bundleHandled, provenance, bundleErr := c.loadSignedBundle(ctx, ns, bundleName, e.WorkloadID) + if bundleErr != nil { + c.handleBundleError(id, e) + return + } + if bundleHandled { + userDefinedCP = bundleCP + } + if !bundleHandled && e.UserCPRef != nil { var userCPErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { userDefinedCP, userCPErr = c.storageClient.GetContainerProfile(rctx, e.UserCPRef.Namespace, e.UserCPRef.Name) @@ -500,17 +513,34 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri helpers.String("containerID", id), helpers.String("name", e.UserCPRef.Name), helpers.Error(userCPErr)) + c.handleProfileFetchError(id, e) return } if userCPErr != nil { userDefinedCP = nil } } + if !bundleHandled { + var refused bool + userDefinedCP, refused, provenance = c.admitSignedProfile(userDefinedCP, e.WorkloadID) + if refused { + c.refuseSignedProfile(id, e) + return + } + } userCPName := "" if e.UserCPRef != nil { userCPName = e.UserCPRef.Name } userDefinedCP = resolveAuthoredSection(userDefinedCP, e.ContainerName, userCPName, id, ns) + if userDefinedCP == nil { + provenance = signingProvenance{} + } + + if c.requiresSignedProfile() && userDefinedCP == nil { + c.refuseSignedProfile(id, e) + return + } if cp == nil && userDefinedCP == nil { logger.L().Debug("refreshOneEntry: no CP available after refresh; evicting entry", helpers.String("containerID", id), @@ -538,14 +568,16 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri if spec := c.snapshotSpec(); spec != nil { postFetchSpecHash = spec.Hash } - if rvsMatchCP(cp, e.RV) && + if c.canSkipSignedRefresh(e, provenance) && rvsMatchCP(cp, e.RV) && checksumOfCP(cp) == e.Checksum && rvsMatchCP(userDefinedCP, e.UserCPRV) && e.SpecHash == postFetchSpecHash { + e.signingProvenance = provenance + c.noteAdoptedProfile(provenance, userDefinedCP, e.WorkloadID) return } - c.rebuildEntryFromSources(id, e, cp, userDefinedCP) + c.rebuildEntryFromSources(id, e, cp, userDefinedCP, provenance) } // rvsMatchCP returns true when either (a) the object is absent and the stored RV @@ -569,6 +601,7 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( prev *CachedContainerProfile, cp *v1beta1.ContainerProfile, userDefinedCP *v1beta1.ContainerProfile, + provenance signingProvenance, ) { // Authored-validation (mirror of the add path): a label-referenced CP that // carries lifecycle annotations is a LEARNED profile, not an authored one. @@ -618,6 +651,8 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( } newEntry := &CachedContainerProfile{ + signingBinding: prev.signingBinding, + signingProvenance: provenance, Projected: projectedCP, SpecHash: projectedCP.SpecHash, State: &objectcache.ProfileState{Completion: effectiveCP.Annotations[helpersv1.CompletionMetadataKey], Status: effectiveCP.Annotations[helpersv1.StatusMetadataKey], Name: effectiveCP.Name}, @@ -651,7 +686,9 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( newEntry.UserCPRef = prev.UserCPRef } + c.preserveSignedBundleReference(newEntry, prev) c.entries.Set(id, newEntry) + c.noteAdoptedProfile(provenance, userDefinedCP, prev.WorkloadID) } // rvOfCP returns the object's ResourceVersion or "" when nil. Using a typed diff --git a/pkg/objectcache/containerprofilecache/rollback_guard_test.go b/pkg/objectcache/containerprofilecache/rollback_guard_test.go new file mode 100644 index 0000000000..8719e45bb9 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/rollback_guard_test.go @@ -0,0 +1,95 @@ +//go:build experimental_signing + +package containerprofilecache + +import ( + "testing" + + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/stretchr/testify/require" +) + +func leaf(class, name string, version int64) bundle.LeafRef { + return bundle.LeafRef{Class: bundle.FragmentClass(class), Name: name, Version: version} +} + +func TestRollbackGuard_MonotonicPerSlot(t *testing.T) { + c := &ContainerProfileCacheImpl{} + ns, b := "redis", "redis" + + // v1 accepted. + require.NoError(t, c.checkAndAdvanceVersions(ns, b, []bundle.LeafRef{leaf("overlay", "ops", 1)})) + // v2 accepted (advance). + require.NoError(t, c.checkAndAdvanceVersions(ns, b, []bundle.LeafRef{leaf("overlay", "ops", 2)})) + // replay of v1 refused. + err := c.checkAndAdvanceVersions(ns, b, []bundle.LeafRef{leaf("overlay", "ops", 1)}) + require.ErrorIs(t, err, bundle.ErrFragmentRollback) + // same version (v2) is allowed — reassembly of the current fragment is not a rollback. + require.NoError(t, c.checkAndAdvanceVersions(ns, b, []bundle.LeafRef{leaf("overlay", "ops", 2)})) +} + +func TestRollbackGuard_RejectsWholeSet(t *testing.T) { + c := &ContainerProfileCacheImpl{} + ns, b := "redis", "redis" + + // Establish base v5, overlay v3. + require.NoError(t, c.checkAndAdvanceVersions(ns, b, []bundle.LeafRef{ + leaf("base", "redis-base", 5), leaf("overlay", "ops", 3), + })) + // A set with a current base but a rolled-back overlay must reject ENTIRELY, + // and must NOT advance the base high-water-mark. + err := c.checkAndAdvanceVersions(ns, b, []bundle.LeafRef{ + leaf("base", "redis-base", 6), leaf("overlay", "ops", 2), + }) + require.ErrorIs(t, err, bundle.ErrFragmentRollback) + // base hwm must still be 5 (not advanced to 6), so a later legit base v5 is fine. + require.NoError(t, c.checkAndAdvanceVersions(ns, b, []bundle.LeafRef{leaf("base", "redis-base", 5)})) + // and base v4 (a rollback of base) is refused. + require.ErrorIs(t, c.checkAndAdvanceVersions(ns, b, []bundle.LeafRef{leaf("base", "redis-base", 4)}), bundle.ErrFragmentRollback) +} + +func TestRollbackGuard_SlotsAreIndependent(t *testing.T) { + c := &ContainerProfileCacheImpl{} + // Same name, different class/namespace/bundle are distinct slots. + require.NoError(t, c.checkAndAdvanceVersions("ns1", "b", []bundle.LeafRef{leaf("overlay", "x", 9)})) + require.NoError(t, c.checkAndAdvanceVersions("ns2", "b", []bundle.LeafRef{leaf("overlay", "x", 1)})) + require.NoError(t, c.checkAndAdvanceVersions("ns1", "other", []bundle.LeafRef{leaf("overlay", "x", 1)})) + require.NoError(t, c.checkAndAdvanceVersions("ns1", "b", []bundle.LeafRef{leaf("base", "x", 1)})) + // but rolling back the original ns1/b/overlay/x slot is refused. + require.ErrorIs(t, c.checkAndAdvanceVersions("ns1", "b", []bundle.LeafRef{leaf("overlay", "x", 8)}), bundle.ErrFragmentRollback) +} + +// Unversioned fragments are version 0 and coexist until a versioned one appears. +func TestRollbackGuard_UnversionedIsZero(t *testing.T) { + c := &ContainerProfileCacheImpl{} + ns, b := "redis", "redis" + require.NoError(t, c.checkAndAdvanceVersions(ns, b, []bundle.LeafRef{leaf("base", "redis-base", 0)})) + require.NoError(t, c.checkAndAdvanceVersions(ns, b, []bundle.LeafRef{leaf("base", "redis-base", 0)})) + // once a v1 is shipped, an unversioned (0) replacement is a rollback. + require.NoError(t, c.checkAndAdvanceVersions(ns, b, []bundle.LeafRef{leaf("base", "redis-base", 1)})) + require.ErrorIs(t, c.checkAndAdvanceVersions(ns, b, []bundle.LeafRef{leaf("base", "redis-base", 0)}), bundle.ErrFragmentRollback) +} + +func TestSigningEnforced_FoldsFlagAndMode(t *testing.T) { + enforce := &bundle.TrustPolicy{Mode: bundle.ModeEnforce} + alert := &bundle.TrustPolicy{Mode: bundle.ModeAlert} + + // enforce-mode policy enforces regardless of the legacy flag. + c := &ContainerProfileCacheImpl{} + c.SetBundleConfig(enforce) + require.True(t, c.signingEnforced()) + + // alert-mode policy does not enforce (unless the legacy flag says so). + c = &ContainerProfileCacheImpl{} + c.SetBundleConfig(alert) + require.False(t, c.signingEnforced()) + + // legacy requireSignedObjects still enforces with no/alert policy (back-compat). + c = &ContainerProfileCacheImpl{} + c.cfg.EnableSignatureVerification = true + require.True(t, c.signingEnforced()) + + // nothing set → not enforced. + c = &ContainerProfileCacheImpl{} + require.False(t, c.signingEnforced()) +} diff --git a/pkg/objectcache/containerprofilecache/signing_disabled.go b/pkg/objectcache/containerprofilecache/signing_disabled.go new file mode 100644 index 0000000000..6dbee8240b --- /dev/null +++ b/pkg/objectcache/containerprofilecache/signing_disabled.go @@ -0,0 +1,36 @@ +//go:build !experimental_signing + +package containerprofilecache + +import ( + "context" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +type signingState struct{} +type signingProvenance struct{} +type signingBinding struct{} + +func (*ContainerProfileCacheImpl) loadSignedBundle(context.Context, string, string, string) (*v1beta1.ContainerProfile, bool, signingProvenance, error) { + return nil, false, signingProvenance{}, nil +} +func (*ContainerProfileCacheImpl) admitSignedProfile(cp *v1beta1.ContainerProfile, _ string) (*v1beta1.ContainerProfile, bool, signingProvenance) { + return cp, false, signingProvenance{} +} +func (*ContainerProfileCacheImpl) requiresSignedProfile() bool { return false } +func (*ContainerProfileCacheImpl) refuseSignedProfile(string, *CachedContainerProfile) {} + +func (*ContainerProfileCacheImpl) handleBundleError(string, *CachedContainerProfile) {} + +func (*ContainerProfileCacheImpl) setSigningBinding(*CachedContainerProfile, string) {} +func (*ContainerProfileCacheImpl) signingBundleName(*CachedContainerProfile) string { return "" } +func (*ContainerProfileCacheImpl) handleProfileFetchError(string, *CachedContainerProfile) {} +func (*ContainerProfileCacheImpl) noteAdoptedProfile(signingProvenance, *v1beta1.ContainerProfile, string) { +} + +func (*ContainerProfileCacheImpl) preserveSignedBundleReference(*CachedContainerProfile, *CachedContainerProfile) { +} + +func (*ContainerProfileCacheImpl) canSkipSignedRefresh(*CachedContainerProfile, signingProvenance) bool { + return true +} diff --git a/pkg/objectcache/containerprofilecache/signing_enabled.go b/pkg/objectcache/containerprofilecache/signing_enabled.go new file mode 100644 index 0000000000..c77beb4b3d --- /dev/null +++ b/pkg/objectcache/containerprofilecache/signing_enabled.go @@ -0,0 +1,144 @@ +//go:build experimental_signing + +package containerprofilecache + +import ( + "context" + "crypto/sha256" + "encoding/json" + "sync" + "sync/atomic" + + "github.com/kubescape/node-agent/pkg/exporters" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Experimental signing state is absent from normal builds. It has no rogue +// artifact writer or governance-alert dependency. +type signingState struct { + tamperAlertExporter exporters.Exporter + tamperEmitted sync.Map + bundleTrustPolicy atomic.Pointer[bundle.TrustPolicy] + bundleRoots sync.Map + bundleTampered sync.Map + bundleVersions sync.Map + bundleNonMembers sync.Map + specDivergence sync.Map +} + +// signingProvenance records admission of the adopted profile. Bundles require +// fresh verification under a replacement policy before retention on errors; +// flat profiles use policy-independent signature verification. +type signingProvenance struct { + verifiedBundlePolicy *bundle.TrustPolicy + verifiedFlat bool + unsigned bool + refused bool + // sourceSpecHash binds admission provenance to the content used for projection. + sourceSpecHash [sha256.Size]byte +} + +// signingBinding preserves the pod label separately from the resolved flat CP. +type signingBinding struct{ bundleName string } + +func (*ContainerProfileCacheImpl) setSigningBinding(entry *CachedContainerProfile, name string) { + entry.bundleName = name +} +func (*ContainerProfileCacheImpl) signingBundleName(entry *CachedContainerProfile) string { + if entry.bundleName != "" { + return entry.bundleName + } + if entry.UserCPRef != nil { + return entry.UserCPRef.Name + } + return "" +} + +// Flat signatures use cryptographic verification independently of bundle policy. +func (c *ContainerProfileCacheImpl) handleProfileFetchError(id string, entry *CachedContainerProfile) { + if c.requiresSignedProfile() && !entry.verifiedFlat && + (entry.verifiedBundlePolicy == nil || entry.verifiedBundlePolicy != c.bundleTrustPolicy.Load()) { + c.refuseSignedProfile(id, entry) + } +} + +func (c *ContainerProfileCacheImpl) noteAdoptedProfile(provenance signingProvenance, cp *v1beta1.ContainerProfile, wlid string) { + if provenance.unsigned { + c.noteUnsignedGoverns(verifyUnsigned, cp, wlid) + } +} + +func (c *ContainerProfileCacheImpl) loadSignedBundle(ctx context.Context, ns, name, wlid string) (*v1beta1.ContainerProfile, bool, signingProvenance, error) { + policy := c.bundleTrustPolicy.Load() + cp, err := c.assembleUserBundleWithPolicy(ctx, ns, name, wlid, policy) + provenance := signingProvenance{} + if cp != nil && err == nil { + provenance.sourceSpecHash, err = profileSpecHash(cp) + if err != nil { + return nil, true, signingProvenance{}, err + } + provenance.verifiedBundlePolicy = policy + } + return cp, cp != nil || err != nil, provenance, err +} + +func (c *ContainerProfileCacheImpl) handleBundleError(id string, entry *CachedContainerProfile) { + if entry.verifiedBundlePolicy == nil || entry.verifiedBundlePolicy != c.bundleTrustPolicy.Load() { + c.refuseSignedProfile(id, entry) + } +} + +func (c *ContainerProfileCacheImpl) requiresSignedProfile() bool { return c.signingEnforced() } + +// Verify the fetched document before flattening grouped sections. Verification +// can restore its embedded signed spec, so never mutate the storage client's copy. +func (c *ContainerProfileCacheImpl) admitSignedProfile(cp *v1beta1.ContainerProfile, wlid string) (*v1beta1.ContainerProfile, bool, signingProvenance) { + if cp == nil { + return nil, false, signingProvenance{} + } + cp = cp.DeepCopy() + verdict := c.verifyUserContainerProfileVerdict(cp, wlid) + if verdict == verifyInvalidEmbedded || (c.signingEnforced() && verdict != verifyOK) { + return nil, true, signingProvenance{} + } + specHash, err := profileSpecHash(cp) + if err != nil { + return nil, true, signingProvenance{} + } + return cp, false, signingProvenance{verifiedFlat: verdict == verifyOK, unsigned: verdict == verifyUnsigned, sourceSpecHash: specHash} +} + +// Refusal removes the previously admitted content from enforcement immediately +// without importing rogue-state classification or RogueArtifact persistence. +// Retain the reference so the next reconciliation can recover after re-signing. +func (c *ContainerProfileCacheImpl) refuseSignedProfile(id string, entry *CachedContainerProfile) { + name := entry.CPName + if entry.UserCPRef != nil { + name = entry.UserCPRef.Name + } + empty := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: entry.Namespace}} + c.rebuildEntryFromSources(id, entry, empty, nil, signingProvenance{refused: true}) +} + +func (*ContainerProfileCacheImpl) preserveSignedBundleReference(entry, prev *CachedContainerProfile) { + if entry.verifiedBundlePolicy != nil && prev.UserCPRef != nil { + entry.UserCPRef = prev.UserCPRef + } +} + +// Hash the admitted spec, after embedded signed content has been restored. +// Grouped documents may cause a conservative rebuild for another section's edit, +// but unchanged resource versions can never attach new trust to old grants. +func profileSpecHash(cp *v1beta1.ContainerProfile) ([sha256.Size]byte, error) { + content, err := json.Marshal(cp.Spec) + if err != nil { + return [sha256.Size]byte{}, err + } + return sha256.Sum256(content), nil +} + +func (*ContainerProfileCacheImpl) canSkipSignedRefresh(entry *CachedContainerProfile, provenance signingProvenance) bool { + return !entry.refused && entry.sourceSpecHash == provenance.sourceSpecHash +} diff --git a/pkg/objectcache/containerprofilecache/signing_integration_test.go b/pkg/objectcache/containerprofilecache/signing_integration_test.go new file mode 100644 index 0000000000..2afedfac14 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/signing_integration_test.go @@ -0,0 +1,134 @@ +//go:build experimental_signing + +package containerprofilecache + +import ( + "context" + "fmt" + "testing" + + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/node-agent/pkg/storage" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" +) + +func TestSigningPolicyReloadDisablesLearnedChecksumSkip(t *testing.T) { + cp := learnedCPWithChecksum("learned", "1", "checksum") + client := &checksumRecordingClient{learned: cp} + c := newReconcilerCache(t, client, newControllableK8sCache(), nil) + entry := seedChecksumEntry(c, "id", cp, "checksum", "") + c.SetBundleConfig(&bundle.TrustPolicy{Mode: bundle.ModeAlert}) + c.refreshOneEntry(context.Background(), "id", entry) + require.Equal(t, []string{"checksum"}, client.checksumsFor(cp.Name)) + + c.SetBundleConfig(&bundle.TrustPolicy{Mode: bundle.ModeEnforce}) + c.refreshOneEntry(context.Background(), "id", entry) + require.Equal(t, []string{"checksum", ""}, client.checksumsFor(cp.Name)) + refused, ok := c.entries.Load("id") + require.True(t, ok) + require.NotEqual(t, entry.Projected, refused.Projected, + "enforcement must remove the unsigned learned projection despite an unchanged checksum") +} + +func TestSigningRefreshRefusesAndRecoversWithoutRogueArtifacts(t *testing.T) { + raw := signedGroupedCP(t) + client := &scriptedProfileClient{byName: map[string]*v1beta1.ContainerProfile{raw.Name: raw}} + c := newReconcilerCache(t, client, newControllableK8sCache(), nil) + c.cfg.EnableSignatureVerification = true + exp := &captureExporter{} + c.SetTamperAlertExporter(exp) + flat := resolveAuthoredContainerSection(raw, "app") + entry := newEntry(flat, "app", "pod", raw.Namespace, "uid") + entry.CPName = "learned" + entry.UserCPRef = &namespacedName{Namespace: raw.Namespace, Name: raw.Name} + entry.UserCPRV = "" + c.entries.Set("id", entry) + c.refreshOneEntry(context.Background(), "id", entry) + verified, _ := c.entries.Load("id") + require.Equal(t, "7", verified.UserCPRV) + + // Even without an RV change verification must precede the cache fast-skip. + raw.Spec.Containers[0].Execs = append(raw.Spec.Containers[0].Execs, v1beta1.ExecCalls{Path: "/bin/evil"}) + c.refreshOneEntry(context.Background(), "id", verified) + refused, _ := c.entries.Load("id") + require.Empty(t, refused.UserCPRV) + require.NotEqual(t, verified.Projected, refused.Projected) + require.NotNil(t, refused.UserCPRef, "reconciliation must retain the recovery target") + require.Len(t, exp.ruleAlerts(), 1) + require.Equal(t, "R1016", exp.ruleAlerts()[0].GetRuleId()) + + require.NoError(t, signature.SignObjectDisableKeyless(profiles.NewContainerProfileAdapter(raw))) + raw.ResourceVersion = "8" + c.refreshOneEntry(context.Background(), "id", refused) + healed, _ := c.entries.Load("id") + require.Equal(t, "8", healed.UserCPRV) + for _, alert := range exp.ruleAlerts() { + require.NotEqual(t, "R1017", alert.GetRuleId()) + } +} + +func TestSigningEmbeddedContentDoesNotMutateStorageObject(t *testing.T) { + cp := signedEmbeddedCP(t) + cp.Spec.Execs = append(cp.Spec.Execs, v1beta1.ExecCalls{Path: "/bin/stored-edit"}) + c := &ContainerProfileCacheImpl{} + admitted, refused, _ := c.admitSignedProfile(cp, "") + require.False(t, refused) + require.Len(t, admitted.Spec.Execs, 1) + require.Len(t, cp.Spec.Execs, 2) +} + +func TestSigningPolicyReloadRejectsUnsignedCacheOnBundleError(t *testing.T) { + cp := authoredCP("authored", "/bin/unsigned", "1") + client := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{cp}} + c := newReconcilerCache(t, client, newControllableK8sCache(), nil) + c.SetBundleConfig(&bundle.TrustPolicy{Mode: bundle.ModeAlert}) + entry := newEntry(cp, "app", "pod", cp.Namespace, "uid") + entry.CPName = "learned" + entry.UserCPRef = &namespacedName{Namespace: cp.Namespace, Name: cp.Name} + c.entries.Set("id", entry) + c.refreshOneEntry(context.Background(), "id", entry) + admitted, _ := c.entries.Load("id") + require.Equal(t, "1", admitted.UserCPRV) + fragment := cp.DeepCopy() + fragment.Name = "invalid-base" + fragment.Labels = map[string]string{bundle.LabelBundle: cp.Name, bundle.LabelFragmentClass: "base"} + client.ContainerProfiles = append(client.ContainerProfiles, fragment) + c.SetBundleConfig(&bundle.TrustPolicy{Mode: bundle.ModeEnforce}) + _, err := c.assembleUserBundle(context.Background(), cp.Namespace, cp.Name, "") + require.Error(t, err) + c.refreshOneEntry(context.Background(), "id", admitted) + refused, ok := c.entries.Load("id") + require.True(t, ok) + require.Empty(t, refused.UserCPRV) + require.Equal(t, admitted.UserCPRef, refused.UserCPRef) + require.NotEqual(t, admitted.Projected, refused.Projected) +} + +func TestMalformedSignatureAnnotationsEmitTamperAlert(t *testing.T) { + for _, annotation := range []string{signature.AnnotationSignature, signature.AnnotationCertificate, signature.AnnotationTimestamp} { + for _, enforce := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/enforce=%t", annotation, enforce), func(t *testing.T) { + cp := authoredCP("authored", "/bin/signed", "1") + require.NoError(t, signature.SignObjectDisableKeyless(profiles.NewContainerProfileAdapter(cp))) + cp.Spec.Execs = []v1beta1.ExecCalls{{Path: "/bin/edited"}} + cp.Annotations[annotation] = "!malformed!" + c := &ContainerProfileCacheImpl{} + mode := bundle.ModeAlert + if enforce { + mode = bundle.ModeEnforce + } + c.SetBundleConfig(&bundle.TrustPolicy{Mode: mode}) + exp := &captureExporter{} + c.SetTamperAlertExporter(exp) + _, refused, provenance := c.admitSignedProfile(cp, "wlid://cluster-a/namespace-default/deployment-test") + require.Equal(t, enforce, refused) + require.False(t, provenance.verifiedFlat) + require.Len(t, exp.ruleAlerts(), 1, "malformed annotations on signed content must produce R1016 even in alert mode") + require.Equal(t, "R1016", exp.ruleAlerts()[0].GetRuleId()) + }) + } + } +} diff --git a/pkg/objectcache/containerprofilecache/signing_refresh_regression_test.go b/pkg/objectcache/containerprofilecache/signing_refresh_regression_test.go new file mode 100644 index 0000000000..e8a9dbb79d --- /dev/null +++ b/pkg/objectcache/containerprofilecache/signing_refresh_regression_test.go @@ -0,0 +1,238 @@ +//go:build experimental_signing + +package containerprofilecache + +import ( + "context" + "fmt" + "testing" + + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/node-agent/pkg/storage" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" +) + +type signingFailureClient struct { + *storage.StorageHttpClientMock + failName string +} + +func (s *signingFailureClient) GetContainerProfile(ctx context.Context, ns, name string) (*v1beta1.ContainerProfile, error) { + if name == s.failName { + return nil, fmt.Errorf("temporary fetch failure") + } + return s.StorageHttpClientMock.GetContainerProfile(ctx, ns, name) +} + +func TestSigningReloadFetchFailure(t *testing.T) { + for _, signed := range []bool{false, true} { + for _, source := range []string{"learned", "authored"} { + t.Run(fmt.Sprintf("signed=%t/%s", signed, source), func(t *testing.T) { + cp := classicalCP("authored-nginx", "default", "/bin/authored") + if signed { + require.NoError(t, signature.SignObjectDisableKeyless(profiles.NewContainerProfileAdapter(cp))) + } + client := &signingFailureClient{StorageHttpClientMock: &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{cp}}} + c, k8s := newTestCache(t, client) + c.SetBundleConfig(&bundle.TrustPolicy{Mode: bundle.ModeAlert}) + primeSharedData(t, k8s, "id", "wlid://cluster-a/namespace-default/deployment-test") + bindToProfile(t, c, "id", "authored") + entry, ok := c.entries.Load("id") + require.True(t, ok) + require.True(t, execEnforced(c, "id", "/bin/authored")) + client.failName = entry.CPName + if source == "authored" { + client.failName = entry.UserCPRef.Name + } + c.SetBundleConfig(&bundle.TrustPolicy{Mode: bundle.ModeEnforce}) + c.refreshAllEntries(context.Background()) + require.Equal(t, signed, execEnforced(c, "id", "/bin/authored")) + current, ok := c.entries.Load("id") + require.True(t, ok) + require.Equal(t, entry.UserCPRef, current.UserCPRef) + }) + } + } +} + +func TestSigningBundleInstalledAfterPerContainerProfile(t *testing.T) { + for _, refuseFirst := range []bool{false, true} { + t.Run(fmt.Sprintf("refusedFirst=%t", refuseFirst), func(t *testing.T) { + cp := classicalCP("bundle37-nginx", "default", "/bin/flat") + client := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{cp}} + c, k8s := newTestCache(t, client) + primeSharedData(t, k8s, "id", "wlid://cluster-a/namespace-default/deployment-test") + bindToProfile(t, c, "id", "bundle37") + entry, ok := c.entries.Load("id") + require.True(t, ok) + require.Equal(t, "bundle37-nginx", entry.UserCPRef.Name) + if refuseFirst { + c.SetBundleConfig(&bundle.TrustPolicy{Mode: bundle.ModeEnforce}) + c.refreshAllEntries(context.Background()) + require.False(t, execEnforced(c, "id", "/bin/flat")) + } + key := bkey(t) + base := bfragIn(t, "new-base", "base", "default", "bundle37", v1beta1.ContainerProfileSpec{Execs: []v1beta1.ExecCalls{{Path: "/bin/bundle"}}}, key) + signer, err := bundle.SignerID(base) + require.NoError(t, err) + client.ContainerProfiles = append(client.ContainerProfiles, base) + c.SetBundleConfig(&bundle.TrustPolicy{Mode: bundle.ModeEnforce, Classes: map[bundle.FragmentClass]bundle.ClassPolicy{bundle.ClassBase: {Signers: []string{signer}, AllowedSpecPaths: []string{"execs"}}}}) + c.refreshAllEntries(context.Background()) + require.True(t, execEnforced(c, "id", "/bin/bundle")) + require.False(t, execEnforced(c, "id", "/bin/flat")) + adopted, ok := c.entries.Load("id") + require.True(t, ok) + require.Equal(t, "bundle37-nginx", adopted.UserCPRef.Name) + }) + } +} + +func TestUnsignedAlertOnlyAfterAdoption(t *testing.T) { + for _, shape := range []string{"missing-section", "learned", "adopted"} { + t.Run(shape, func(t *testing.T) { + cp := classicalCP("authored-nginx", "default", "/bin/authored") + if shape == "missing-section" { + cp.Spec.Containers = []v1beta1.ContainerProfileContainer{{Name: "other"}} + } + if shape == "learned" { + cp.Annotations = map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed} + } + c, k8s := newTestCache(t, &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{cp}}) + exp := &captureExporter{} + c.SetTamperAlertExporter(exp) + primeSharedData(t, k8s, "id", "wlid://cluster-a/namespace-default/deployment-test") + bindToProfile(t, c, "id", "authored") + if shape == "adopted" { + require.Len(t, exp.ruleAlerts(), 1) + require.Equal(t, "R1018", exp.ruleAlerts()[0].GetRuleId()) + } else { + require.Empty(t, exp.ruleAlerts()) + } + }) + } +} + +func TestSigningBundleFetchFailurePreservesOnlyCurrentPolicy(t *testing.T) { + key := bkey(t) + base := bfragIn(t, "base", "base", "default", "bundle37", v1beta1.ContainerProfileSpec{Execs: []v1beta1.ExecCalls{{Path: "/bin/bundle"}}}, key) + signer, err := bundle.SignerID(base) + require.NoError(t, err) + policy := &bundle.TrustPolicy{Mode: bundle.ModeEnforce, Classes: map[bundle.FragmentClass]bundle.ClassPolicy{bundle.ClassBase: {Signers: []string{signer}, AllowedSpecPaths: []string{"execs"}}}} + client := &signingFailureClient{StorageHttpClientMock: &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{base}}} + c, k8s := newTestCache(t, client) + c.SetBundleConfig(policy) + primeSharedData(t, k8s, "id", "wlid://cluster-a/namespace-default/deployment-test") + bindToProfile(t, c, "id", "bundle37") + entry, ok := c.entries.Load("id") + require.True(t, ok) + client.failName = entry.CPName + c.refreshAllEntries(context.Background()) + require.True(t, execEnforced(c, "id", "/bin/bundle")) + replacement := *policy + c.SetBundleConfig(&replacement) + c.refreshAllEntries(context.Background()) + require.False(t, execEnforced(c, "id", "/bin/bundle")) + client.failName = "" + c.refreshAllEntries(context.Background()) + require.True(t, execEnforced(c, "id", "/bin/bundle")) +} + +func TestUnsignedAlertOnlyAfterRefreshAdoption(t *testing.T) { + for _, shape := range []string{"missing-section", "learned", "adopted"} { + t.Run(shape, func(t *testing.T) { + cp := classicalCP("authored-nginx", "default", "/bin/authored") + client := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{cp}} + c, k8s := newTestCache(t, client) + primeSharedData(t, k8s, "id", "wlid://cluster-a/namespace-default/deployment-test") + bindToProfile(t, c, "id", "authored") + exp := &captureExporter{} + c.SetTamperAlertExporter(exp) + if shape == "missing-section" { + cp.Spec.Containers = []v1beta1.ContainerProfileContainer{{Name: "other"}} + } + if shape == "learned" { + cp.Annotations = map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed} + } + c.refreshAllEntries(context.Background()) + if shape == "adopted" { + require.Len(t, exp.ruleAlerts(), 1) + require.Equal(t, "R1018", exp.ruleAlerts()[0].GetRuleId()) + } else { + require.Empty(t, exp.ruleAlerts()) + } + }) + } +} + +func TestSigningRefusalRecoversWithEmptyResourceVersion(t *testing.T) { + cp := classicalCP("authored-nginx", "default", "/bin/recovered") + client := &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{cp}} + c, k8s := newTestCache(t, client) + primeSharedData(t, k8s, "id", "wlid://cluster-a/namespace-default/deployment-test") + bindToProfile(t, c, "id", "authored") + require.True(t, execEnforced(c, "id", "/bin/recovered")) + c.SetBundleConfig(&bundle.TrustPolicy{Mode: bundle.ModeEnforce}) + c.refreshAllEntries(context.Background()) + require.False(t, execEnforced(c, "id", "/bin/recovered")) + refused, ok := c.entries.Load("id") + require.True(t, ok) + require.Empty(t, refused.RV) + require.Empty(t, refused.UserCPRV) + require.Empty(t, refused.Checksum) + require.NoError(t, signature.SignObjectDisableKeyless(profiles.NewContainerProfileAdapter(cp))) + require.Empty(t, cp.ResourceVersion) + c.refreshAllEntries(context.Background()) + require.True(t, execEnforced(c, "id", "/bin/recovered"), "successful re-signing must install the recovered grants despite unchanged empty RVs") + recovered, ok := c.entries.Load("id") + require.True(t, ok) + require.Equal(t, helpersv1.Completed, recovered.State.Status) + require.Equal(t, refused.UserCPRef, recovered.UserCPRef) + // The restored projection stays usable on subsequent unchanged-source ticks. + c.refreshAllEntries(context.Background()) + require.True(t, execEnforced(c, "id", "/bin/recovered")) +} + +func TestSignedRefreshRebuildsChangedContentWithUnchangedResourceVersion(t *testing.T) { + for _, rv := range []string{"", "unchanged"} { + for _, initiallySigned := range []bool{false, true} { + for _, failedSource := range []string{"learned", "authored"} { + t.Run(fmt.Sprintf("rv=%s/signed=%t/failure=%s", rv, initiallySigned, failedSource), func(t *testing.T) { + cp := classicalCP("authored-nginx", "default", "/bin/old") + cp.ResourceVersion = rv + if initiallySigned { + require.NoError(t, signature.SignObjectDisableKeyless(profiles.NewContainerProfileAdapter(cp))) + } + client := &signingFailureClient{StorageHttpClientMock: &storage.StorageHttpClientMock{ContainerProfiles: []*v1beta1.ContainerProfile{cp}}} + c, k8s := newTestCache(t, client) + c.SetBundleConfig(&bundle.TrustPolicy{Mode: bundle.ModeAlert}) + primeSharedData(t, k8s, "id", "wlid://cluster-a/namespace-default/deployment-test") + bindToProfile(t, c, "id", "authored") + require.True(t, execEnforced(c, "id", "/bin/old")) + + replacement := classicalCP("authored-nginx", "default", "/bin/signed") + replacement.ResourceVersion = rv + require.NoError(t, signature.SignObjectDisableKeyless(profiles.NewContainerProfileAdapter(replacement))) + client.ContainerProfiles = []*v1beta1.ContainerProfile{replacement} + c.refreshAllEntries(context.Background()) + require.False(t, execEnforced(c, "id", "/bin/old"), "newly verified content must replace the previous projection despite matching RVs") + require.True(t, execEnforced(c, "id", "/bin/signed")) + + entry, ok := c.entries.Load("id") + require.True(t, ok) + client.failName = entry.CPName + if failedSource == "authored" { + client.failName = entry.UserCPRef.Name + } + c.SetBundleConfig(&bundle.TrustPolicy{Mode: bundle.ModeEnforce}) + c.refreshAllEntries(context.Background()) + require.False(t, execEnforced(c, "id", "/bin/old")) + require.True(t, execEnforced(c, "id", "/bin/signed"), "fetch failure may retain only the projection built from authenticated content") + }) + } + } + } +} diff --git a/pkg/objectcache/containerprofilecache/spec_divergence_signing.go b/pkg/objectcache/containerprofilecache/spec_divergence_signing.go new file mode 100644 index 0000000000..b626c41228 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/spec_divergence_signing.go @@ -0,0 +1,40 @@ +//go:build experimental_signing + +package containerprofilecache + +import ( + "strings" + + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +// reportSpecDivergence warns when a signed object's STORED spec no longer +// matches its embedded signed content: what kubectl shows is not what is +// enforced, in either direction. Observability only — never part of the +// admission decision, and a comparison failure reports nothing. One warning +// per distinct stored content (re-armed when the divergence clears or the +// stored content changes again). Path names and counts only, never values. +func (c *ContainerProfileCacheImpl) reportSpecDivergence(key string, cp *v1beta1.ContainerProfile, bundleName, ns string) { + paths, storedHash, ok := bundle.FragmentStoredDivergence(cp) + if !ok { + return + } + if len(paths) == 0 { + c.specDivergence.Delete(key) + return + } + if prev, loaded := c.specDivergence.Load(key); loaded && prev.(string) == storedHash { + return + } + c.specDivergence.Store(key, storedHash) + logger.L().Warning("signed fragment stored spec diverges from the signed content: the stored spec is display-only and is NOT enforced; enforcement uses the signed content", + helpers.String("fragment", cp.Name), + helpers.String("bundle", bundleName), + helpers.String("namespace", ns), + helpers.String("divergingPaths", strings.Join(paths, ",")), + helpers.Int("pathCount", len(paths)), + helpers.String("hint", "the object was edited after signing; re-apply the signed artifact to realign what kubectl shows with what is enforced")) +} diff --git a/pkg/objectcache/containerprofilecache/spec_divergence_test.go b/pkg/objectcache/containerprofilecache/spec_divergence_test.go new file mode 100644 index 0000000000..7fda068dfe --- /dev/null +++ b/pkg/objectcache/containerprofilecache/spec_divergence_test.go @@ -0,0 +1,82 @@ +//go:build experimental_signing + +package containerprofilecache + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "io" + "os" + "strings" + "testing" + + logger "github.com/kubescape/go-logger" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func captureLogs(t *testing.T, fn func()) string { + t.Helper() + origStderr, origStdout := os.Stderr, os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stderr, os.Stdout = w, w + logger.InitLogger("pretty") + fn() + w.Close() + os.Stderr, os.Stdout = origStderr, origStdout + logger.InitLogger("pretty") + b, _ := io.ReadAll(r) + return string(b) +} + +func testECDSAKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + return k +} + +func signedEmbeddedCP(t *testing.T) *v1beta1.ContainerProfile { + t.Helper() + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "frag", Namespace: "redis", Labels: map[string]string{ + "signature.kubescape.io/bundle": "redis", "signature.kubescape.io/fragment-class": "base"}}, + Spec: v1beta1.ContainerProfileSpec{Execs: []v1beta1.ExecCalls{{Path: "/bin/x"}}}, + } + require.NoError(t, signature.SignObject(profiles.NewContainerProfileAdapter(cp), signature.WithPrivateKey(testECDSAKey(t)), signature.WithEmbedContent(true))) + return cp +} + +// One warning per distinct stored content; a clean pass re-arms; a new +// distinct edit warns again (AC #70-3). +func TestReportSpecDivergence_DedupAndRearm(t *testing.T) { + c := &ContainerProfileCacheImpl{} + cp := signedEmbeddedCP(t) + key := "bundle/redis/redis/frag" + + out := captureLogs(t, func() { c.reportSpecDivergence(key, cp, "redis", "redis") }) + require.NotContains(t, out, "diverges", "clean fragment must not warn") + + cp.Spec.Execs = append(cp.Spec.Execs, v1beta1.ExecCalls{Path: "/bin/backdoor"}) + out = captureLogs(t, func() { + c.reportSpecDivergence(key, cp, "redis", "redis") + c.reportSpecDivergence(key, cp, "redis", "redis") + c.reportSpecDivergence(key, cp, "redis", "redis") + }) + require.Equal(t, 1, strings.Count(out, "stored spec diverges"), "same stored content must warn exactly once") + require.Contains(t, out, "execs") + require.NotContains(t, out, "/bin/backdoor", "spec values must never reach the log") + + cp.Spec.Execs = cp.Spec.Execs[:1] + out = captureLogs(t, func() { c.reportSpecDivergence(key, cp, "redis", "redis") }) + require.NotContains(t, out, "diverges", "reverting must clear, not warn") + + cp.Spec.Execs = append(cp.Spec.Execs, v1beta1.ExecCalls{Path: "/bin/other"}) + out = captureLogs(t, func() { c.reportSpecDivergence(key, cp, "redis", "redis") }) + require.Equal(t, 1, strings.Count(out, "stored spec diverges"), "a distinct later edit must warn again") +} diff --git a/pkg/objectcache/containerprofilecache/tamper_alert_signing.go b/pkg/objectcache/containerprofilecache/tamper_alert_signing.go new file mode 100644 index 0000000000..611c85a6d3 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/tamper_alert_signing.go @@ -0,0 +1,238 @@ +//go:build experimental_signing + +// Tamper detection for user-supplied ContainerProfile overlays loaded into the +// ContainerProfileCache. +// +// When a pod references a signed ContainerProfile via the +// `kubescape.io/user-defined-profile` label, this path re-verifies the +// signature on every cache load and emits an R1016 "Signed profile tampered" +// alert via the rule-alert exporter when the signature is present but no longer +// valid (the profile was modified after signing). +// +// AP/NN → ContainerProfile note: this replaces the legacy pair of verify +// methods (verifyUserApplicationProfile + verifyUserNetworkNeighborhood) with a +// single verifyUserContainerProfile — one fetch, one verify, one R1016 site — +// because a ContainerProfile is the unified successor to the AP+NN overlay +// pair. The R1016 alert shape is unchanged so existing component tests +// (Test_31_TamperDetectionAlert) keep matching. +package containerprofilecache + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/armosec/armoapi-go/armotypes" + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + "github.com/kubescape/node-agent/pkg/exporters" + "github.com/kubescape/node-agent/pkg/rulemanager/types" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +// tamperKey uniquely identifies a tampered profile occurrence. ResourceVersion +// is included so that an attacker editing the resource (which changes RV) is +// re-flagged on the next reconcile cycle, while a long-lived broken profile +// only emits one R1016 across the cache's lifetime. +func tamperKey(kind, namespace, name, resourceVersion string) string { + return kind + "|" + namespace + "/" + name + "@" + resourceVersion +} + +// SetTamperAlertExporter wires the rule-alert exporter used to emit R1016. +// Optional — when nil, signature verification still runs (and is logged) but no +// alert is emitted. Production wiring lives in cmd/main.go after the alert +// exporter is constructed. +func (c *ContainerProfileCacheImpl) SetTamperAlertExporter(e exporters.Exporter) { + c.tamperAlertExporter = e +} + +// verifyVerdict maps a signature check to its truth-table state (A5-A8). +type verifyVerdict int + +const ( + verifyOK verifyVerdict = iota // signed+verifies, or unsigned handled by caller mode + verifyUnsigned // no signature present (A5) + verifyTampered // signature mismatch — real tamper (A7) + verifyOperational // verification machinery failed — NOT tamper (A8) + verifyInvalidEmbedded // signed content cannot be interpreted; never use the mutable live spec +) + +// Invalid verified embedded content is refused in every mode. Other verdicts +// follow the configured enforcement mode. +func (c *ContainerProfileCacheImpl) verifyUserContainerProfile(profile *v1beta1.ContainerProfile, wlid string) bool { + verdict := c.verifyUserContainerProfileVerdict(profile, wlid) + return verdict != verifyInvalidEmbedded && (verdict == verifyOK || !c.signingEnforced()) +} + +// Verdict never gates by mode; R1016 emitted (deduped per RV) on real tamper. +func (c *ContainerProfileCacheImpl) verifyUserContainerProfileVerdict(profile *v1beta1.ContainerProfile, wlid string) verifyVerdict { + if profile == nil { + return verifyOK + } + adapter := profiles.NewContainerProfileAdapter(profile) + if !signature.IsSigned(adapter) { + if c.signingEnforced() { + logger.L().Warning("user-defined ContainerProfile refused: signature verification is required and the profile is unsigned", + helpers.String("profile", profile.Name), + helpers.String("namespace", profile.Namespace), + helpers.String("wlid", wlid)) + } + return verifyUnsigned + } + key := tamperKey("ContainerProfile", profile.Namespace, profile.Name, profile.ResourceVersion) + // AllowUntrusted: accept self-signed/local-CA signatures as long as the + // signature itself verifies against the cert in the annotations. We only + // want to flag actual tampering, not the absence of a Sigstore Fulcio trust + // chain. Matches `cmd/sign-object`'s default verifier. + err := signature.VerifyObjectAllowUntrusted(adapter) + if err == nil { + // Verified clean — clear any prior emit so future tampers re-alert. + c.tamperEmitted.Delete(key) + // Enforce the VERIFIED content: if the signature covers embedded content + // (server-normalised spec differs from what was signed), replace the live + // spec with the embedded one so what's enforced is exactly what was + // signed — never the mutable live spec. Verification already bound the + // embedded content to this object's name/namespace, so this is safe. + embSpec, present, decodeErr := embeddedContainerProfileSpec(profile) + if decodeErr != nil { + logger.L().Warning("signed ContainerProfile embedded spec is invalid; refusing mutable stored spec", + helpers.String("profile", profile.Name), helpers.String("namespace", profile.Namespace), helpers.Error(decodeErr)) + return verifyInvalidEmbedded + } + if present { + c.reportSpecDivergence("flat/"+profile.Namespace+"/"+profile.Name, profile, "", profile.Namespace) + profile.Spec = embSpec + } + return verifyOK + } + // Invalid signatures, including malformed signature annotations, raise R1016. + // Hash-computation and verifier-construction failures are operational errors + // and do not indicate tampering. + if !errors.Is(err, signature.ErrSignatureMismatch) { + logger.L().Warning("user-defined ContainerProfile signature verification operational error (NOT tamper)", + helpers.String("profile", profile.Name), + helpers.String("namespace", profile.Namespace), + helpers.String("wlid", wlid), + helpers.Error(err)) + // A8: transient. + return verifyOperational + } + // Real tamper. + logger.L().Warning("user-defined ContainerProfile signature mismatch (tamper detected)", + helpers.String("profile", profile.Name), + helpers.String("namespace", profile.Namespace), + helpers.String("wlid", wlid), + helpers.Error(err)) + // Dedup: emit R1016 only on first transition to invalid for this + // (kind, ns, name, resourceVersion). Otherwise the refresh loop would alert + // every reconcile cycle, once per container ref. + if _, alreadyEmitted := c.tamperEmitted.LoadOrStore(key, struct{}{}); !alreadyEmitted { + c.emitTamperAlert(profile.Name, profile.Namespace, wlid, "ContainerProfile", err) + } + return verifyTampered +} + +// emitTamperAlert sends a single R1016 "Signed profile tampered" alert through +// the rule-alert exporter. No-op when the exporter is unset. +// +// `wlid` should be the authoritative workload identifier the caller has on hand +// (sharedData.Wlid) — using the runtime containerID instead loses workload +// kind/name/cluster attribution because GenericRuleFailure.SetWorkloadDetails() +// parses it as a WLID. +func (c *ContainerProfileCacheImpl) emitTamperAlert(profileName, namespace, wlid, objectKind string, verifyErr error) { + if c.tamperAlertExporter == nil { + return + } + + ruleFailure := &types.GenericRuleFailure{ + BaseRuntimeAlert: armotypes.BaseRuntimeAlert{ + AlertName: "Signed profile tampered", + InfectedPID: 1, + Severity: 10, + FixSuggestions: "Investigate who modified the " + objectKind + " '" + profileName + "' in namespace '" + namespace + "'. Re-sign the profile after verifying its contents.", + // No kernel event backs a tamper verdict. + Timestamp: time.Now(), + }, + AlertType: armotypes.AlertTypeRule, + RuntimeProcessDetails: armotypes.ProcessTree{ + ProcessTree: armotypes.Process{ + PID: 1, + Comm: "node-agent", + }, + }, + RuleAlert: armotypes.RuleAlert{ + RuleDescription: fmt.Sprintf("Signed %s '%s' in namespace '%s' has been tampered with: %v", + objectKind, profileName, namespace, verifyErr), + }, + RuntimeAlertK8sDetails: armotypes.RuntimeAlertK8sDetails{ + Namespace: namespace, + }, + RuleID: "R1016", + } + + ruleFailure.SetWorkloadDetails(wlid) + + c.tamperAlertExporter.SendRuleAlert(ruleFailure) +} + +func unsignedKey(profile *v1beta1.ContainerProfile) string { + return "unsigned|" + tamperKey("ContainerProfile", profile.Namespace, profile.Name, "") +} + +func (c *ContainerProfileCacheImpl) noteUnsignedGoverns(verdict verifyVerdict, profile *v1beta1.ContainerProfile, wlid string) { + if verdict != verifyUnsigned || c.signingEnforced() || profile == nil || c.tamperAlertExporter == nil { + return + } + key := unsignedKey(profile) + if _, already := c.tamperEmitted.LoadOrStore(key, struct{}{}); already { + return + } + ruleFailure := &types.GenericRuleFailure{ + BaseRuntimeAlert: armotypes.BaseRuntimeAlert{ + AlertName: "Unsigned profile governs", + InfectedPID: 1, + Severity: 1, + FixSuggestions: "ContainerProfile '" + profile.Name + "' in namespace '" + profile.Namespace + "' governs its workload without a signature. Sign it with a trusted key before enforcement is turned on.", + Timestamp: time.Now(), + }, + AlertType: armotypes.AlertTypeRule, + RuntimeProcessDetails: armotypes.ProcessTree{ + ProcessTree: armotypes.Process{PID: 1, Comm: "node-agent"}, + }, + RuleAlert: armotypes.RuleAlert{ + RuleDescription: fmt.Sprintf("ContainerProfile '%s' in namespace '%s' governs while unsigned; signature verification is not enforced", profile.Name, profile.Namespace), + }, + RuntimeAlertK8sDetails: armotypes.RuntimeAlertK8sDetails{Namespace: profile.Namespace}, + RuleID: "R1018", + } + ruleFailure.SetWorkloadDetails(wlid) + c.tamperAlertExporter.SendRuleAlert(ruleFailure) +} + +// embeddedContainerProfileSpec returns the ContainerProfileSpec from a signed +// CP's embedded content (signature.kubescape.io/content), when present. The +// embedded content is the verified source of truth — enforcing it (rather than +// the mutable live spec) closes the gap where a validly-signed embedded blob is +// stapled onto an object whose live spec was then tampered. Returns present=false +// when there is no embedded content (legacy sign-after-roundtrip artifacts, +// where the live spec IS the signed spec). +func embeddedContainerProfileSpec(profile *v1beta1.ContainerProfile) (v1beta1.ContainerProfileSpec, bool, error) { + adapter := profiles.NewContainerProfileAdapter(profile) + embedded, present, err := signature.EmbeddedContent(adapter) + if !present || err != nil { + return v1beta1.ContainerProfileSpec{}, present, err + } + var view struct { + Spec *v1beta1.ContainerProfileSpec `json:"spec"` + } + if err := json.Unmarshal(embedded, &view); err != nil { + return v1beta1.ContainerProfileSpec{}, true, err + } + if view.Spec == nil { + return v1beta1.ContainerProfileSpec{}, true, fmt.Errorf("embedded content has no profile spec") + } + return *view.Spec, true, nil +} diff --git a/pkg/objectcache/containerprofilecache/tamper_alert_test.go b/pkg/objectcache/containerprofilecache/tamper_alert_test.go new file mode 100644 index 0000000000..2a9aa7fdd2 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/tamper_alert_test.go @@ -0,0 +1,245 @@ +//go:build experimental_signing + +// Unit tests for the ContainerProfile tamper-detection path. Ported from the +// legacy AP/NN tamper tests (fix/sign-object-release-v0.0.3) to the single +// verifyUserContainerProfile method. They pin: the tamper-vs-operational error +// classification, the R1016 emit + dedup wiring, and strict-mode drop. +package containerprofilecache + +import ( + "encoding/json" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/hostfimsensor" + "github.com/kubescape/node-agent/pkg/malwaremanager" + rmtypes "github.com/kubescape/node-agent/pkg/rulemanager/types" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// captureExporter records every SendRuleAlert call for assertion in tests. +type captureExporter struct { + mu sync.Mutex + alerts []rmtypes.RuleFailure +} + +func (e *captureExporter) SendRuleAlert(r rmtypes.RuleFailure) { + e.mu.Lock() + defer e.mu.Unlock() + e.alerts = append(e.alerts, r) +} +func (e *captureExporter) SendMalwareAlert(_ malwaremanager.MalwareResult) {} +func (e *captureExporter) SendFimAlerts(_ []hostfimsensor.FimEvent) {} +func (e *captureExporter) ruleAlerts() []rmtypes.RuleFailure { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]rmtypes.RuleFailure, len(e.alerts)) + copy(out, e.alerts) + return out +} + +func signedCP(name, ns, rv string) *v1beta1.ContainerProfile { + return &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + ResourceVersion: rv, + UID: types.UID("cp-uid-" + name), + }, + Spec: v1beta1.ContainerProfileSpec{ + Architectures: []string{"amd64"}, + Execs: []v1beta1.ExecCalls{{Path: "/bin/sh", Args: []string{"/bin/sh"}}}, + }, + } +} + +// TestVerifyClassification_ErrSignatureMismatchValue is a smoke test that the +// sentinel exists with its canonical message, so log/alert pipelines matching +// the substring keep working. +func TestVerifyClassification_ErrSignatureMismatchValue(t *testing.T) { + if signature.ErrSignatureMismatch == nil { + t.Fatalf("signature.ErrSignatureMismatch is nil — sentinel was removed") + } + if signature.ErrSignatureMismatch.Error() != "signature verification failed" { + t.Errorf("sentinel message changed: %q", signature.ErrSignatureMismatch.Error()) + } +} + +// TestVerifyClassification_OperationalErrorDistinguishable confirms operational +// errors do NOT match ErrSignatureMismatch, so the verify path routes around +// the dedup map + emit. +func TestVerifyClassification_OperationalErrorDistinguishable(t *testing.T) { + for _, tc := range []struct { + name string + err error + }{ + {"hash computation failure", fmt.Errorf("failed to compute content hash: %w", errors.New("io error"))}, + {"verifier construction failure", fmt.Errorf("failed to create verifier: %w", errors.New("missing root certs"))}, + } { + t.Run(tc.name, func(t *testing.T) { + if errors.Is(tc.err, signature.ErrSignatureMismatch) { + t.Errorf("operational error %q matched ErrSignatureMismatch", tc.err) + } + }) + } +} + +// TestVerifyCP_Unsigned_ReturnsTrue: signing is opt-in; an unsigned CP passes +// without alerting. +func TestVerifyCP_Unsigned_ReturnsTrue(t *testing.T) { + profile := signedCP("unsigned", "ns", "1") + exporter := &captureExporter{} + c := &ContainerProfileCacheImpl{} + c.SetTamperAlertExporter(exporter) + if !c.verifyUserContainerProfile(profile, "wlid://test/cluster/ns/Pod/p") { + t.Errorf("unsigned CP returned false; want true") + } + if got := len(exporter.ruleAlerts()); got != 0 { + t.Errorf("unsigned CP emitted %d alerts; want 0", got) + } +} + +// TestVerifyCP_TamperedProfile_PopulatesDedupMap: sign a real CP, mutate its +// spec, verify → dedup map carries the key; re-sign at the same RV clears it. +func TestVerifyCP_TamperedProfile_PopulatesDedupMap(t *testing.T) { + profile := signedCP("tampered", "test-ns", "42") + adapter := profiles.NewContainerProfileAdapter(profile) + if err := signature.SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("sign profile: %v", err) + } + if !signature.IsSigned(adapter) { + t.Fatalf("post-Sign IsSigned returned false") + } + + // Tamper: mutate spec content after signing. + profile.Spec.Execs[0].Path = "/bin/evil" + + c := &ContainerProfileCacheImpl{} + if !c.verifyUserContainerProfile(profile, "wlid://test/cluster/ns/Pod/p") { + t.Errorf("verify returned false; expected true (permissive mode)") + } + key := tamperKey("ContainerProfile", profile.Namespace, profile.Name, profile.ResourceVersion) + if _, found := c.tamperEmitted.Load(key); !found { + t.Errorf("tamperEmitted missing key %q after a real tamper", key) + } + + // Re-sign over the mutated content at the SAME RV → verify-clean branch + // must Delete the dedup entry. + if err := signature.SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("re-sign profile: %v", err) + } + if !c.verifyUserContainerProfile(profile, "wlid://test/cluster/ns/Pod/p") { + t.Errorf("verify after re-sign returned false; expected true") + } + if _, found := c.tamperEmitted.Load(key); found { + t.Errorf("tamperEmitted still has key %q after successful re-verify; verify-clean path must Delete it", key) + } +} + +// TestVerifyCP_TamperedProfile_EmitsR1016ViaExporter: one R1016 per tamper +// event, deduped per RV, re-alerts on RV bump. +func TestVerifyCP_TamperedProfile_EmitsR1016ViaExporter(t *testing.T) { + profile := signedCP("tampered-emit", "test-ns", "1") + adapter := profiles.NewContainerProfileAdapter(profile) + if err := signature.SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("sign profile: %v", err) + } + profile.Spec.Execs[0].Path = "/bin/evil" + + exporter := &captureExporter{} + c := &ContainerProfileCacheImpl{} + c.SetTamperAlertExporter(exporter) + + c.verifyUserContainerProfile(profile, "wlid://test/cluster/ns/Pod/p") + alerts := exporter.ruleAlerts() + if len(alerts) != 1 { + t.Fatalf("exporter received %d alerts; want exactly 1", len(alerts)) + } + a := alerts[0] + if got := a.GetBaseRuntimeAlert().AlertName; got != "Signed profile tampered" { + t.Errorf("AlertName=%q; want %q", got, "Signed profile tampered") + } + if got := a.GetRuleId(); got != "R1016" { + t.Errorf("RuleId=%q; want R1016", got) + } + if got := a.GetRuntimeAlertK8sDetails().Namespace; got != "test-ns" { + t.Errorf("Namespace=%q; want test-ns", got) + } + + // Same RV: dedup holds. + c.verifyUserContainerProfile(profile, "wlid://test/cluster/ns/Pod/p") + if got := len(exporter.ruleAlerts()); got != 1 { + t.Errorf("after dedup re-call, exporter has %d alerts; want 1", got) + } + + // Bump RV → fresh alert. + profile.ResourceVersion = "2" + c.verifyUserContainerProfile(profile, "wlid://test/cluster/ns/Pod/p") + if got := len(exporter.ruleAlerts()); got != 2 { + t.Errorf("after RV bump, exporter has %d alerts; want 2", got) + } +} + +// TestVerifyCP_StrictMode_ReturnsFalseOnTamper: with EnableSignatureVerification +// the caller must drop a tampered overlay (verify returns false). +func TestVerifyCP_StrictMode_ReturnsFalseOnTamper(t *testing.T) { + profile := signedCP("tampered-strict", "test-ns", "7") + adapter := profiles.NewContainerProfileAdapter(profile) + if err := signature.SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("sign profile: %v", err) + } + profile.Spec.Execs[0].Path = "/bin/evil" + + c := &ContainerProfileCacheImpl{cfg: config.Config{EnableSignatureVerification: true}} + if c.verifyUserContainerProfile(profile, "wlid://test/cluster/ns/Pod/p") { + t.Errorf("strict mode returned true on tamper; want false (drop the overlay)") + } +} + +func TestVerifyCP_StrictMode_RefusesUnsignedUserProfile(t *testing.T) { + profile := signedCP("unsigned-strict", "test-ns", "11") + profile.Annotations = map[string]string{} + + c := &ContainerProfileCacheImpl{cfg: config.Config{EnableSignatureVerification: true}} + if c.verifyUserContainerProfile(profile, "wlid://test/cluster/ns/Pod/p") { + t.Error("strict mode must refuse an unsigned user-defined profile") + } +} + +func TestVerifyCP_PermissiveMode_AcceptsUnsignedUserProfile(t *testing.T) { + profile := signedCP("unsigned-permissive", "test-ns", "12") + profile.Annotations = map[string]string{} + + c := &ContainerProfileCacheImpl{cfg: config.Config{EnableSignatureVerification: false}} + if !c.verifyUserContainerProfile(profile, "wlid://test/cluster/ns/Pod/p") { + t.Error("with verification off an unsigned user-defined profile must still load") + } +} + +// TestTamperAlert_StampsEmissionTime pins the same for R1016. +func TestTamperAlert_StampsEmissionTime(t *testing.T) { + exporter := &captureExporter{} + c := &ContainerProfileCacheImpl{} + c.SetTamperAlertExporter(exporter) + c.emitTamperAlert("tamper-app", "ns", "wlid://cluster/namespace-ns/deployment-d", "ContainerProfile", errTamper) + + alerts := exporter.ruleAlerts() + require.Len(t, alerts, 1) + ts := alerts[0].GetBaseRuntimeAlert().Timestamp + require.False(t, ts.IsZero(), "R1016 must carry an emission timestamp") + require.WithinDuration(t, time.Now(), ts, 5*time.Second) + b, err := json.Marshal(alerts[0]) + require.NoError(t, err) + require.NotContains(t, string(b), `"timestamp":"0001-01-01T00:00:00Z"`, "serialised R1016 alert timestamp must not be the zero value") +} + +var errTamper = fmt.Errorf("signature verification failed") diff --git a/pkg/objectcache/containerprofilecache/unsigned_key_test.go b/pkg/objectcache/containerprofilecache/unsigned_key_test.go new file mode 100644 index 0000000000..6ce01dd036 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/unsigned_key_test.go @@ -0,0 +1,19 @@ +//go:build experimental_signing + +package containerprofilecache + +import ( + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestUnsignedKeyIgnoresResourceVersion(t *testing.T) { + a := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Namespace: "arc-systems", Name: "arc-runner-dind", ResourceVersion: "100"}} + b := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Namespace: "arc-systems", Name: "arc-runner-dind", ResourceVersion: "101"}} + other := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Namespace: "arc-systems", Name: "arc-runner", ResourceVersion: "100"}} + assert.Equal(t, unsignedKey(a), unsignedKey(b)) + assert.NotEqual(t, unsignedKey(a), unsignedKey(other)) +} diff --git a/pkg/rulebindingmanager/cache/cache.go b/pkg/rulebindingmanager/cache/cache.go index 72d2004846..a5381ea868 100644 --- a/pkg/rulebindingmanager/cache/cache.go +++ b/pkg/rulebindingmanager/cache/cache.go @@ -1,3 +1,5 @@ +//go:build !experimental_signing + package cache import ( diff --git a/pkg/rulebindingmanager/cache/cache_signing.go b/pkg/rulebindingmanager/cache/cache_signing.go new file mode 100644 index 0000000000..2c502da596 --- /dev/null +++ b/pkg/rulebindingmanager/cache/cache_signing.go @@ -0,0 +1,746 @@ +//go:build experimental_signing + +package cache + +import ( + "context" + "sync" + "time" + + mapset "github.com/deckarep/golang-set/v2" + "github.com/goradd/maps" + "github.com/hashicorp/golang-lru/v2/expirable" + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/k8sclient" + "github.com/kubescape/node-agent/pkg/rulebindingmanager" + rbtypes "github.com/kubescape/node-agent/pkg/rulebindingmanager/types" + typesv1 "github.com/kubescape/node-agent/pkg/rulebindingmanager/types/v1" + "github.com/kubescape/node-agent/pkg/rulemanager/prefilter" + "github.com/kubescape/node-agent/pkg/rulemanager/rulecreator" + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/node-agent/pkg/utils" + "github.com/kubescape/node-agent/pkg/watcher" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" +) + +var _ rulebindingmanager.RuleBindingCache = (*RBCache)(nil) +var _ watcher.Adaptor = (*RBCache)(nil) + +type pendingNotification struct { + notifiers []*chan rulebindingmanager.RuleBindingNotify + events []rulebindingmanager.RuleBindingNotify +} + +type RBCache struct { + config config.Config + nodeName string + k8sClient k8sclient.K8sClientInterface + allPods mapset.Set[string] // set of all pods (also pods without rules) + podToRBNames maps.SafeMap[string, mapset.Set[string]] // podID -> []rule binding names + podToBundle maps.SafeMap[string, string] // podID -> signed bundle the pod opted into (kubescape.io/user-defined-profile) + rbNameToRB maps.SafeMap[string, typesv1.RuntimeAlertRuleBinding] // rule binding name -> rule binding + rbNameToRules maps.SafeMap[string, []rulemanagertypesv1.Rule] // rule binding name -> []created rules + rbNameToPods maps.SafeMap[string, mapset.Set[string]] // rule binding name -> podIDs + ruleCreator rulecreator.RuleCreator + watchResources []watcher.WatchResource + notifiers []*chan rulebindingmanager.RuleBindingNotify + mutex sync.RWMutex + rulesForPod *expirable.LRU[string, []rulemanagertypesv1.Rule] + notificationQueue chan pendingNotification + + policyMutex sync.RWMutex + trustPolicy *bundle.TrustPolicy +} + +func (c *RBCache) SetTrustPolicy(p *bundle.TrustPolicy) { + c.policyMutex.Lock() + defer c.policyMutex.Unlock() + c.trustPolicy = p +} + +func (c *RBCache) trustPolicySnapshot() (*bundle.TrustPolicy, bool) { + c.policyMutex.RLock() + defer c.policyMutex.RUnlock() + if c.trustPolicy == nil || !c.trustPolicy.BindingSigningEnabled() { + return nil, false + } + return c.trustPolicy, true +} + +func (c *RBCache) bindingSigningEnabled() bool { + _, ok := c.trustPolicySnapshot() + return ok +} + +func NewCache(config config.Config, k8sClient k8sclient.K8sClientInterface, ruleCreator rulecreator.RuleCreator) *RBCache { + c := &RBCache{ + config: config, + nodeName: config.NodeName, + k8sClient: k8sClient, + ruleCreator: ruleCreator, + allPods: mapset.NewSet[string](), + rbNameToRB: maps.SafeMap[string, typesv1.RuntimeAlertRuleBinding]{}, + podToRBNames: maps.SafeMap[string, mapset.Set[string]]{}, + podToBundle: maps.SafeMap[string, string]{}, + rbNameToPods: maps.SafeMap[string, mapset.Set[string]]{}, + watchResources: resourcesToWatch(config.NodeName, config.IgnoreRuleBindings), + rulesForPod: expirable.NewLRU[string, []rulemanagertypesv1.Rule](1000, nil, 5*time.Second), + notificationQueue: make(chan pendingNotification, 10000), + } + go c.processNotifications() + return c +} + +func (c *RBCache) processNotifications() { + for pn := range c.notificationQueue { + for _, n := range pn.notifiers { + for _, event := range pn.events { + select { + case *n <- event: + default: + timer := time.NewTimer(100 * time.Millisecond) + select { + case *n <- event: + timer.Stop() + case <-timer.C: + logger.L().Error("RBCache - notifier is slow or blocked, dropping notification", helpers.Interface("event", event)) + } + } + } + } + } +} + +// ----------------- watcher.WatchResources methods ----------------- + +func (c *RBCache) WatchResources() []watcher.WatchResource { + c.mutex.RLock() + defer c.mutex.RUnlock() + return c.watchResources +} + +// ------------------ rulebindingmanager.RuleBindingCache methods ----------------------- + +func (c *RBCache) ListRulesForPod(namespace, name string) []rulemanagertypesv1.Rule { + podID := utils.CreateK8sPodID(namespace, name) + bundleName := c.bundleForPod(podID) + + if c.config.IgnoreRuleBindings { + rules, ok := c.rulesForPod.Get(podID) + if ok { + return rules + } + // Scope BEFORE caching: the LRU key is the podID, which identifies the + // pod whose bundle we scoped to, so a post-filter result is correct to + // memoise. + rules = scopeRulesToBundle(c.getRules(), bundleName) + c.rulesForPod.Add(podID, rules) + return rules + } + + var rulesSlice []rulemanagertypesv1.Rule + + rulesSlice, ok := c.rulesForPod.Get(podID) + if ok { + return rulesSlice + } + + if !c.podToRBNames.Has(podID) { + return nil + } + + //append rules for pod + rbNames := c.podToRBNames.Get(podID) + for _, i := range rbNames.ToSlice() { + if rules, ok := c.rbNameToRules.Load(i); ok { + rulesSlice = append(rulesSlice, rules...) + } + } + + rulesSlice = scopeRulesToBundle(rulesSlice, bundleName) + + c.rulesForPod.Add(podID, rulesSlice) + + return rulesSlice +} + +// bundleForPod returns the signed bundle a pod opted into, or "" when the pod +// carries no user-defined-profile label or is not known to the cache. Empty is +// the safe default: only cluster-wide (base-class) rules then apply. +func (c *RBCache) bundleForPod(podID string) string { + if b, ok := c.podToBundle.Load(podID); ok { + return b + } + return "" +} + +// setPodBundle records (or clears) the bundle a pod is bound to. The key is the +// same namespace-qualified pod ID used by podToRBNames and by the rulesForPod +// LRU, so all three agree on pod identity. +func (c *RBCache) setPodBundle(pod *corev1.Pod) { + podID := uniqueName(pod) + // Cached rules were scoped to the previous bundle membership. + if c.rulesForPod != nil { + c.rulesForPod.Remove(podID) + } + if b := pod.GetLabels()[helpersv1.UserDefinedProfileMetadataKey]; b != "" { + c.podToBundle.Set(podID, b) + return + } + // The label can be removed by an update; a stale entry would keep applying + // an overlay to a pod that opted out. + c.podToBundle.Delete(podID) +} + +// scopeRulesToBundle resolves the provenance of signed rule fragments for a pod +// bound to the given bundle: +// +// 1. A base (cluster-wide) rule applies to every workload, bundle or not. +// 2. An overlay rule applies ONLY to workloads bound to its bundle — in any +// namespace — and is dropped for everyone else. Bundle membership is signed, +// the namespace is not, so the namespace plays no part here. +// 3. For a pod inside the bundle, the overlay REPLACES the base rule carrying +// the same ID. That override is the point of the feature: the vendor ships +// one bundle whose ContainerProfile half describes the expected behaviour and +// whose Rules half retunes the detections that go with it. +// +// When rule signing is DISABLED the rules watcher stamps ClusterWide on every +// rule and no bundle at all, and this function returns the input untouched — no +// filtering, no de-duplication, no reordering. That keeps the no-trust-policy +// deployment bit-for-bit identical to the pre-signing behaviour, including +// duplicate rule IDs contributed by two rule bindings with different prefilter +// parameters. +// +// Ordering is first-seen deterministic: the output preserves the order of the +// input, with an overriding bundle rule taking the position of the cluster-wide +// rule it replaces. It never depends on map iteration order. +func scopeRulesToBundle(rules []rulemanagertypesv1.Rule, bundleName string) []rulemanagertypesv1.Rule { + if len(rules) == 0 { + return rules + } + + // Fast path: no rule belongs to a bundle, so there is nothing to scope and + // nothing that could override anything. A non-empty Bundle is the only thing + // that can make this function change its input: AdmitRulesFragment refuses an + // overlay without one, so an overlay always trips this check. + provenance := false + for i := range rules { + if rules[i].Bundle != "" { + provenance = true + break + } + } + if !provenance { + return rules + } + + out := make([]rulemanagertypesv1.Rule, 0, len(rules)) + posByID := make(map[string]int, len(rules)) + + for _, rule := range rules { + // A rule that is neither cluster-wide nor bundled carries no provenance + // at all (e.g. registered directly by a third party). It is NOT an + // overlay, so it is kept for every pod — the pre-signing behaviour. + overlay := !rule.ClusterWide && rule.Bundle != "" + if overlay && (bundleName == "" || rule.Bundle != bundleName) { + // An overlay never leaks outside the bundle it was signed for. + continue + } + pos, seen := posByID[rule.ID] + if !seen { + posByID[rule.ID] = len(out) + out = append(out, rule) + continue + } + existing := out[pos] + existingOverlay := !existing.ClusterWide && existing.Bundle != "" + if overlay && !existingOverlay { + // The bundle overlay wins over the cluster-wide rule. + out[pos] = rule + } + // Otherwise keep the first-seen rule (two cluster-wide rules with the + // same ID, or an already-applied bundle override). + } + + return out +} + +func (c *RBCache) AddNotifier(n *chan rulebindingmanager.RuleBindingNotify) { + c.mutex.Lock() + defer c.mutex.Unlock() + c.notifiers = append(c.notifiers, n) +} + +// ------------------ watcher.Watcher methods ----------------------- + +func (c *RBCache) AddHandler(ctx context.Context, obj runtime.Object) { + c.mutex.Lock() + defer c.mutex.Unlock() + + var rbs []rulebindingmanager.RuleBindingNotify + + if pod, ok := obj.(*corev1.Pod); ok { + // The pod's bundle is tracked in EVERY mode: ListRulesForPod scopes + // overlays by it whether or not rule bindings are consulted. + c.setPodBundle(pod) + // When rule bindings are ignored, pod->binding bookkeeping is never read + // (ListRulesForPod resolves rules directly from the rule creator), so skip it. + if c.config.IgnoreRuleBindings { + return + } + rbs = c.addPod(ctx, pod) + } else if un, ok := obj.(*unstructured.Unstructured); ok { + if un.GetKind() != "" && un.GetKind() != rbtypes.RuntimeRuleBindingAlertKind { + return + } + ruleBinding, err := unstructuredToRuleBinding(un) + if err != nil { + logger.L().Warning("RBCache - failed to convert unstructured to rule binding", helpers.Error(err)) + return + } + rbs = c.addRuleBinding(ruleBinding) + } + + if len(c.notifiers) > 0 && len(rbs) > 0 { + notifiers := make([]*chan rulebindingmanager.RuleBindingNotify, len(c.notifiers)) + copy(notifiers, c.notifiers) + select { + case c.notificationQueue <- pendingNotification{notifiers: notifiers, events: rbs}: + default: + logger.L().Error("RBCache - notification queue full, dropping notifications", helpers.Int("size", len(c.notificationQueue))) + } + } +} + +func (c *RBCache) ModifyHandler(ctx context.Context, obj runtime.Object) { + c.mutex.Lock() + defer c.mutex.Unlock() + + var rbs []rulebindingmanager.RuleBindingNotify + + if pod, ok := obj.(*corev1.Pod); ok { + // A pod update can add, change or remove the user-defined-profile label, + // so the bundle is re-read on every modify, in every mode. + c.setPodBundle(pod) + // When rule bindings are ignored, pod->binding bookkeeping is never read + // (ListRulesForPod resolves rules directly from the rule creator), so skip it. + if c.config.IgnoreRuleBindings { + return + } + rbs = c.addPod(ctx, pod) + } else if un, ok := obj.(*unstructured.Unstructured); ok { + if un.GetKind() != "" && un.GetKind() != rbtypes.RuntimeRuleBindingAlertKind { + return + } + ruleBinding, err := unstructuredToRuleBinding(un) + if err != nil { + logger.L().Warning("RBCache - failed to convert unstructured to rule binding", helpers.Error(err)) + return + } + rbs = c.modifiedRuleBinding(ruleBinding) + } + + if len(c.notifiers) > 0 && len(rbs) > 0 { + notifiers := make([]*chan rulebindingmanager.RuleBindingNotify, len(c.notifiers)) + copy(notifiers, c.notifiers) + select { + case c.notificationQueue <- pendingNotification{notifiers: notifiers, events: rbs}: + default: + logger.L().Error("RBCache - notification queue full, dropping notifications", helpers.Int("size", len(c.notificationQueue))) + } + } +} + +func (c *RBCache) DeleteHandler(_ context.Context, obj runtime.Object) { + c.mutex.Lock() + defer c.mutex.Unlock() + + var rbs []rulebindingmanager.RuleBindingNotify + + if pod, ok := obj.(*corev1.Pod); ok { + // Drop the bundle binding in every mode, so a recycled pod ID cannot + // inherit the previous pod's overlay. + c.podToBundle.Delete(uniqueName(pod)) + if c.rulesForPod != nil { + c.rulesForPod.Remove(uniqueName(pod)) + } + // When rule bindings are ignored, pod->binding bookkeeping is never populated, so skip it. + if c.config.IgnoreRuleBindings { + return + } + c.deletePod(uniqueName(pod)) + } else if un, ok := obj.(*unstructured.Unstructured); ok { + rbs = c.deleteRuleBinding(uniqueName(un)) + } + + if len(c.notifiers) > 0 && len(rbs) > 0 { + notifiers := make([]*chan rulebindingmanager.RuleBindingNotify, len(c.notifiers)) + copy(notifiers, c.notifiers) + select { + case c.notificationQueue <- pendingNotification{notifiers: notifiers, events: rbs}: + default: + logger.L().Error("RBCache - notification queue full, dropping notifications", helpers.Int("size", len(c.notificationQueue))) + } + } +} + +func (c *RBCache) RefreshRuleBindingsRules() { + c.mutex.Lock() + defer c.mutex.Unlock() + + // When rule bindings are ignored, ListRulesForPod resolves rules directly from + // the rule creator (CreateAllRules), so there are no per-binding rules to rebuild. + // We still notify downstream consumers so they react to the underlying rule change + // (e.g. RuleManager recompiling the profile projection spec). + if !c.config.IgnoreRuleBindings { + for _, rbName := range c.rbNameToRB.Keys() { + rb := c.rbNameToRB.Get(rbName) + c.rbNameToRules.Set(rbName, c.createRules(rb.Spec.Rules)) + } + logger.L().Info("RBCache - refreshed rule bindings rules", helpers.Int("ruleBindings", len(c.rbNameToRB.Keys()))) + } + + // The per-pod memo answers from the ruleset that was live when it was + // filled, so leaving it in place holds every pod on the previous ruleset + // until the entry expires: permissive while a rules outage is in progress, + // and blind to the rules that end it. Both directions are a detection gap + // bounded only by the memo's lifetime, so a rules change drops it. + c.rulesForPod.Purge() + + if len(c.notifiers) > 0 { + notifiers := make([]*chan rulebindingmanager.RuleBindingNotify, len(c.notifiers)) + copy(notifiers, c.notifiers) + select { + case c.notificationQueue <- pendingNotification{notifiers: notifiers, events: []rulebindingmanager.RuleBindingNotify{{}}}: + default: + logger.L().Error("RBCache - notification queue full, dropping refresh notification") + } + } +} + +// ----------------- RuleBinding manager methods ----------------- + +// AddRuleBinding adds a rule binding to the cache +func (c *RBCache) addRuleBinding(ruleBinding *typesv1.RuntimeAlertRuleBinding) []rulebindingmanager.RuleBindingNotify { + var rbs []rulebindingmanager.RuleBindingNotify + rbName := uniqueName(ruleBinding) + logger.L().Info("RBCache - ruleBinding added/modified", helpers.String("name", rbName)) + + if policy, signingEnabled := c.trustPolicySnapshot(); signingEnabled { + verified, admitErr := bundle.AdmitBinding(ruleBinding, *policy) + if admitErr != nil { + logger.L().Warning("rule binding rejected", + helpers.String("name", ruleBinding.GetName()), + helpers.String("namespace", ruleBinding.GetNamespace()), + helpers.Error(admitErr)) + return rbs + } + // Keep the watch object's identity, but enforce only authenticated + // selectors and rules. Never mutate an informer-owned object. + admitted := *ruleBinding + admitted.Spec = verified.Spec + ruleBinding = &admitted + } + + // convert selectors to string + nsSelector, err := metav1.LabelSelectorAsSelector(&ruleBinding.Spec.NamespaceSelector) + // check if the selectors are valid + if err != nil { + logger.L().Warning("RBCache - failed to parse ns selector", helpers.String("ruleBiding", rbName), helpers.Interface("NamespaceSelector", ruleBinding.Spec.NamespaceSelector), helpers.Error(err)) + return rbs + } + podSelector, err := metav1.LabelSelectorAsSelector(&ruleBinding.Spec.PodSelector) + // check if the selectors are valid + if err != nil { + logger.L().Warning("RBCache - failed to parse pod selector", helpers.String("ruleBiding", rbName), helpers.Interface("PodSelector", ruleBinding.Spec.PodSelector), helpers.Error(err)) + return rbs + } + + nsSelectorStr := nsSelector.String() + podSelectorStr := podSelector.String() + + // add the rule binding to the cache + c.rbNameToRB.Set(rbName, *ruleBinding) + c.rbNameToPods.Set(rbName, mapset.NewSet[string]()) + c.rbNameToRules.Set(rbName, c.createRules(ruleBinding.Spec.Rules)) + + var namespaces *corev1.NamespaceList + // if ruleBinding.GetNamespace() == "" { + namespaces, err = c.k8sClient.GetKubernetesClient().CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{LabelSelector: nsSelectorStr}) + if err != nil { + logger.L().Warning("RBCache - failed to list namespaces", helpers.String("ruleBiding", rbName), helpers.String("nsSelector", nsSelectorStr), helpers.Error(err)) + return rbs + } + // } else { + // namespaces = &corev1.NamespaceList{Items: []corev1.Namespace{{ObjectMeta: metav1.ObjectMeta{Name: ruleBinding.GetNamespace()}}}} + // } + + // get related pods + for _, ns := range namespaces.Items { + lp := metav1.ListOptions{ + LabelSelector: podSelectorStr, + FieldSelector: "spec.nodeName=" + c.nodeName, + } + pods, err := c.k8sClient.GetKubernetesClient().CoreV1().Pods(ns.GetName()).List(context.Background(), lp) + if err != nil { + logger.L().Warning("RBCache - failed to list pods", helpers.String("ruleBiding", rbName), helpers.String("podSelector", podSelectorStr), helpers.Error(err)) + return rbs + } + + for _, pod := range pods.Items { + podName := uniqueName(&pod) + // These pod objects come straight from the apiserver, so they are as + // authoritative about the bundle opt-in as a watch event. + c.setPodBundle(&pod) + if rbNames, ok := c.podToRBNames.Load(podName); !ok { + c.podToRBNames.Set(podName, mapset.NewSet[string](rbName)) + } else { + rbNames.Add(rbName) + } + + c.rbNameToPods.Get(rbName).Add(podName) + + if len(c.notifiers) == 0 { + continue + } + n := rulebindingmanager.NewRuleBindingNotifierImpl(rulebindingmanager.Added, pod) + rbs = append(rbs, n) + + logger.L().Debug("RBCache - ruleBinding attached to pod", helpers.String("ruleBinding", rbName), helpers.String("pod", podName)) + } + } + return rbs +} +func (c *RBCache) deleteRuleBinding(uniqueName string) []rulebindingmanager.RuleBindingNotify { + logger.L().Info("RBCache - ruleBinding deleted", helpers.String("name", uniqueName)) + var rbs []rulebindingmanager.RuleBindingNotify + + // remove the rule binding from the pods + for _, podName := range c.podToRBNames.Keys() { + c.podToRBNames.Get(podName).Remove(uniqueName) + + if c.podToRBNames.Get(podName).Cardinality() != 0 { + // if this pod is still bound to other rule bindings, continue + continue + } + c.podToRBNames.Delete(podName) + + if len(c.notifiers) == 0 { + continue + } + namespace, name := uniqueNameToName(podName) + n, err := rulebindingmanager.RuleBindingNotifierImplWithK8s(c.k8sClient, rulebindingmanager.Removed, namespace, name) + if err != nil { + logger.L().Warning("RBCache - failed to create notifier", helpers.String("namespace", namespace), helpers.String("name", name), helpers.Error(err)) + continue + } + + rbs = append(rbs, n) + } + + // remove the rule binding from the cache + c.rbNameToRB.Delete(uniqueName) + c.rbNameToRules.Delete(uniqueName) + c.rbNameToPods.Delete(uniqueName) + + return rbs +} + +func (c *RBCache) modifiedRuleBinding(ruleBinding *typesv1.RuntimeAlertRuleBinding) []rulebindingmanager.RuleBindingNotify { + rbsD := c.deleteRuleBinding(uniqueName(ruleBinding)) + rbsA := c.addRuleBinding(ruleBinding) + + return diff(rbsD, rbsA) +} + +// ----------------- Pod manager methods ----------------- + +// addPod binds a pod to the rule bindings selecting it. The pod's BUNDLE opt-in +// is recorded by the calling handler (setPodBundle) before this runs, because it +// must be tracked even when rule bindings are ignored and addPod is skipped. +func (c *RBCache) addPod(ctx context.Context, pod *corev1.Pod) []rulebindingmanager.RuleBindingNotify { + var rbs []rulebindingmanager.RuleBindingNotify + podName := uniqueName(pod) + + // add the pods to list of all pods only after the pod is processed + defer c.allPods.Add(podName) + + // if pod is already in the cache, ignore + if c.podToRBNames.Has(podName) { + return rbs + } + + for _, rb := range c.rbNameToRB.Values() { + // if rb.GetNamespace() != "" && rb.GetNamespace() != pod.GetNamespace() { + // // rule binding is not in the same namespace as the pod + // continue + // } + rbName := uniqueName(&rb) + + // check pod selectors + podSelector, err := metav1.LabelSelectorAsSelector(&rb.Spec.PodSelector) + if err != nil { + logger.L().Warning("RBCache - failed to parse pod selector", helpers.String("ruleBiding", uniqueName(&rb)), helpers.Error(err)) + continue + } + if !podSelector.Matches(labels.Set(pod.GetLabels())) { + // pod selectors doesnt match + continue + } + + // check namespace selectors + nsSelector, err := metav1.LabelSelectorAsSelector(&rb.Spec.NamespaceSelector) + if err != nil { + logger.L().Warning("RBCache - failed to parse ns selector", helpers.String("ruleBiding", uniqueName(&rb)), helpers.Error(err)) + continue + } + nsSelectorStr := nsSelector.String() + if len(nsSelectorStr) != 0 { + // get related namespaces + namespaces, err := c.k8sClient.GetKubernetesClient().CoreV1().Namespaces().List(ctx, metav1.ListOptions{LabelSelector: nsSelectorStr}) + if err != nil { + logger.L().Warning("RBCache - failed to list namespaces", helpers.String("ruleBiding", uniqueName(&rb)), helpers.String("nsSelector", nsSelectorStr), helpers.Error(err)) + continue + } + if !namespaceListHasName(namespaces, pod.GetNamespace()) { + // namespace selectors dont match + continue + } + } + + // selectors match, add the rule binding to the pod + if rbNames, ok := c.podToRBNames.Load(podName); !ok { + c.podToRBNames.Set(podName, mapset.NewSet[string](rbName)) + } else { + rbNames.Add(rbName) + } + + if pods, ok := c.rbNameToPods.Load(rbName); !ok { + c.rbNameToPods.Set(rbName, mapset.NewSet[string](podName)) + } else { + pods.Add(podName) + } + logger.L().Debug("RBCache - adding pod to roleBinding", helpers.String("pod", podName), helpers.String("ruleBinding", rbName)) + + n := rulebindingmanager.NewRuleBindingNotifierImpl(rulebindingmanager.Added, *pod) + rbs = append(rbs, n) + } + return rbs +} + +func (c *RBCache) deletePod(uniqueName string) { + c.allPods.Remove(uniqueName) + c.podToBundle.Delete(uniqueName) + + // selectors match, add the rule binding to the pod + if rbNames, ok := c.podToRBNames.Load(uniqueName); ok { + rbNames.Each(func(name string) bool { + if pods, ok := c.rbNameToPods.Load(name); ok { + pods.Remove(uniqueName) + } + return false + }) + } + c.podToRBNames.Delete(uniqueName) +} + +func (c *RBCache) createRules(rulesForPod []typesv1.RuntimeAlertRuleBindingRule) []rulemanagertypesv1.Rule { + var rules []rulemanagertypesv1.Rule + // Get the rules that are bound to the container + for _, ruleParams := range rulesForPod { + rules = append(rules, c.createRule(&ruleParams)...) + } + return rules +} + +// createRule expands one rule-binding entry into the rules it contributes. +// +// A by-ID or by-name entry contributes EVERY variant of that rule: the +// cluster-wide one plus each signed bundle overlay carrying the same ID/name. +// Selecting between them is not this function's job — it has no pod and +// therefore no bundle. ListRulesForPod does it later via scopeRulesToBundle, +// which drops the overlays belonging to other bundles and lets the matching +// overlay override the cluster-wide rule. Resolving to a single rule here (as +// the by-ID/by-name path used to) would make that override unreachable for the +// common binding that names its rules explicitly. +// +// Every contributed variant receives the binding's prefilter parameters, exactly +// as before: the binding tunes the detection, the fragment defines it. +func (c *RBCache) createRule(r *typesv1.RuntimeAlertRuleBindingRule) []rulemanagertypesv1.Rule { + if r.RuleID != "" { + rules := c.ruleCreator.CreateRulesByID(r.RuleID) + if len(rules) == 0 { + // No rule with that ID. Keep the historical shape (one zero-valued + // rule) so consumers counting bound rules — e.g. + // HasApplicableRuleBindings — behave exactly as before for bindings + // that name a rule the agent does not know. + rules = []rulemanagertypesv1.Rule{c.ruleCreator.CreateRuleByID(r.RuleID)} + } + for i := range rules { + rules[i].Prefilter = prefilter.ParseWithDefaults(rules[i].State, r.Parameters) + } + return rules + } + if r.RuleName != "" { + rules := c.ruleCreator.CreateRulesByName(r.RuleName) + if len(rules) == 0 { + rules = []rulemanagertypesv1.Rule{c.ruleCreator.CreateRuleByName(r.RuleName)} + } + for i := range rules { + rules[i].Prefilter = prefilter.ParseWithDefaults(rules[i].State, r.Parameters) + } + return rules + } + if len(r.RuleTags) > 0 { + rules := c.ruleCreator.CreateRulesByTags(r.RuleTags) + for i := range rules { + rules[i].Prefilter = prefilter.ParseWithDefaults(rules[i].State, r.Parameters) + } + return rules + } + + return []rulemanagertypesv1.Rule{} +} + +// Expose the rule creator to be able to create rules from third party. +func (c *RBCache) GetRuleCreator() rulecreator.RuleCreator { + c.mutex.RLock() + defer c.mutex.RUnlock() + return c.ruleCreator +} + +func diff(a, b []rulebindingmanager.RuleBindingNotify) []rulebindingmanager.RuleBindingNotify { + m := make(map[string]rulebindingmanager.RuleBindingNotify) + diff := make([]rulebindingmanager.RuleBindingNotify, 0) + + for i := range a { + m[uniqueName(&a[i].Pod)] = a[i] + } + + for i := range b { + n := uniqueName(&b[i].Pod) + if _, found := m[n]; !found { + diff = append(diff, b[i]) + } else { + delete(m, n) + } + } + + for i := range m { + diff = append(diff, m[i]) + } + + return diff +} + +func (c *RBCache) getRules() []rulemanagertypesv1.Rule { + return c.ruleCreator.CreateAllRules() +} diff --git a/pkg/rulebindingmanager/cache/cache_signing_test.go b/pkg/rulebindingmanager/cache/cache_signing_test.go new file mode 100644 index 0000000000..24e07bf983 --- /dev/null +++ b/pkg/rulebindingmanager/cache/cache_signing_test.go @@ -0,0 +1,244 @@ +//go:build experimental_signing + +package cache + +import ( + "context" + "fmt" + mapset "github.com/deckarep/golang-set/v2" + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + typesv1 "github.com/kubescape/node-agent/pkg/rulebindingmanager/types/v1" + "github.com/kubescape/node-agent/pkg/rulemanager/rulecreator" + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "testing" +) + +func TestBundleOverrideOnByNameBinding(t *testing.T) { + const ruleName = "unexpected_process_launched" + + rc := rulecreator.NewRuleCreator() + // Cluster-wide baseline from a base-class fragment. + rc.RegisterRule(rulemanagertypesv1.Rule{ + ID: "R0001", Name: ruleName, Enabled: true, Severity: 5, + Expressions: rulemanagertypesv1.RuleExpressions{Message: "cluster baseline"}, + ClusterWide: true, + }) + // Signed overlay for the "redis" bundle, same ID and name, retuned. + rc.RegisterRule(rulemanagertypesv1.Rule{ + ID: "R0001", Name: ruleName, Enabled: true, Severity: 10, + Expressions: rulemanagertypesv1.RuleExpressions{Message: "redis override"}, + Bundle: "redis", + }) + // An unrelated cluster rule, also bound by name, must be unaffected. + rc.RegisterRule(rulemanagertypesv1.Rule{ + ID: "R0003", Name: "exec_from_malicious_source", Enabled: true, Severity: 7, + ClusterWide: true, + }) + + c := NewCacheMock("") + c.ruleCreator = rc + + rbName := "kubescape/rb-by-name" + rb := typesv1.RuntimeAlertRuleBinding{ + Spec: typesv1.RuntimeAlertRuleBindingSpec{ + Rules: []typesv1.RuntimeAlertRuleBindingRule{ + {RuleName: ruleName}, + {RuleName: "exec_from_malicious_source"}, + }, + }, + } + c.rbNameToRB.Set(rbName, rb) + c.rbNameToRules.Set(rbName, c.createRules(rb.Spec.Rules)) + + // Four pods, all selected by the same binding. Two opted into the "redis" + // bundle from DIFFERENT namespaces, one opted into another bundle, one did + // not opt in at all. + pods := []*corev1.Pod{ + podWithBundle("redis", "redis-0", "redis"), + podWithBundle("team-a", "cache-0", "redis"), + podWithBundle("other", "pg-0", "postgres"), + podWithBundle("other", "api-0", ""), + } + for _, p := range pods { + c.podToRBNames.Set(uniqueName(p), mapset.NewSet[string](rbName)) + c.setPodBundle(p) + } + + assertOverridden := func(t *testing.T, namespace, name string) { + t.Helper() + got := c.ListRulesForPod(namespace, name) + require.Len(t, got, 2, "expected exactly one R0001 plus R0003, got %v", got) + + var r0001 []rulemanagertypesv1.Rule + for _, r := range got { + if r.ID == "R0001" { + r0001 = append(r0001, r) + } + } + require.Len(t, r0001, 1, "the override must replace the base rule, not be added next to it") + assert.Equal(t, "redis", r0001[0].Bundle) + assert.False(t, r0001[0].ClusterWide) + assert.Equal(t, 10, r0001[0].Severity) + assert.Equal(t, "redis override", r0001[0].Expressions.Message) + } + + assertBaseline := func(t *testing.T, namespace, name string) { + t.Helper() + got := c.ListRulesForPod(namespace, name) + require.Len(t, got, 2, "expected exactly one R0001 plus R0003, got %v", got) + + var r0001 []rulemanagertypesv1.Rule + for _, r := range got { + if r.ID == "R0001" { + r0001 = append(r0001, r) + } + } + require.Len(t, r0001, 1, "the redis overlay must not leak outside its bundle") + assert.True(t, r0001[0].ClusterWide) + assert.Empty(t, r0001[0].Bundle) + assert.Equal(t, 5, r0001[0].Severity) + assert.Equal(t, "cluster baseline", r0001[0].Expressions.Message) + } + + t.Run("pod bound to the bundle gets the overlay", func(t *testing.T) { + assertOverridden(t, "redis", "redis-0") + }) + + t.Run("bundle crosses namespaces", func(t *testing.T) { + // Same bundle, entirely different namespace: the overlay still applies. + // This is what scoping by bundle buys over scoping by namespace. + assertOverridden(t, "team-a", "cache-0") + }) + + t.Run("pod bound to another bundle keeps the base rule", func(t *testing.T) { + assertBaseline(t, "other", "pg-0") + }) + + t.Run("pod with no bundle label keeps the base rule", func(t *testing.T) { + assertBaseline(t, "other", "api-0") + }) + + t.Run("pod unknown to the bundle map keeps the base rule", func(t *testing.T) { + // Safe default: an unknown pod is treated as bound to no bundle. + c.podToRBNames.Set("ghost/ghost-0", mapset.NewSet[string](rbName)) + assertBaseline(t, "ghost", "ghost-0") + }) + + t.Run("binding contributes both variants before scoping", func(t *testing.T) { + // The cache must hold BOTH R0001 variants per binding; scoping picks one + // per pod. If this drops to one, the override above is accidental. + rules, ok := c.rbNameToRules.Load(rbName) + require.True(t, ok) + count := 0 + for _, r := range rules { + if r.ID == "R0001" { + count++ + } + } + assert.Equal(t, 2, count, "createRules must expand a by-name entry to every variant") + }) +} + +// podWithBundle builds a pod carrying (or not carrying) the user-defined-profile +// label that opts a workload into a signed bundle. +func podWithBundle(namespace, name, bundleName string) *corev1.Pod { + labels := map[string]string{} + if bundleName != "" { + labels[helpersv1.UserDefinedProfileMetadataKey] = bundleName + } + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Labels: labels}, + } +} + +// TestPodBundleTracking pins the pod -> bundle bookkeeping the scoping depends +// on: the label is read on add and on every update, and the entry disappears +// with the pod (or with the label). +func TestPodBundleTracking(t *testing.T) { + c := NewCacheMock("") + c.config.IgnoreRuleBindings = true // pod bundles are tracked in this mode too + + pod := podWithBundle("redis", "redis-0", "redis") + c.AddHandler(context.Background(), pod) + assert.Equal(t, "redis", c.bundleForPod("redis/redis-0")) + + // Re-labelled to another bundle. + c.ModifyHandler(context.Background(), podWithBundle("redis", "redis-0", "postgres")) + assert.Equal(t, "postgres", c.bundleForPod("redis/redis-0")) + + // Opted out: a stale entry would keep applying an overlay. + c.ModifyHandler(context.Background(), podWithBundle("redis", "redis-0", "")) + assert.Empty(t, c.bundleForPod("redis/redis-0")) + + // Deleted: the entry must not survive for a recycled pod ID. + c.ModifyHandler(context.Background(), podWithBundle("redis", "redis-0", "redis")) + require.Equal(t, "redis", c.bundleForPod("redis/redis-0")) + c.DeleteHandler(context.Background(), podWithBundle("redis", "redis-0", "redis")) + assert.Empty(t, c.bundleForPod("redis/redis-0")) + + // An entirely unknown pod resolves to no bundle. + assert.Empty(t, c.bundleForPod("nowhere/nothing")) +} + +// TestListRulesForPod_IgnoreRuleBindingsScopesByBundle covers the other +// ListRulesForPod branch: with IgnoreRuleBindings the rules come straight from +// the rule creator, and they must still be scoped to the pod's bundle. +func TestListRulesForPod_IgnoreRuleBindingsScopesByBundle(t *testing.T) { + rc := rulecreator.NewRuleCreator() + rc.RegisterRule(rulemanagertypesv1.Rule{ + ID: "R0001", Name: "r1", Enabled: true, Severity: 5, + Expressions: rulemanagertypesv1.RuleExpressions{Message: "cluster baseline"}, + ClusterWide: true, + }) + rc.RegisterRule(rulemanagertypesv1.Rule{ + ID: "R0001", Name: "r1", Enabled: true, Severity: 10, + Expressions: rulemanagertypesv1.RuleExpressions{Message: "redis override"}, + Bundle: "redis", + }) + + c := NewCacheMock("") + c.ruleCreator = rc + c.config.IgnoreRuleBindings = true + + c.AddHandler(context.Background(), podWithBundle("anywhere", "redis-0", "redis")) + c.AddHandler(context.Background(), podWithBundle("anywhere", "plain-0", "")) + + inBundle := c.ListRulesForPod("anywhere", "redis-0") + require.Len(t, inBundle, 1) + assert.Equal(t, "redis override", inBundle[0].Expressions.Message) + + outside := c.ListRulesForPod("anywhere", "plain-0") + require.Len(t, outside, 1) + assert.Equal(t, "cluster baseline", outside[0].Expressions.Message) +} + +func TestBundleChangesInvalidateCachedRules(t *testing.T) { + for _, ignoreBindings := range []bool{false, true} { + t.Run(fmt.Sprintf("ignoreBindings=%t", ignoreBindings), func(t *testing.T) { + rc := rulecreator.NewRuleCreator() + rc.RegisterRule(rulemanagertypesv1.Rule{ID: "R0001", ClusterWide: true, Severity: 5}) + rc.RegisterRule(rulemanagertypesv1.Rule{ID: "R0001", Bundle: "redis", Severity: 10}) + c := NewCacheMock("") + c.ruleCreator = rc + c.config.IgnoreRuleBindings = ignoreBindings + pod := podWithBundle("ns", "pod", "redis") + c.AddHandler(context.Background(), pod) + if !ignoreBindings { + c.rbNameToRules.Set("rb", rc.CreateAllRules()) + c.podToRBNames.Set("ns/pod", mapset.NewSet[string]("rb")) + } + require.Equal(t, 10, c.ListRulesForPod("ns", "pod")[0].Severity) + c.ModifyHandler(context.Background(), podWithBundle("ns", "pod", "")) + require.Equal(t, 5, c.ListRulesForPod("ns", "pod")[0].Severity) + c.ModifyHandler(context.Background(), pod) + require.Equal(t, 10, c.ListRulesForPod("ns", "pod")[0].Severity) + c.DeleteHandler(context.Background(), pod) + _, cached := c.rulesForPod.Get("ns/pod") + require.False(t, cached, "a recycled pod ID must not inherit a cached overlay") + }) + } +} diff --git a/pkg/rulebindingmanager/cache/mock.go b/pkg/rulebindingmanager/cache/mock.go index 02ffdedd73..a9e2f75fa5 100644 --- a/pkg/rulebindingmanager/cache/mock.go +++ b/pkg/rulebindingmanager/cache/mock.go @@ -1,3 +1,5 @@ +//go:build !experimental_signing + package cache import ( diff --git a/pkg/rulebindingmanager/cache/mock_signing.go b/pkg/rulebindingmanager/cache/mock_signing.go new file mode 100644 index 0000000000..64cba20975 --- /dev/null +++ b/pkg/rulebindingmanager/cache/mock_signing.go @@ -0,0 +1,30 @@ +//go:build experimental_signing + +package cache + +import ( + "time" + + mapset "github.com/deckarep/golang-set/v2" + "github.com/goradd/maps" + "github.com/hashicorp/golang-lru/v2/expirable" + "github.com/kubescape/k8s-interface/k8sinterface" + "github.com/kubescape/node-agent/pkg/rulemanager/rulecreator" + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" +) + +func NewCacheMock(nodeName string) *RBCache { + c := &RBCache{ + nodeName: nodeName, + allPods: mapset.NewSet[string](), + k8sClient: k8sinterface.NewKubernetesApiMock(), + ruleCreator: &rulecreator.RuleCreatorMock{}, + podToRBNames: maps.SafeMap[string, mapset.Set[string]]{}, + podToBundle: maps.SafeMap[string, string]{}, + rbNameToPods: maps.SafeMap[string, mapset.Set[string]]{}, + rulesForPod: expirable.NewLRU[string, []rulemanagertypesv1.Rule](1000, nil, 60*time.Second), + notificationQueue: make(chan pendingNotification, 10000), + } + go c.processNotifications() + return c +} diff --git a/pkg/rulebindingmanager/cache/scope_test.go b/pkg/rulebindingmanager/cache/scope_test.go new file mode 100644 index 0000000000..dc910f5ba4 --- /dev/null +++ b/pkg/rulebindingmanager/cache/scope_test.go @@ -0,0 +1,215 @@ +//go:build experimental_signing + +package cache + +import ( + "testing" + + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" +) + +// baseRule is what an admitted base-class fragment produces: cluster-wide, in no +// bundle. It is also what the watcher stamps when rule signing is disabled. +func baseRule(id, name string) rulemanagertypesv1.Rule { + return rulemanagertypesv1.Rule{ID: id, Name: name, Enabled: true, ClusterWide: true} +} + +// overlayRule is what an admitted overlay-class fragment produces: scoped to its +// signed bundle, never cluster-wide. +func overlayRule(id, name, bundleName string) rulemanagertypesv1.Rule { + return rulemanagertypesv1.Rule{ID: id, Name: name, Enabled: true, Bundle: bundleName} +} + +func ids(rules []rulemanagertypesv1.Rule) []string { + out := make([]string, 0, len(rules)) + for _, r := range rules { + out = append(out, r.ID) + } + return out +} + +func TestScopeRulesToBundle_BaseRuleAppliesToEveryPod(t *testing.T) { + in := []rulemanagertypesv1.Rule{baseRule("R0001", "baseline")} + + // Bound to a bundle, bound to another bundle, or bound to none at all. + for _, b := range []string{"redis", "postgres", ""} { + got := scopeRulesToBundle(in, b) + if len(got) != 1 || got[0].Name != "baseline" { + t.Fatalf("bundle %q: got %+v, want the base rule", b, ids(got)) + } + } +} + +func TestScopeRulesToBundle_OverlayOnlyForItsBundle(t *testing.T) { + in := []rulemanagertypesv1.Rule{overlayRule("R0002", "redis-only", "redis")} + + if got := scopeRulesToBundle(in, "redis"); len(got) != 1 || got[0].Name != "redis-only" { + t.Fatalf("in its own bundle: got %+v, want the overlay rule", ids(got)) + } + if got := scopeRulesToBundle(in, "postgres"); len(got) != 0 { + t.Fatalf("in another bundle: got %+v, want none", ids(got)) + } + // A pod with no user-defined-profile label opted into no bundle. + if got := scopeRulesToBundle(in, ""); len(got) != 0 { + t.Fatalf("pod with no bundle: got %+v, want none", ids(got)) + } +} + +func TestScopeRulesToBundle_OverlayOverridesBaseRule(t *testing.T) { + in := []rulemanagertypesv1.Rule{ + baseRule("R0001", "cluster-baseline"), + overlayRule("R0001", "redis-override", "redis"), + baseRule("R0003", "other"), + } + + got := scopeRulesToBundle(in, "redis") + if len(got) != 2 { + t.Fatalf("in redis: got %+v, want 2 rules", ids(got)) + } + if got[0].ID != "R0001" || got[0].Name != "redis-override" { + t.Errorf("in redis: R0001 must be the bundle overlay, got %q", got[0].Name) + } + if got[1].ID != "R0003" { + t.Errorf("in redis: expected R0003 second, got %q", got[1].ID) + } + + // For another bundle the base rule is untouched and the override invisible. + got = scopeRulesToBundle(in, "postgres") + if len(got) != 2 { + t.Fatalf("in postgres: got %+v, want 2 rules", ids(got)) + } + if got[0].Name != "cluster-baseline" { + t.Errorf("in postgres: R0001 must stay the base rule, got %q", got[0].Name) + } + + // And for a pod bound to no bundle at all. + got = scopeRulesToBundle(in, "") + if len(got) != 2 || got[0].Name != "cluster-baseline" { + t.Errorf("pod with no bundle must get only base rules, got %+v", got) + } +} + +func TestScopeRulesToBundle_OverrideRegardlessOfInputOrder(t *testing.T) { + // The overlay must win whether it is listed before or after the base rule + // (the watcher's list order is not stable). + overlayFirst := []rulemanagertypesv1.Rule{ + overlayRule("R0001", "redis-override", "redis"), + baseRule("R0001", "cluster-baseline"), + } + got := scopeRulesToBundle(overlayFirst, "redis") + if len(got) != 1 || got[0].Name != "redis-override" { + t.Fatalf("overlay-first: got %+v", got) + } +} + +func TestScopeRulesToBundle_OverlaysOfOtherBundlesNeverLeak(t *testing.T) { + // Two vendors ship overlays for R0001. A pod may only ever see its own. + in := []rulemanagertypesv1.Rule{ + baseRule("R0001", "cluster-baseline"), + overlayRule("R0001", "redis-override", "redis"), + overlayRule("R0001", "postgres-override", "postgres"), + } + + for bundleName, want := range map[string]string{ + "redis": "redis-override", + "postgres": "postgres-override", + "nginx": "cluster-baseline", + "": "cluster-baseline", + } { + got := scopeRulesToBundle(in, bundleName) + if len(got) != 1 { + t.Fatalf("bundle %q: got %+v, want exactly one R0001", bundleName, got) + } + if got[0].Name != want { + t.Errorf("bundle %q: R0001 = %q, want %q", bundleName, got[0].Name, want) + } + } +} + +func TestScopeRulesToBundle_BundleIsNamespaceIndependent(t *testing.T) { + // The whole point of scoping by bundle: the same signed overlay applies to + // every workload that opted in, no matter which namespace it runs in. The + // input carries no namespace at all any more, so this is really an assertion + // that scoping depends on nothing but the bundle string. + in := []rulemanagertypesv1.Rule{ + baseRule("R0001", "cluster-baseline"), + overlayRule("R0001", "redis-override", "redis"), + } + first := scopeRulesToBundle(in, "redis") + second := scopeRulesToBundle(in, "redis") + if len(first) != 1 || first[0].Name != "redis-override" { + t.Fatalf("got %+v", first) + } + if len(second) != len(first) || second[0].Name != first[0].Name { + t.Fatalf("scoping is not deterministic: %+v vs %+v", first, second) + } +} + +func TestScopeRulesToBundle_NoProvenanceUnchanged(t *testing.T) { + // Rule signing disabled: the watcher stamps ClusterWide=true and an empty + // Bundle, so scoping is an exact pass-through for every pod — including + // duplicate IDs contributed by two rule bindings with different prefilter + // parameters, which must NOT be collapsed. + in := []rulemanagertypesv1.Rule{ + baseRule("R0001", "a"), + baseRule("R0002", "b"), + baseRule("R0001", "a-with-other-params"), + baseRule("R0003", "c"), + } + for _, b := range []string{"anything", ""} { + got := scopeRulesToBundle(in, b) + if len(got) != len(in) { + t.Fatalf("bundle %q: got %+v, want all %d rules unchanged", b, ids(got), len(in)) + } + for i := range in { + if got[i].ID != in[i].ID || got[i].Name != in[i].Name { + t.Fatalf("input was altered at %d: %+v vs %+v", i, got[i], in[i]) + } + } + } +} + +// Rules registered directly by a third party carry no provenance at all: not +// ClusterWide, not bundled. They predate rule signing and must go through the +// same untouched fast path, duplicates included. +func TestScopeRulesToBundle_UnstampedRulesUnchanged(t *testing.T) { + in := []rulemanagertypesv1.Rule{ + {ID: "R0001", Name: "a", Enabled: true}, + {ID: "R0001", Name: "a-with-other-params", Enabled: true}, + {ID: "R0002", Name: "b", Enabled: true}, + } + for _, b := range []string{"redis", ""} { + got := scopeRulesToBundle(in, b) + if len(got) != len(in) { + t.Fatalf("bundle %q: got %+v, want all %d rules unchanged", b, ids(got), len(in)) + } + for i := range in { + if got[i].Name != in[i].Name { + t.Fatalf("input was altered at %d: %+v vs %+v", i, got[i], in[i]) + } + } + } +} + +// A rule registered directly by a third party carries no provenance at all +// (neither ClusterWide nor a Bundle). It must be treated as cluster-wide, never +// silently dropped. +func TestScopeRulesToBundle_UnstampedRuleTreatedAsClusterWide(t *testing.T) { + in := []rulemanagertypesv1.Rule{ + {ID: "R0009", Name: "third-party", Enabled: true}, + overlayRule("R0001", "redis-override", "redis"), + } + got := scopeRulesToBundle(in, "redis") + if len(got) != 2 { + t.Fatalf("got %+v, want both rules", ids(got)) + } + if got := scopeRulesToBundle(in, "postgres"); len(got) != 1 || got[0].ID != "R0009" { + t.Fatalf("got %+v, want only the unstamped rule", ids(got)) + } +} + +func TestScopeRulesToBundle_Empty(t *testing.T) { + if got := scopeRulesToBundle(nil, "redis"); len(got) != 0 { + t.Fatalf("got %+v, want none", got) + } +} diff --git a/pkg/rulebindingmanager/cache/signed_binding_test.go b/pkg/rulebindingmanager/cache/signed_binding_test.go new file mode 100644 index 0000000000..9bc2ad548c --- /dev/null +++ b/pkg/rulebindingmanager/cache/signed_binding_test.go @@ -0,0 +1,333 @@ +//go:build experimental_signing + +package cache + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "reflect" + "testing" + + "github.com/kubescape/k8s-interface/k8sinterface" + "github.com/kubescape/node-agent/mocks" + typesv1 "github.com/kubescape/node-agent/pkg/rulebindingmanager/types/v1" + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/node-agent/pkg/signature/profiles" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + k8sfake "k8s.io/client-go/kubernetes/fake" +) + +const signedBindingPodID = "default/nginx-77b4fdf86c-hp4x5" + +func bindingTestKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("gen key: %v", err) + } + return k +} + +func bindingTestCache(t *testing.T) *RBCache { + t.Helper() + defer func() { mocks.NAMESPACE = "" }() + + k8sClient := k8sinterface.NewKubernetesApiMock() + var r []runtime.Object + mocks.NAMESPACE = "default" + r = append(r, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: mocks.NAMESPACE, Labels: map[string]string{"app": mocks.NAMESPACE}}}) + r = append(r, mocks.GetRuntime(mocks.TestKindPod, mocks.TestNginx)) + k8sClient.KubernetesClient = k8sfake.NewClientset(r...) + + c := NewCacheMock("") + c.k8sClient = k8sClient + return c +} + +func bindingFor(name, class string) *typesv1.RuntimeAlertRuleBinding { + labels := map[string]string{} + if class != "" { + labels[bundle.LabelFragmentClass] = class + } + return &typesv1.RuntimeAlertRuleBinding{ + TypeMeta: metav1.TypeMeta{APIVersion: "kubescape.io/v1", Kind: "RuntimeRuleAlertBinding"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + }, + Spec: typesv1.RuntimeAlertRuleBindingSpec{ + Rules: []typesv1.RuntimeAlertRuleBindingRule{{RuleID: "R0001"}}, + }, + } +} + +func signBinding(t *testing.T, b *typesv1.RuntimeAlertRuleBinding, key *ecdsa.PrivateKey) *typesv1.RuntimeAlertRuleBinding { + t.Helper() + if err := signature.SignObject(profiles.NewRuleBindingAdapter(b), signature.WithPrivateKey(key)); err != nil { + t.Fatalf("sign %s: %v", b.Name, err) + } + return b +} + +func policyTrusting(t *testing.T, b *typesv1.RuntimeAlertRuleBinding) *bundle.TrustPolicy { + t.Helper() + signer, err := bundle.BindingSignerID(b) + if err != nil { + t.Fatalf("signer identity: %v", err) + } + return &bundle.TrustPolicy{BindingClasses: map[bundle.FragmentClass]bundle.BindingClassPolicy{ + bundle.ClassOverlay: {Signers: []string{signer}}, + }} +} + +func TestRBCache_BindingSigningEnabledFlag(t *testing.T) { + c := NewCacheMock("") + if c.bindingSigningEnabled() { + t.Error("a cache with no trust policy must report binding signing disabled") + } + c.SetTrustPolicy(nil) + if c.bindingSigningEnabled() { + t.Error("a nil trust policy must report binding signing disabled") + } + c.SetTrustPolicy(&bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{bundle.RuleClassBase: {}}}) + if c.bindingSigningEnabled() { + t.Error("a policy with only ruleClasses must report binding signing disabled") + } + c.SetTrustPolicy(&bundle.TrustPolicy{BindingClasses: map[bundle.FragmentClass]bundle.BindingClassPolicy{bundle.ClassOverlay: {}}}) + if !c.bindingSigningEnabled() { + t.Error("a policy with bindingClasses must report binding signing enabled") + } +} + +func TestRBCache_SignedBindingAdmitted(t *testing.T) { + key := bindingTestKey(t) + b := signBinding(t, bindingFor("rb-signed", string(bundle.ClassOverlay)), key) + + c := bindingTestCache(t) + c.SetTrustPolicy(policyTrusting(t, b)) + c.addRuleBinding(b) + + rbName := uniqueName(b) + if !c.rbNameToRB.Has(rbName) { + t.Fatal("a validly signed binding by a trusted signer must be added to the cache") + } + if !c.podToRBNames.Has(signedBindingPodID) { + t.Fatalf("pod %s must be bound to the admitted binding", signedBindingPodID) + } + if got := c.ListRulesForPod("default", "nginx-77b4fdf86c-hp4x5"); len(got) != 1 || got[0].ID != "R0001" { + t.Fatalf("ListRulesForPod = %+v, want the single bound rule R0001", got) + } +} + +func TestRBCache_TamperedBindingIsRefused(t *testing.T) { + key := bindingTestKey(t) + b := signBinding(t, bindingFor("rb-tampered", string(bundle.ClassOverlay)), key) + policy := policyTrusting(t, b) + + b.Spec.Rules = nil + + c := bindingTestCache(t) + c.SetTrustPolicy(policy) + rbs := c.addRuleBinding(b) + + rbName := uniqueName(b) + if c.rbNameToRB.Has(rbName) { + t.Error("a binding whose spec was edited after signing must not enter the cache") + } + if c.rbNameToRules.Has(rbName) || c.rbNameToPods.Has(rbName) { + t.Error("a refused binding must leave no rules or pod bookkeeping behind") + } + if c.podToRBNames.Has(signedBindingPodID) { + t.Errorf("pod %s must not be bound to a refused binding", signedBindingPodID) + } + if len(rbs) != 0 { + t.Errorf("a refused binding must notify nobody, got %d notifications", len(rbs)) + } + if got := c.ListRulesForPod("default", "nginx-77b4fdf86c-hp4x5"); len(got) != 0 { + t.Errorf("a refused binding must produce no rules for its pods, got %+v", got) + } +} + +func TestRBCache_UnsignedBindingIsRefused(t *testing.T) { + key := bindingTestKey(t) + trusted := signBinding(t, bindingFor("rb-trusted", string(bundle.ClassOverlay)), key) + policy := policyTrusting(t, trusted) + + unsigned := bindingFor("rb-unsigned", string(bundle.ClassOverlay)) + + c := bindingTestCache(t) + c.SetTrustPolicy(policy) + c.addRuleBinding(unsigned) + + if c.rbNameToRB.Has(uniqueName(unsigned)) { + t.Error("an unsigned binding must not enter the cache when binding signing is enabled") + } + if c.podToRBNames.Has(signedBindingPodID) { + t.Errorf("pod %s must not be bound to an unsigned binding", signedBindingPodID) + } + if got := c.ListRulesForPod("default", "nginx-77b4fdf86c-hp4x5"); len(got) != 0 { + t.Errorf("an unsigned binding must produce no rules for its pods, got %+v", got) + } +} + +func TestRBCache_UntrustedSignerBindingIsRefused(t *testing.T) { + vendor, attacker := bindingTestKey(t), bindingTestKey(t) + trusted := signBinding(t, bindingFor("rb-trusted", string(bundle.ClassOverlay)), vendor) + rogue := signBinding(t, bindingFor("rb-rogue", string(bundle.ClassOverlay)), attacker) + + c := bindingTestCache(t) + c.SetTrustPolicy(policyTrusting(t, trusted)) + c.addRuleBinding(rogue) + + if c.rbNameToRB.Has(uniqueName(rogue)) { + t.Error("a binding signed by an untrusted key must not enter the cache") + } + if c.podToRBNames.Has(signedBindingPodID) { + t.Errorf("pod %s must not be bound to a binding from an untrusted signer", signedBindingPodID) + } +} + +func TestRBCache_ModifyToTamperedBindingUnbindsPods(t *testing.T) { + key := bindingTestKey(t) + b := signBinding(t, bindingFor("rb-modified", string(bundle.ClassOverlay)), key) + policy := policyTrusting(t, b) + + c := bindingTestCache(t) + c.SetTrustPolicy(policy) + c.addRuleBinding(b) + if !c.podToRBNames.Has(signedBindingPodID) { + t.Fatalf("precondition: pod %s must be bound before the tampering update", signedBindingPodID) + } + + tampered := *b + tampered.Spec.Rules = []typesv1.RuntimeAlertRuleBindingRule{{RuleID: "R9999"}} + c.modifiedRuleBinding(&tampered) + + if c.rbNameToRB.Has(uniqueName(&tampered)) { + t.Error("an update that fails admission must not leave the binding in the cache") + } + if c.podToRBNames.Has(signedBindingPodID) { + t.Errorf("pod %s must be unbound after a refused update", signedBindingPodID) + } + if got := c.ListRulesForPod("default", "nginx-77b4fdf86c-hp4x5"); len(got) != 0 { + t.Errorf("a refused update must produce no rules for its pods, got %+v", got) + } +} + +func TestRBCache_SigningDisabledAddsBindingUnchanged(t *testing.T) { + key := bindingTestKey(t) + rbName := "" + + snapshot := func(c *RBCache) (typesv1.RuntimeAlertRuleBinding, []rulemanagertypesv1.Rule, []string, []rulemanagertypesv1.Rule) { + rb := c.rbNameToRB.Get(rbName) + rules := c.rbNameToRules.Get(rbName) + pods := c.rbNameToPods.Get(rbName).ToSlice() + return rb, rules, pods, c.ListRulesForPod("default", "nginx-77b4fdf86c-hp4x5") + } + + unsigned := bindingFor("rb-nopolicy", string(bundle.ClassOverlay)) + rbName = uniqueName(unsigned) + + base := bindingTestCache(t) + base.addRuleBinding(unsigned) + baseRB, baseRules, basePods, basePodRules := snapshot(base) + + if !base.rbNameToRB.Has(rbName) { + t.Fatal("precondition: with no trust policy the unsigned binding must be cached") + } + + for _, tc := range []struct { + name string + policy *bundle.TrustPolicy + }{ + {name: "nil policy", policy: nil}, + {name: "profile-only policy", policy: &bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{bundle.ClassBase: {Signers: []string{"key:none"}}}}}, + {name: "rules-only policy", policy: &bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{bundle.RuleClassBase: {Signers: []string{"key:none"}}}}}, + } { + t.Run(tc.name, func(t *testing.T) { + c := bindingTestCache(t) + c.SetTrustPolicy(tc.policy) + c.addRuleBinding(bindingFor("rb-nopolicy", string(bundle.ClassOverlay))) + + gotRB, gotRules, gotPods, gotPodRules := snapshot(c) + if !reflect.DeepEqual(gotRB, baseRB) { + t.Errorf("cached binding differs from the no-policy path:\n got %+v\nwant %+v", gotRB, baseRB) + } + if !reflect.DeepEqual(gotRules, baseRules) { + t.Errorf("cached rules differ from the no-policy path:\n got %+v\nwant %+v", gotRules, baseRules) + } + if !reflect.DeepEqual(gotPods, basePods) { + t.Errorf("bound pods differ from the no-policy path:\n got %+v\nwant %+v", gotPods, basePods) + } + if !reflect.DeepEqual(gotPodRules, basePodRules) { + t.Errorf("ListRulesForPod differs from the no-policy path:\n got %+v\nwant %+v", gotPodRules, basePodRules) + } + }) + } + + signed := signBinding(t, bindingFor("rb-nopolicy", string(bundle.ClassOverlay)), key) + c := bindingTestCache(t) + c.addRuleBinding(signed) + if !c.rbNameToRB.Has(rbName) { + t.Error("with binding signing disabled a signed binding must be cached exactly like any other") + } +} + +func TestRBCache_EmbeddedBindingUsesAuthenticatedSpec(t *testing.T) { + for _, field := range []string{"rules", "pod selector", "namespace selector"} { + t.Run(field, func(t *testing.T) { + b := bindingFor("rb-embedded", string(bundle.ClassOverlay)) + b.Spec.PodSelector = metav1.LabelSelector{MatchLabels: map[string]string{"app": "nginx"}} + b.Spec.NamespaceSelector = metav1.LabelSelector{MatchLabels: map[string]string{"app": "default"}} + want := b.Spec + if err := signature.SignObject(profiles.NewRuleBindingAdapter(b), signature.WithPrivateKey(bindingTestKey(t)), signature.WithEmbedContent(true)); err != nil { + t.Fatal(err) + } + policy := policyTrusting(t, b) + switch field { + case "rules": + b.Spec.Rules = nil + case "pod selector": + b.Spec.PodSelector = metav1.LabelSelector{MatchLabels: map[string]string{"app": "attacker"}} + case "namespace selector": + b.Spec.NamespaceSelector = metav1.LabelSelector{MatchLabels: map[string]string{"app": "attacker"}} + } + stored := b.Spec + c := bindingTestCache(t) + c.SetTrustPolicy(policy) + c.addRuleBinding(b) + if got := c.rbNameToRB.Get(uniqueName(b)).Spec; !reflect.DeepEqual(got, want) { + t.Errorf("cached spec = %+v, want authenticated %+v", got, want) + } + if !reflect.DeepEqual(b.Spec, stored) { + t.Error("admission mutated the informer object") + } + assertRules := func() { + t.Helper() + if got := c.ListRulesForPod("default", "nginx-77b4fdf86c-hp4x5"); len(got) != 1 || got[0].ID != "R0001" { + t.Fatalf("rules = %+v, want authenticated R0001", got) + } + } + assertRules() + c.RefreshRuleBindingsRules() + assertRules() + c.deletePod(signedBindingPodID) + pod, err := c.k8sClient.GetKubernetesClient().CoreV1().Pods("default").Get(context.Background(), "nginx-77b4fdf86c-hp4x5", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + c.addPod(context.Background(), pod) + if !c.podToRBNames.Has(signedBindingPodID) { + t.Fatal("later pod admission must use authenticated selectors") + } + c.rulesForPod.Purge() + assertRules() + }) + } +} diff --git a/pkg/rulemanager/rulecreator/factory.go b/pkg/rulemanager/rulecreator/factory.go index 9cb40f7f2c..b1f5348e95 100644 --- a/pkg/rulemanager/rulecreator/factory.go +++ b/pkg/rulemanager/rulecreator/factory.go @@ -1,3 +1,5 @@ +//go:build !experimental_signing + package rulecreator import ( diff --git a/pkg/rulemanager/rulecreator/factory_signing.go b/pkg/rulemanager/rulecreator/factory_signing.go new file mode 100644 index 0000000000..4ed28e3757 --- /dev/null +++ b/pkg/rulemanager/rulecreator/factory_signing.go @@ -0,0 +1,288 @@ +//go:build experimental_signing + +package rulecreator + +import ( + "slices" + "sync" + + "github.com/kubescape/node-agent/pkg/contextdetection" + "github.com/kubescape/node-agent/pkg/rulemanager/prefilter" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/utils" +) + +var _ RuleCreator = (*RuleCreatorImpl)(nil) + +type RuleCreatorImpl struct { + mutex sync.RWMutex + Rules []typesv1.Rule +} + +func NewRuleCreator() *RuleCreatorImpl { + return &RuleCreatorImpl{} +} + +func (r *RuleCreatorImpl) CreateRulesByTags(tags []string) []typesv1.Rule { + var rules []typesv1.Rule + for _, rule := range r.Rules { + for _, tag := range tags { + if slices.Contains(rule.Tags, tag) { + rules = append(rules, rule) + break + } + } + } + return rules +} + +// CreateRuleByID returns the rule with the given ID. +// +// Since signed bundle overlays may carry a rule ID that also exists +// cluster-wide, an ID can now match several entries. This single-return lookup +// deliberately prefers the CLUSTER-WIDE variant (Bundle == "") so its result is +// identical to the pre-signing behaviour for every existing caller; bundle +// overriding is resolved later, per pod, in the rule-binding cache +// (scopeRulesToBundle). If no cluster-wide variant exists, the first match +// wins. +func (r *RuleCreatorImpl) CreateRuleByID(id string) typesv1.Rule { + var fallback typesv1.Rule + var found bool + for _, rule := range r.Rules { + if rule.ID != id { + continue + } + if rule.ClusterWide || rule.Bundle == "" { + return rule + } + if !found { + fallback, found = rule, true + } + } + return fallback +} + +// CreateRuleByName returns the rule with the given name, preferring the +// cluster-wide variant for the same reason as CreateRuleByID. +func (r *RuleCreatorImpl) CreateRuleByName(name string) typesv1.Rule { + var fallback typesv1.Rule + var found bool + for _, rule := range r.Rules { + if rule.Name != name { + continue + } + if rule.ClusterWide || rule.Bundle == "" { + return rule + } + if !found { + fallback, found = rule, true + } + } + return fallback +} + +// CreateRulesByID returns EVERY rule carrying the given ID: the cluster-wide +// rule and each bundle overlay that overrides it. Unlike CreateRuleByID this +// makes no choice — the choice belongs to the per-pod resolution in the +// rule-binding cache, which knows the pod's bundle. +// Ordering follows registration order, so it is deterministic. +func (r *RuleCreatorImpl) CreateRulesByID(id string) []typesv1.Rule { + var rules []typesv1.Rule + for _, rule := range r.Rules { + if rule.ID == id { + rules = append(rules, rule) + } + } + return rules +} + +// CreateRulesByName returns EVERY rule carrying the given name, for the same +// reason as CreateRulesByID. +func (r *RuleCreatorImpl) CreateRulesByName(name string) []typesv1.Rule { + var rules []typesv1.Rule + for _, rule := range r.Rules { + if rule.Name == name { + rules = append(rules, rule) + } + } + return rules +} + +func (r *RuleCreatorImpl) RegisterRule(rule typesv1.Rule) { + r.Rules = append(r.Rules, rule) +} + +func (r *RuleCreatorImpl) CreateRulesByEventType(eventType utils.EventType) []typesv1.Rule { + var rules []typesv1.Rule + for _, rule := range r.Rules { + for _, expression := range rule.Expressions.RuleExpression { + if expression.EventType == eventType { + rules = append(rules, rule) + break + } + } + } + return rules +} + +func (r *RuleCreatorImpl) CreateRulePolicyRulesByEventType(eventType utils.EventType) []typesv1.Rule { + rules := r.CreateRulesByEventType(eventType) + for _, rule := range rules { + if rule.SupportPolicy { + rules = append(rules, rule) + } + } + + return rules +} + +func (r *RuleCreatorImpl) GetAllRuleIDs() []string { + r.mutex.RLock() + defer r.mutex.RUnlock() + + var ruleIDs []string + for _, rule := range r.Rules { + ruleIDs = append(ruleIDs, rule.ID) + } + return ruleIDs +} + +func (r *RuleCreatorImpl) CreateAllRules() []typesv1.Rule { + r.mutex.Lock() + defer r.mutex.Unlock() + + if len(r.Rules) == 0 { + return nil + } + rules := make([]typesv1.Rule, len(r.Rules)) + for i := range r.Rules { + if r.Rules[i].Prefilter == nil { + r.Rules[i].Prefilter = prefilter.ParseWithDefaults(r.Rules[i].State, nil) + } + rules[i] = r.Rules[i] + } + return rules +} + +func (r *RuleCreatorImpl) CreateRulesForContext(ctx contextdetection.EventSourceContext) []typesv1.Rule { + r.mutex.Lock() + defer r.mutex.Unlock() + + var rules []typesv1.Rule + for i := range r.Rules { + if !RuleMatchesContext(&r.Rules[i], ctx) { + continue + } + if r.Rules[i].Prefilter == nil { + r.Rules[i].Prefilter = prefilter.ParseWithDefaults(r.Rules[i].State, nil) + } + rules = append(rules, r.Rules[i]) + } + return rules +} + +// ruleKey identifies a rule inside the creator's rule set. A rule ID alone is +// NOT unique any more: a signed bundle overlay may carry the same rule ID as the +// cluster-wide rule it overrides for that bundle, and the two must coexist here +// (bundle resolution happens later, in the rule-binding cache). Cluster-wide +// rules have an empty Bundle, so their key is "/" and the pre-signing +// behaviour is unchanged. +func ruleKey(rule typesv1.Rule) string { + return rule.Bundle + "/" + rule.ID +} + +// SyncRules replaces the current rules with the new set of rules +// It removes rules that are no longer present and adds/updates existing ones +func (r *RuleCreatorImpl) SyncRules(newRules []typesv1.Rule) { + r.mutex.Lock() + defer r.mutex.Unlock() + + // Create a map of new rules by (bundle, ID) for quick lookup + newRuleMap := make(map[string]typesv1.Rule) + for _, rule := range newRules { + newRuleMap[ruleKey(rule)] = rule + } + + // Remove rules that are no longer present + var updatedRules []typesv1.Rule + for _, existingRule := range r.Rules { + key := ruleKey(existingRule) + if newRule, exists := newRuleMap[key]; exists { + // Rule still exists, use the new version + updatedRules = append(updatedRules, newRule) + delete(newRuleMap, key) // Mark as processed + } + // If rule doesn't exist in newRuleMap, it's removed (not added to updatedRules) + } + + // Preserve input order for new variants so fallback selection is stable. + for _, rule := range newRules { + key := ruleKey(rule) + if newRule, exists := newRuleMap[key]; exists { + updatedRules = append(updatedRules, newRule) + delete(newRuleMap, key) + } + } + + r.Rules = updatedRules +} + +// RemoveRuleByID removes a rule with the given ID and returns true if found. +// Like CreateRuleByID it prefers the cluster-wide variant when several rules +// share an ID, so its effect on a cluster without bundle overlays is unchanged. +func (r *RuleCreatorImpl) RemoveRuleByID(id string) bool { + r.mutex.Lock() + defer r.mutex.Unlock() + + fallback := -1 + for i, rule := range r.Rules { + if rule.ID != id { + continue + } + if rule.ClusterWide || rule.Bundle == "" { + r.Rules = append(r.Rules[:i], r.Rules[i+1:]...) + return true + } + if fallback < 0 { + fallback = i + } + } + if fallback >= 0 { + r.Rules = append(r.Rules[:fallback], r.Rules[fallback+1:]...) + return true + } + return false +} + +// UpdateRule updates an existing rule or adds it if it doesn't exist. The match +// is on the composite (bundle, ID) key so updating a cluster-wide rule never +// clobbers a bundle overlay with the same ID, and vice versa. +func (r *RuleCreatorImpl) UpdateRule(rule typesv1.Rule) bool { + r.mutex.Lock() + defer r.mutex.Unlock() + + key := ruleKey(rule) + for i, existingRule := range r.Rules { + if ruleKey(existingRule) == key { + r.Rules[i] = rule + return true + } + } + + // Rule not found, add it + r.Rules = append(r.Rules, rule) + return false +} + +// HasRule checks if a rule with the given ID exists +func (r *RuleCreatorImpl) HasRule(id string) bool { + r.mutex.RLock() + defer r.mutex.RUnlock() + + for _, rule := range r.Rules { + if rule.ID == id { + return true + } + } + return false +} diff --git a/pkg/rulemanager/rulecreator/ruleengine_interface.go b/pkg/rulemanager/rulecreator/ruleengine_interface.go index f09b808ef0..437f95cbe7 100644 --- a/pkg/rulemanager/rulecreator/ruleengine_interface.go +++ b/pkg/rulemanager/rulecreator/ruleengine_interface.go @@ -1,3 +1,5 @@ +//go:build !experimental_signing + package rulecreator import ( diff --git a/pkg/rulemanager/rulecreator/ruleengine_interface_signing.go b/pkg/rulemanager/rulecreator/ruleengine_interface_signing.go new file mode 100644 index 0000000000..7b2d1f3d28 --- /dev/null +++ b/pkg/rulemanager/rulecreator/ruleengine_interface_signing.go @@ -0,0 +1,54 @@ +//go:build experimental_signing + +package rulecreator + +import ( + "github.com/kubescape/node-agent/pkg/contextdetection" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/utils" + + "github.com/armosec/armoapi-go/armotypes" +) + +// ProfileRequirement indicates how a rule uses profiles +type ProfileRequirement struct { + // ProfileDependency indicates if the rule requires a profile + ProfileDependency armotypes.ProfileDependency + + // ProfileType indicates what type of profile is needed (Application, Network, etc) + ProfileType armotypes.ProfileType +} + +// RuleCreator is an interface for creating rules by tags, IDs, and names +type RuleCreator interface { + CreateRulesByTags(tags []string) []typesv1.Rule + + // CreateRuleByID / CreateRuleByName return a SINGLE rule and prefer the + // cluster-wide variant when a signed bundle overlay carries the same ID or + // name. They are kept for callers that genuinely want one rule. + CreateRuleByID(id string) typesv1.Rule + CreateRuleByName(name string) typesv1.Rule + + // CreateRulesByID / CreateRulesByName return EVERY variant carrying that ID + // or name — the cluster-wide rule plus each bundle overlay. + // Callers that resolve rules per pod (the rule-binding cache) must use these, + // otherwise a bundle overlay can never override the cluster-wide rule it is + // meant to replace: the override is decided later, per bundle, by + // scopeRulesToBundle, which can only choose between variants it was + // given. Order is the creator's registration order, hence deterministic. + CreateRulesByID(id string) []typesv1.Rule + CreateRulesByName(name string) []typesv1.Rule + + RegisterRule(rule typesv1.Rule) + CreateRulesByEventType(eventType utils.EventType) []typesv1.Rule + CreateRulePolicyRulesByEventType(eventType utils.EventType) []typesv1.Rule + CreateAllRules() []typesv1.Rule + CreateRulesForContext(ctx contextdetection.EventSourceContext) []typesv1.Rule + GetAllRuleIDs() []string + + // Dynamic rule management methods for CRD sync + SyncRules(newRules []typesv1.Rule) + RemoveRuleByID(id string) bool + UpdateRule(rule typesv1.Rule) bool + HasRule(id string) bool +} diff --git a/pkg/rulemanager/rulecreator/ruleengine_mock.go b/pkg/rulemanager/rulecreator/ruleengine_mock.go index a004e0fa2f..48067aedf6 100644 --- a/pkg/rulemanager/rulecreator/ruleengine_mock.go +++ b/pkg/rulemanager/rulecreator/ruleengine_mock.go @@ -1,3 +1,5 @@ +//go:build !experimental_signing + package rulecreator import ( diff --git a/pkg/rulemanager/rulecreator/ruleengine_mock_signing.go b/pkg/rulemanager/rulecreator/ruleengine_mock_signing.go new file mode 100644 index 0000000000..b8d9e6400c --- /dev/null +++ b/pkg/rulemanager/rulecreator/ruleengine_mock_signing.go @@ -0,0 +1,114 @@ +//go:build experimental_signing + +package rulecreator + +import ( + "github.com/kubescape/node-agent/pkg/contextdetection" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/utils" +) + +var _ RuleCreator = (*RuleCreatorMock)(nil) + +type RuleCreatorMock struct { + Rules []typesv1.Rule +} + +func (r *RuleCreatorMock) CreateRulesByTags(tags []string) []typesv1.Rule { + var rl []typesv1.Rule + for _, t := range tags { + rl = append(rl, typesv1.Rule{ + Name: t, + Tags: []string{t}, + }) + } + return rl +} + +func (r *RuleCreatorMock) CreateRuleByID(id string) typesv1.Rule { + return typesv1.Rule{ + ID: id, + } +} + +func (r *RuleCreatorMock) CreateRuleByName(name string) typesv1.Rule { + return typesv1.Rule{ + Name: name, + } +} + +func (r *RuleCreatorMock) CreateRulesByID(id string) []typesv1.Rule { + return []typesv1.Rule{r.CreateRuleByID(id)} +} + +func (r *RuleCreatorMock) CreateRulesByName(name string) []typesv1.Rule { + return []typesv1.Rule{r.CreateRuleByName(name)} +} + +func (r *RuleCreatorMock) RegisterRule(rule typesv1.Rule) { +} + +func (r *RuleCreatorMock) CreateRulesByEventType(eventType utils.EventType) []typesv1.Rule { + return []typesv1.Rule{} +} + +func (r *RuleCreatorMock) CreateRulePolicyRulesByEventType(eventType utils.EventType) []typesv1.Rule { + return []typesv1.Rule{} +} + +func (r *RuleCreatorMock) CreateAllRules() []typesv1.Rule { + return []typesv1.Rule{} +} + +func (r *RuleCreatorMock) CreateRulesForContext(ctx contextdetection.EventSourceContext) []typesv1.Rule { + var rules []typesv1.Rule + for i := range r.Rules { + if RuleMatchesContext(&r.Rules[i], ctx) { + rules = append(rules, r.Rules[i]) + } + } + return rules +} + +func (r *RuleCreatorMock) GetAllRuleIDs() []string { + var ids []string + for _, rule := range r.Rules { + ids = append(ids, rule.ID) + } + return ids +} + +// Dynamic rule management methods for CRD sync +func (r *RuleCreatorMock) SyncRules(newRules []typesv1.Rule) { + r.Rules = newRules +} + +func (r *RuleCreatorMock) RemoveRuleByID(id string) bool { + for i, rule := range r.Rules { + if rule.ID == id { + r.Rules = append(r.Rules[:i], r.Rules[i+1:]...) + return true + } + } + return false +} + +func (r *RuleCreatorMock) UpdateRule(rule typesv1.Rule) bool { + for i, existingRule := range r.Rules { + if existingRule.ID == rule.ID { + r.Rules[i] = rule + return true + } + } + r.Rules = append(r.Rules, rule) + return false +} + +func (r *RuleCreatorMock) HasRule(id string) bool { + for _, rule := range r.Rules { + if rule.ID == id { + return true + } + } + return false +} diff --git a/pkg/rulemanager/rulecreator/stacking_test.go b/pkg/rulemanager/rulecreator/stacking_test.go new file mode 100644 index 0000000000..eca901f25b --- /dev/null +++ b/pkg/rulemanager/rulecreator/stacking_test.go @@ -0,0 +1,130 @@ +//go:build experimental_signing + +// Stacking truth table for the effective ruleset: rules from stacked Rules +// fragments are ADDITIVE across distinct (bundle, ID) keys and REPLACEMENT +// within the same key; evaluation prefers the cluster-wide variant when the +// same ID exists in several bundles; a fragment leaving the cluster removes +// exactly its rules on the next sync. +package rulecreator + +import ( + "testing" + + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/stretchr/testify/assert" +) + +func rule(bundle, id, name string, clusterWide bool) typesv1.Rule { + return typesv1.Rule{ID: id, Name: name, Bundle: bundle, ClusterWide: clusterWide, Enabled: true} +} + +// R-A1: distinct IDs from stacked fragments union into the effective set. +func TestRuleStacking_AdditiveAcrossIDs(t *testing.T) { + r := NewRuleCreator() + r.SyncRules([]typesv1.Rule{ + rule("base", "R0001", "exec", false), + rule("overlay", "R0011", "egress", false), + }) + assert.Len(t, r.Rules, 2, "distinct (bundle, ID) keys are additive") +} + +// R-R1: the same (bundle, ID) appearing twice in one sync collapses to ONE +// rule — the later element replaces the earlier (map overwrite in sync order). +func TestRuleStacking_SameBundleSameIDReplaces(t *testing.T) { + r := NewRuleCreator() + r.SyncRules([]typesv1.Rule{ + rule("base", "R0001", "exec-v1", false), + rule("base", "R0001", "exec-v2", false), + }) + assert.Len(t, r.Rules, 1, "same (bundle, ID) must collapse to a single rule") + assert.Equal(t, "exec-v2", r.Rules[0].Name, "the later occurrence replaces the earlier") +} + +// R-A2: the same ID stacked from DIFFERENT bundles coexists — bundle-scoped +// variants are distinct keys, not replacements of each other. +func TestRuleStacking_SameIDAcrossBundlesCoexists(t *testing.T) { + r := NewRuleCreator() + r.SyncRules([]typesv1.Rule{ + rule("", "R0001", "exec-clusterwide", true), + rule("tenant-a", "R0001", "exec-a", false), + rule("tenant-b", "R0001", "exec-b", false), + }) + assert.Len(t, r.Rules, 3, "same ID across bundles must coexist (multi-stack)") +} + +// R-P1: when several stacked variants share an ID, evaluation prefers the +// cluster-wide / bundle-less one, falling back to the first bundle-scoped. +func TestRuleStacking_EvaluationPrefersClusterWide(t *testing.T) { + r := NewRuleCreator() + r.SyncRules([]typesv1.Rule{ + rule("tenant-a", "R0001", "exec-a", false), + rule("", "R0001", "exec-clusterwide", true), + }) + assert.Equal(t, "exec-clusterwide", r.CreateRuleByID("R0001").Name, + "cluster-wide variant wins evaluation over bundle-scoped") + + r2 := NewRuleCreator() + r2.SyncRules([]typesv1.Rule{ + rule("tenant-a", "R0001", "exec-a", false), + rule("tenant-b", "R0001", "exec-b", false), + }) + assert.Equal(t, "exec-a", r2.CreateRuleByID("R0001").Name, + "without a cluster-wide variant, the first stacked variant is the fallback") +} + +// R-M1: three stacked bundles with the same ID — removal takes the preferred +// (cluster-wide) variant first and leaves the bundle-scoped stack intact. +func TestRuleStacking_MultiStackRemovalOrder(t *testing.T) { + r := NewRuleCreator() + r.SyncRules([]typesv1.Rule{ + rule("tenant-a", "R0001", "exec-a", false), + rule("", "R0001", "exec-clusterwide", true), + rule("tenant-b", "R0001", "exec-b", false), + }) + assert.True(t, r.RemoveRuleByID("R0001")) + assert.Len(t, r.Rules, 2, "removal takes exactly one variant") + for _, left := range r.Rules { + assert.False(t, left.ClusterWide, "the cluster-wide variant is removed first") + } +} + +// R-S1: a fragment leaving the cluster removes exactly its rules on the next +// sync; surviving keys keep their (possibly updated) definitions. +func TestRuleStacking_FragmentRemovalDropsItsRules(t *testing.T) { + r := NewRuleCreator() + r.SyncRules([]typesv1.Rule{ + rule("base", "R0001", "exec", false), + rule("overlay", "R0011", "egress", false), + }) + r.SyncRules([]typesv1.Rule{ + rule("base", "R0001", "exec-updated", false), + }) + assert.Len(t, r.Rules, 1, "the departed fragment's rules must be gone") + assert.Equal(t, "exec-updated", r.Rules[0].Name, "the surviving key carries the new definition") +} + +// W6: multi-rule fragments with PARTIAL cross-bundle ID overlap — all distinct +// (bundle, ID) keys coexist; evaluation of the overlapping ID prefers the +// cluster-wide (base) variant; removing the base bundle's rules on a re-sync +// leaves the overlay's variant to govern. +func TestRuleStacking_MultiRulePartialOverlapAcrossBundles(t *testing.T) { + r := NewRuleCreator() + r.SyncRules([]typesv1.Rule{ + rule("", "R0001", "base-exec", true), + rule("", "R0004", "base-caps", true), + rule("", "R0011", "base-egress", true), + rule("tenant", "R0001", "tenant-exec", false), + rule("tenant", "R0007", "tenant-open", false), + }) + assert.Len(t, r.Rules, 5, "partial overlap: every (bundle, ID) key coexists") + assert.Equal(t, "base-exec", r.CreateRuleByID("R0001").Name, "overlapping ID evaluates to the cluster-wide variant") + assert.Equal(t, "tenant-open", r.CreateRuleByID("R0007").Name, "overlay-only ID evaluates to the overlay variant") + + // Base bundle departs: overlay's overlapping variant takes over. + r.SyncRules([]typesv1.Rule{ + rule("tenant", "R0001", "tenant-exec", false), + rule("tenant", "R0007", "tenant-open", false), + }) + assert.Len(t, r.Rules, 2) + assert.Equal(t, "tenant-exec", r.CreateRuleByID("R0001").Name, "after the base departs, the overlay variant governs") +} diff --git a/pkg/rulemanager/ruleswatcher/admission.go b/pkg/rulemanager/ruleswatcher/admission.go new file mode 100644 index 0000000000..bf24b239c9 --- /dev/null +++ b/pkg/rulemanager/ruleswatcher/admission.go @@ -0,0 +1,139 @@ +//go:build experimental_signing + +package ruleswatcher + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// admissionTally counts what one sync pass did, for the log line and the +// detection-outage backstop. +type admissionTally struct { + admitted int + rejected int + skippedVersion int +} + +// admitFragments turns the Rules objects found in the cluster into the flat +// enabled-rule set the rule creator is synced with. +// +// It is a free function rather than a loop inside syncAllRulesFromCluster so +// the admission contract can be stated as a table against real signed +// fixtures, without a cluster. The contract it implements, in full: +// +// A1. Signing off: every fragment contributes, cluster-wide, no bundle. This +// reproduces the pre-signing semantics exactly. +// A2. Signing on, fragment admitted: it contributes its rules under its own +// class's provenance — cluster-wide only for the base class, otherwise +// scoped to the bundle named in its signed labels. +// A3. Signing on, fragment rejected: it contributes NOTHING, and it removes +// nothing. Admission is per fragment; a rejection cannot reach across to +// another fragment's rules. This is the property that keeps an untrusted +// fragment from silencing a trusted rule it names. +// A4. A disabled rule contributes nothing, from any fragment. So a fragment +// cannot switch off a rule another fragment enables — it can only fail to +// add its own copy. +// A5. A rule whose agent-version requirement is not met contributes nothing +// and is counted separately, so a version skip is never read as a +// rejection. +// +// What the table does NOT claim, because it is not true: two ADMITTED +// fragments that both claim the same (bundle, rule ID) collide, and which one +// survives is decided downstream by SyncRules' map, not here. That requires +// two separately trusted signers for the same class, which the trust policy +// permits, and it is recorded as a known ambiguity rather than papered over. +func admitFragments(fragments []*typesv1.Rules, policy *bundle.TrustPolicy, signingEnabled bool, onAdmitted func(*typesv1.Rules)) ([]typesv1.Rule, admissionTally) { + var enabledRules []typesv1.Rule + var tally admissionTally + + for _, rules := range fragments { + if rules == nil { + continue + } + + // A1: provenance defaults reproduce the pre-signing semantics exactly — + // every rule is cluster-wide and belongs to no bundle, so bundle + // resolution downstream is a no-op. + bundleName := "" + clusterWide := true + candidates := rules.Spec.Rules + + if signingEnabled { + verified, admitErr := bundle.AdmitRulesFragment(rules, *policy) + if admitErr == nil { + admitErr = validateAdmittedRules(rules, verified.Rules) + } + if admitErr != nil { + // A3: drop this fragment whole and move on. Nothing already + // collected is revisited. + tally.rejected++ + logger.L().Warning("rules fragment rejected", + helpers.String("name", rules.Name), + helpers.String("namespace", rules.Namespace), + helpers.Error(admitErr)) + continue + } + tally.admitted++ + bundleName = verified.Bundle + clusterWide = verified.ClusterWide + candidates = verified.Rules + if onAdmitted != nil { + onAdmitted(rules) + } + } + + for _, rule := range candidates { + if !rule.Enabled { // A4 + continue + } + if rule.AgentVersionRequirement != "" && !isAgentVersionCompatible(rule.AgentVersionRequirement) { // A5 + logger.L().Debug("RulesWatcher - skipping rule due to agent version requirement", + helpers.String("ruleID", rule.ID), + helpers.String("requirement", rule.AgentVersionRequirement), + helpers.String("agentVersion", os.Getenv("AGENT_VERSION"))) + tally.skippedVersion++ + continue + } + rule.Bundle = bundleName + rule.ClusterWide = clusterWide + enabledRules = append(enabledRules, rule) + } + } + + return enabledRules, tally +} + +// Validate the authenticated view, including raw fields that typed decoding can +// discard, before admitting any rules from the fragment. +func validateAdmittedRules(carrier *typesv1.Rules, rules []typesv1.Rule) error { + if content, present, err := signature.EmbeddedContent(profiles.NewRulesAdapter(carrier)); present { + if err != nil { + return err + } + var raw map[string]any + if err := json.Unmarshal(content, &raw); err != nil { + return err + } + if err := validateRawProfileDataInUnstructured(&unstructured.Unstructured{Object: raw}); err != nil { + return err + } + } + for _, rule := range rules { + if rule.ProfileDataRequired != nil { + if err := rule.ProfileDataRequired.Validate(); err != nil { + return fmt.Errorf("rule %q invalid profileDataRequired: %w", rule.ID, err) + } + } + } + return nil +} diff --git a/pkg/rulemanager/ruleswatcher/admission_truthtable_test.go b/pkg/rulemanager/ruleswatcher/admission_truthtable_test.go new file mode 100644 index 0000000000..9166a33d3e --- /dev/null +++ b/pkg/rulemanager/ruleswatcher/admission_truthtable_test.go @@ -0,0 +1,259 @@ +//go:build experimental_signing + +package ruleswatcher + +import ( + "encoding/json" + "os" + "testing" + + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +// The admission truth table (A1-A5, stated on admitFragments). +// +// It exists because a component test cannot tell the two halves of this apart. +// Test_39 asserts that a rejected untrusted fragment leaves the trusted R0001 +// firing, and it does that by exec'ing a binary and watching for an alert — a +// path that also runs through profile completion, the profile cache, the +// cooldown and the alert exporter. When it failed, the failure was in profile +// completion and had nothing to do with signing, but the assertion message +// said "R0001 from the trusted base fragment must still fire" and pointed +// squarely at the admission gate. These tests separate the claim from the +// plumbing: they run the real signed fixtures through the real admission code +// and read the resulting rule set directly. +// +// Every case uses committed, really-signed fixtures. Nothing here constructs a +// fragment that could not exist in a cluster. + +const fixtureDir = "../../../tests/resources/signed/" + +func policy(t *testing.T) *bundle.TrustPolicy { + t.Helper() + raw, err := os.ReadFile(fixtureDir + "trust-policy.json") + require.NoError(t, err, "the committed trust policy is the subject, not a detail") + var p bundle.TrustPolicy + require.NoError(t, json.Unmarshal(raw, &p)) + return &p +} + +func fragment(t *testing.T, rel string) *typesv1.Rules { + t.Helper() + b, err := os.ReadFile(fixtureDir + "rules/" + rel) + require.NoError(t, err, "read %s", rel) + var r typesv1.Rules + require.NoError(t, yaml.Unmarshal(b, &r), "parse %s", rel) + return &r +} + +// severitiesOf collects the severities admitted for one rule ID, keyed by the +// bundle they were admitted under. That is exactly the (bundle, ID) key +// SyncRules uses, so a collision here is a collision there. +func severitiesOf(rules []typesv1.Rule, id string) map[string][]int { + out := map[string][]int{} + for _, r := range rules { + if r.ID == id { + out[r.Bundle] = append(out[r.Bundle], r.Severity) + } + } + return out +} + +func idsOf(rules []typesv1.Rule) map[string]bool { + out := map[string]bool{} + for _, r := range rules { + out[r.ID] = true + } + return out +} + +const ( + baseFixture = "cluster-baseline-signed.yaml" + rogueFixture = "untrusted-signer-signed.yaml" + overlayFixture = "namespace-override-signed.yaml" + mixedFixture = "mixed-overlay-rejected-signed.yaml" + + baseR0001Sev = 1 + rogueR0001Sev = 7 + overlayR0001Sev = 9 + baseR0011Sev = 5 +) + +// A3, the property this whole file exists for, in both orderings. The rogue +// fragment is a base-class R0001 signed by a key no class in the policy names. +// It must contribute nothing, and — the part that matters — it must not take +// the trusted R0001 with it. +func TestAdmission_UntrustedFragmentCannotSilenceTheTrustedRule(t *testing.T) { + base := fragment(t, baseFixture) + rogue := fragment(t, rogueFixture) + + for _, tc := range []struct { + name string + input []*typesv1.Rules + }{ + {"rogue after the base", []*typesv1.Rules{base, rogue}}, + {"rogue before the base", []*typesv1.Rules{rogue, base}}, + } { + t.Run(tc.name, func(t *testing.T) { + rules, tally := admitFragments(tc.input, policy(t), true, nil) + + assert.Equal(t, 1, tally.admitted, "only the base fragment is admissible") + assert.Equal(t, 1, tally.rejected, "the untrusted fragment is rejected") + + sev := severitiesOf(rules, "R0001") + assert.Equal(t, []int{baseR0001Sev}, sev[""], + "the trusted cluster-wide R0001 survives the rejection alongside it") + assert.NotContains(t, sev[""], rogueR0001Sev, "the rogue severity must never be admitted") + assert.Len(t, sev, 1, "the rogue fragment must not have created a second scope for R0001") + + // Not just R0001: nothing else the base fragment carries may go + // missing because a fragment next to it was rejected. + assert.True(t, idsOf(rules)["R0011"], "an unrelated trusted rule is untouched by the rejection") + assert.True(t, idsOf(rules)["R1002"], "an unrelated trusted rule is untouched by the rejection") + }) + } +} + +// A3 again, at the harshest point: the rogue fragment ALONE. Detection goes to +// zero, which is correct — but the tally has to say so, because that number is +// what the outage backstop screams on. +func TestAdmission_RejectedFragmentAloneLeavesNoRulesAndSaysSo(t *testing.T) { + rules, tally := admitFragments([]*typesv1.Rules{fragment(t, rogueFixture)}, policy(t), true, nil) + + assert.Empty(t, rules, "an untrusted fragment contributes no rules at all") + assert.Equal(t, 0, tally.admitted, "admitted==0 with fragments present is the detection-outage signal") + assert.Equal(t, 1, tally.rejected) +} + +// A1: with signing off the gate is not in the path at all, and the rogue +// fragment's R0001 is admitted right next to the base one. This is the +// pre-signing behaviour, and stating it here is what makes the value of +// signing legible: without it, anyone who can create a Rules object retunes +// any rule they name. +func TestAdmission_SigningOffAdmitsEveryFragmentIncludingTheRogueOne(t *testing.T) { + rules, tally := admitFragments([]*typesv1.Rules{fragment(t, baseFixture), fragment(t, rogueFixture)}, nil, false, nil) + + sev := severitiesOf(rules, "R0001") + assert.ElementsMatch(t, []int{baseR0001Sev, rogueR0001Sev}, sev[""], + "unsigned mode admits both, and which one wins is decided downstream by SyncRules") + assert.Equal(t, admissionTally{}, tally, "the tally counts admission decisions, and none were made") +} + +// A2: an admitted overlay is scoped to its signed bundle and is NOT +// cluster-wide, so it coexists with the base rule under a different key rather +// than replacing it. This is the difference between "retune for the workloads +// that opted in" and "retune for the cluster". +func TestAdmission_OverlayIsScopedToItsBundleAndCoexistsWithTheBase(t *testing.T) { + rules, tally := admitFragments([]*typesv1.Rules{fragment(t, baseFixture), fragment(t, overlayFixture)}, policy(t), true, nil) + + assert.Equal(t, 2, tally.admitted) + assert.Equal(t, 0, tally.rejected) + + sev := severitiesOf(rules, "R0001") + assert.Equal(t, []int{baseR0001Sev}, sev[""], "the cluster-wide R0001 is unchanged") + assert.Equal(t, []int{overlayR0001Sev}, sev["bundle37"], "the overlay's R0001 lives under its bundle") + + for _, r := range rules { + if r.ID != "R0001" { + continue + } + if r.Bundle == "bundle37" { + assert.False(t, r.ClusterWide, "an overlay must never be cluster-wide — that is the whole confinement") + } else { + assert.True(t, r.ClusterWide, "the base class is the only cluster-wide class") + } + } +} + +// A3 with a subtler rejection: this overlay is signed by a TRUSTED signer for +// its class, but carries one rule ID (R0011) the overlay class is not allowed +// to touch. The fragment is refused whole — no partial admission of the +// allowed half — and both rules keep their trusted values. +func TestAdmission_TrustedSignerReachingOutsideItsClassIsRefusedWhole(t *testing.T) { + rules, tally := admitFragments([]*typesv1.Rules{fragment(t, baseFixture), fragment(t, mixedFixture)}, policy(t), true, nil) + + assert.Equal(t, 1, tally.admitted) + assert.Equal(t, 1, tally.rejected) + + r1 := severitiesOf(rules, "R0001") + assert.Equal(t, []int{baseR0001Sev}, r1[""]) + assert.NotContains(t, r1, "bundle37", "the allowed half of a refused fragment must not slip through") + + r11 := severitiesOf(rules, "R0011") + assert.Equal(t, []int{baseR0011Sev}, r11[""], "the out-of-class rule keeps its trusted severity") +} + +// A4: a rule the fragment ships disabled contributes nothing. This is why a +// fragment cannot silence a rule by redefining it as disabled — the disabled +// copy simply never enters the set, and any enabled copy from another fragment +// is unaffected. +func TestAdmission_DisabledRulesNeverEnterTheSet(t *testing.T) { + rules, _ := admitFragments([]*typesv1.Rules{fragment(t, baseFixture)}, policy(t), true, nil) + + ids := idsOf(rules) + // R0002, R1003 and R1011 ship disabled in the committed baseline. + assert.False(t, ids["R0002"], "a disabled rule is absent, not present-and-off") + assert.False(t, ids["R1003"]) + assert.False(t, ids["R1011"]) + assert.True(t, ids["R0001"], "the enabled ones from the same fragment are there") +} + +// A5: a version skip is counted apart from a rejection, because the two mean +// opposite things to an operator — one is "your agent is old", the other is +// "someone tried to change your rules". +func TestAdmission_VersionSkipIsNotARejection(t *testing.T) { + t.Setenv("AGENT_VERSION", "v0.1.0") + frag := &typesv1.Rules{Spec: typesv1.RulesSpec{Rules: []typesv1.Rule{ + {ID: "R9998", Enabled: true, AgentVersionRequirement: ">= 99.0.0"}, + {ID: "R9999", Enabled: true}, + }}} + + rules, tally := admitFragments([]*typesv1.Rules{frag}, nil, false, nil) + + assert.Equal(t, []string{"R9999"}, func() []string { + var out []string + for _, r := range rules { + out = append(out, r.ID) + } + return out + }()) + assert.Equal(t, 1, tally.skippedVersion) + assert.Equal(t, 0, tally.rejected, "an incompatible agent version is not an admission failure") +} + +// The known ambiguity, asserted rather than left to be discovered: two +// fragments admitted under the SAME (bundle, ID) key both enter the flat set, +// and SyncRules keeps whichever it maps last. Reaching this needs two signers +// both trusted for the base class, which the policy format permits. Recorded +// here so a change in that resolution shows up as a failing test with a name +// that explains what it is. +func TestAdmission_SameKeyFromTwoAdmittedFragmentsIsNotResolvedHere(t *testing.T) { + dup := func(sev int) *typesv1.Rules { + return &typesv1.Rules{Spec: typesv1.RulesSpec{Rules: []typesv1.Rule{{ID: "R0001", Enabled: true, Severity: sev}}}} + } + rules, _ := admitFragments([]*typesv1.Rules{dup(1), dup(7)}, nil, false, nil) + + assert.Equal(t, []int{1, 7}, severitiesOf(rules, "R0001")[""], + "admission emits both; the collision is decided by SyncRules' map, not here") +} + +func TestAdmission_NilFragmentIsSkipped(t *testing.T) { + rules, tally := admitFragments([]*typesv1.Rules{nil, fragment(t, baseFixture), nil}, policy(t), true, nil) + + assert.Equal(t, 1, tally.admitted) + assert.True(t, idsOf(rules)["R0001"]) +} + +// The divergence callback fires for admitted fragments only. A rejected +// fragment must not be reported as if the agent had taken it into account. +func TestAdmission_DivergenceIsReportedOnlyForAdmittedFragments(t *testing.T) { + var seen []string + _, _ = admitFragments([]*typesv1.Rules{fragment(t, baseFixture), fragment(t, rogueFixture)}, policy(t), true, + func(r *typesv1.Rules) { seen = append(seen, r.Name) }) + + assert.Equal(t, []string{"ct-base-rules"}, seen, "a rejected fragment is never handed downstream") +} diff --git a/pkg/rulemanager/ruleswatcher/embedded_validation_test.go b/pkg/rulemanager/ruleswatcher/embedded_validation_test.go new file mode 100644 index 0000000000..83e0bcc08c --- /dev/null +++ b/pkg/rulemanager/ruleswatcher/embedded_validation_test.go @@ -0,0 +1,85 @@ +//go:build experimental_signing + +package ruleswatcher + +import ( + "context" + "testing" + + "github.com/kubescape/node-agent/pkg/rulemanager/rulecreator" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestEmbeddedRulesIgnoreMalformedStoredSpec(t *testing.T) { + for _, stored := range []struct { + name string + spec any + }{ + {"empty requirement", map[string]any{"rules": []any{map[string]any{"id": "R0001", "enabled": true, "profileDataRequired": map[string]any{"execs": ""}}}}}, + {"undecodable spec", "not a spec"}, + } { + t.Run(stored.name, func(t *testing.T) { + fragment := rulesObj("baseline", "kubescape", "", string(bundle.RuleClassBase), "R0001") + require.NoError(t, signature.SignObject(profiles.NewRulesAdapter(fragment), signature.WithPrivateKey(testKey(t)), signature.WithEmbedContent(true))) + client := newFakeK8sClient(t, fragment) + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(client, creator, nil) + w.SetTrustPolicy(&bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{ + bundle.RuleClassBase: {Signers: []string{signerOf(t, fragment)}, AllowedRuleIDs: []string{"*"}}, + }}) + require.NoError(t, w.InitialSync(context.Background())) + require.Len(t, creator.CreateAllRules(), 1) + resource := client.dyn.Resource(typesv1.RuleGvr).Namespace(fragment.Namespace) + raw, err := resource.Get(context.Background(), fragment.Name, metav1.GetOptions{}) + require.NoError(t, err) + raw.Object["spec"] = stored.spec + _, err = resource.Update(context.Background(), raw, metav1.UpdateOptions{}) + require.NoError(t, err) + require.NoError(t, w.InitialSync(context.Background())) + all := creator.CreateAllRules() + require.Len(t, all, 1) + require.Equal(t, "R0001", all[0].ID) + // Signing disabled still validates the stored spec as before. + w.SetTrustPolicy(nil) + require.NoError(t, w.InitialSync(context.Background())) + require.Empty(t, creator.CreateAllRules()) + }) + } +} + +type rawRulesAdapter struct { + *profiles.RulesAdapter + requirement any +} + +func (a rawRulesAdapter) GetContent() any { + content := a.RulesAdapter.GetContent().(map[string]any) + content["spec"] = map[string]any{"rules": []any{map[string]any{"id": "R0001", "enabled": true, "profileDataRequired": a.requirement}}} + return content +} + +func TestAdmissionRejectsMalformedAuthenticatedRules(t *testing.T) { + for _, requirement := range []any{map[string]any{"execs": ""}, map[string]any{"unknownSurface": "all"}, map[string]any{"execs": []any{map[string]any{"exct": "/bin/sh"}}}} { + fragment := rulesObj("baseline", "kubescape", "", string(bundle.RuleClassBase), "R0001") + adapter := rawRulesAdapter{profiles.NewRulesAdapter(fragment), requirement} + require.NoError(t, signature.SignObject(adapter, signature.WithPrivateKey(testKey(t)), signature.WithEmbedContent(true))) + policy := &bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{ + bundle.RuleClassBase: {Signers: []string{signerOf(t, fragment)}, AllowedRuleIDs: []string{"*"}}, + }} + // Test direct admission as well as cluster conversion: typed decoding must + // not silently erase unknown authenticated requirement fields. + rules, tally := admitFragments([]*typesv1.Rules{fragment}, policy, true, nil) + require.Empty(t, rules) + require.Equal(t, 1, tally.rejected) + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(newFakeK8sClient(t, fragment), creator, nil) + w.SetTrustPolicy(policy) + require.NoError(t, w.InitialSync(context.Background())) + require.Empty(t, creator.CreateAllRules()) + } +} diff --git a/pkg/rulemanager/ruleswatcher/policy_publication_test.go b/pkg/rulemanager/ruleswatcher/policy_publication_test.go new file mode 100644 index 0000000000..83662ac7a1 --- /dev/null +++ b/pkg/rulemanager/ruleswatcher/policy_publication_test.go @@ -0,0 +1,75 @@ +//go:build experimental_signing + +package ruleswatcher + +import ( + "context" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sync" + "testing" + "time" + + "github.com/kubescape/node-agent/pkg/rulemanager/rulecreator" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature/bundle" +) + +func TestPolicyReloadCannotBeOverwrittenByInFlightAdmission(t *testing.T) { + fragment := sign(t, rulesObj("baseline", "kubescape", "", string(bundle.RuleClassBase), "R0001"), testKey(t)) + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(newFakeK8sClient(t, fragment), creator, nil) + w.SetTrustPolicy(&bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{ + bundle.RuleClassBase: {Signers: []string{signerOf(t, fragment)}, AllowedRuleIDs: []string{"*"}}, + }}) + + w.ResyncNow(context.Background()) + if got := creator.CreateAllRules(); len(got) != 1 { + t.Fatalf("initial policy did not load the signed rule: %v", got) + } + + // Stop the old sync after it has admitted a fragment with the old signer. + admitted, resume := make(chan struct{}), make(chan struct{}) + var release sync.Once + defer release.Do(func() { close(resume) }) + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(fragment) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + _, _, _, err := w.admitAndSyncRules(context.Background(), []unstructured.Unstructured{{Object: raw}}, func(*typesv1.Rules) { + close(admitted) + <-resume + }) + done <- err + }() + select { + case <-admitted: + case <-time.After(5 * time.Second): + t.Fatal("old sync did not reach admission") + } + + // Complete a reload while the old admission is paused. The replacement + // signer rejects the old fragment and removes its rules. + w.SetTrustPolicy(&bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{ + bundle.RuleClassBase: {Signers: []string{"replacement-signer"}, AllowedRuleIDs: []string{"*"}}, + }}) + w.ResyncNow(context.Background()) + if got := creator.CreateAllRules(); len(got) != 0 { + t.Fatalf("reload retained rejected rules: %v", got) + } + + release.Do(func() { close(resume) }) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("old sync did not finish after reload") + } + if got := creator.CreateAllRules(); len(got) != 0 { + t.Fatalf("old admission restored revoked rules after reload: %v", got) + } +} diff --git a/pkg/rulemanager/ruleswatcher/recovery_oracle_test.go b/pkg/rulemanager/ruleswatcher/recovery_oracle_test.go new file mode 100644 index 0000000000..f77cc57cfe --- /dev/null +++ b/pkg/rulemanager/ruleswatcher/recovery_oracle_test.go @@ -0,0 +1,183 @@ +//go:build experimental_signing + +package ruleswatcher + +import ( + "context" + "testing" + + "github.com/kubescape/node-agent/pkg/rulemanager/rulecreator" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature/bundle" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" +) + +func mutableRulesClient(t *testing.T, objs ...*typesv1.Rules) *fakeK8sClient { + t.Helper() + items := make([]runtime.Object, 0, len(objs)) + for _, o := range objs { + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(o) + if err != nil { + t.Fatalf("to unstructured: %v", err) + } + items = append(items, &unstructured.Unstructured{Object: u}) + } + listKinds := map[schema.GroupVersionResource]string{typesv1.RuleGvr: "RuleList"} + // Seed the explicit GVR: the fake client's pluralizer maps Kind Rules to + // "ruleses", whereas the CRD resource is "rules". + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds) + for _, item := range items { + obj := item.(*unstructured.Unstructured) + if err := dyn.Tracker().Create(typesv1.RuleGvr, obj, obj.GetNamespace()); err != nil { + t.Fatalf("seed rules: %v", err) + } + } + return &fakeK8sClient{dyn: dyn} +} + +func applyRules(t *testing.T, c *fakeK8sClient, r *typesv1.Rules) { + t.Helper() + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(r) + if err != nil { + t.Fatalf("to unstructured: %v", err) + } + _, err = c.dyn.Resource(typesv1.RuleGvr).Namespace(r.Namespace). + Create(context.Background(), &unstructured.Unstructured{Object: u}, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("create %s: %v", r.Name, err) + } +} + +func deleteRules(t *testing.T, c *fakeK8sClient, namespace, name string) { + t.Helper() + if err := c.dyn.Resource(typesv1.RuleGvr).Namespace(namespace). + Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil { + t.Fatalf("delete %s: %v", name, err) + } +} + +func trustPolicyFor(signer string) *bundle.TrustPolicy { + return &bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{ + bundle.RuleClassBase: {Signers: []string{signer}, AllowedRuleIDs: []string{"*"}}, + }} +} + +func ruleIDs(rules []typesv1.Rule) map[string]bool { + out := map[string]bool{} + for _, r := range rules { + out[r.ID] = true + } + return out +} + +// entlein/node-agent#48: Test_45 drives the ruleset to zero and back, and +// asserts detection returns without restarting the agent. Every other test in +// this package is single-shot, so nothing covered the sequence itself. This +// walks it and names which half of the chain loses the rules. +func TestRecovery_RulesetReturnsAfterAZeroAdmittedInterval(t *testing.T) { + baseline := rulesObj("baseline", "kubescape", "", "", "R0001", "R0002") + client := mutableRulesClient(t, baseline) + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(client, creator, nil) + + sync := func(stage string) map[string]bool { + t.Helper() + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("%s: sync: %v", stage, err) + } + return ruleIDs(creator.CreateAllRules()) + } + + before := sync("baseline present") + if !before["R0001"] { + t.Fatalf("baseline must load R0001, got %v", before) + } + + deleteRules(t, client, "kubescape", "baseline") + during := sync("zero rules") + if len(during) != 0 { + t.Fatalf("deleting every Rules object must empty the effective ruleset, got %v", during) + } + + applyRules(t, client, rulesObj("baseline", "kubescape", "", "", "R0001", "R0002")) + after := sync("baseline re-applied") + if !after["R0001"] { + t.Fatalf("re-applying the baseline must restore R0001 without a restart, got %v", after) + } + if len(after) != len(before) { + t.Errorf("recovered ruleset must match the original: before=%v after=%v", before, after) + } +} + +// The same sequence run repeatedly: a recovery that works once but degrades +// across cycles is the shape an accumulating cache would produce. +func TestRecovery_SurvivesRepeatedOutageCycles(t *testing.T) { + client := mutableRulesClient(t, rulesObj("baseline", "kubescape", "", "", "R0001", "R0002")) + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(client, creator, nil) + + for cycle := 1; cycle <= 3; cycle++ { + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("cycle %d: sync: %v", cycle, err) + } + if got := ruleIDs(creator.CreateAllRules()); !got["R0001"] { + t.Fatalf("cycle %d: R0001 must be present before the outage, got %v", cycle, got) + } + + deleteRules(t, client, "kubescape", "baseline") + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("cycle %d: sync during outage: %v", cycle, err) + } + if got := ruleIDs(creator.CreateAllRules()); len(got) != 0 { + t.Fatalf("cycle %d: outage must empty the ruleset, got %v", cycle, got) + } + + applyRules(t, client, rulesObj("baseline", "kubescape", "", "", "R0001", "R0002")) + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("cycle %d: sync after recovery: %v", cycle, err) + } + if got := ruleIDs(creator.CreateAllRules()); !got["R0001"] { + t.Fatalf("cycle %d: R0001 must return, got %v", cycle, got) + } + } +} + +// A signing outage is the shape Test_45 actually drives: the objects are still +// there, signing is on, and nothing is admitted. Recovery is re-admission, not +// re-creation, so the ruleset must come back when the policy accepts again. +func TestRecovery_RulesetReturnsAfterASigningOutage(t *testing.T) { + key := testKey(t) + signed := sign(t, rulesObj("baseline", "kubescape", "b1", "base", "R0001"), key) + client := mutableRulesClient(t, signed) + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(client, creator, nil) + + trusted := trustPolicyFor(signerOf(t, signed)) + w.SetTrustPolicy(trusted) + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("sync trusted: %v", err) + } + if got := ruleIDs(creator.CreateAllRules()); !got["R0001"] { + t.Fatalf("a trusted fragment must be admitted, got %v", got) + } + + w.SetTrustPolicy(trustPolicyFor("sha256:0000000000000000000000000000000000000000000000000000000000000000")) + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("sync untrusted: %v", err) + } + if got := ruleIDs(creator.CreateAllRules()); len(got) != 0 { + t.Fatalf("a policy trusting nobody must admit nothing, got %v", got) + } + + w.SetTrustPolicy(trusted) + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("sync re-trusted: %v", err) + } + if got := ruleIDs(creator.CreateAllRules()); !got["R0001"] { + t.Fatalf("restoring the policy must re-admit the fragment without a restart, got %v", got) + } +} diff --git a/pkg/rulemanager/ruleswatcher/signed_rules_test.go b/pkg/rulemanager/ruleswatcher/signed_rules_test.go new file mode 100644 index 0000000000..310fe3e684 --- /dev/null +++ b/pkg/rulemanager/ruleswatcher/signed_rules_test.go @@ -0,0 +1,339 @@ +//go:build experimental_signing + +package ruleswatcher + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "strings" + "testing" + + "github.com/kubescape/k8s-interface/k8sinterface" + "github.com/kubescape/node-agent/pkg/rulemanager/rulecreator" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/node-agent/pkg/signature/profiles" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes" +) + +// fakeK8sClient serves a fixed set of Rules objects from a fake dynamic client. +type fakeK8sClient struct { + dyn dynamic.Interface +} + +func (f *fakeK8sClient) GetWorkload(string, string, string) (k8sinterface.IWorkload, error) { + return nil, nil +} + +func (f *fakeK8sClient) CalculateWorkloadParentRecursive(k8sinterface.IWorkload) (string, string, error) { + return "", "", nil +} + +func (f *fakeK8sClient) GetKubernetesClient() kubernetes.Interface { return nil } +func (f *fakeK8sClient) GetDynamicClient() dynamic.Interface { return f.dyn } + +func newFakeK8sClient(t *testing.T, objs ...*typesv1.Rules) *fakeK8sClient { + t.Helper() + items := make([]runtime.Object, 0, len(objs)) + for _, o := range objs { + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(o) + if err != nil { + t.Fatalf("to unstructured: %v", err) + } + items = append(items, &unstructured.Unstructured{Object: u}) + } + listKinds := map[schema.GroupVersionResource]string{typesv1.RuleGvr: "RuleList"} + // Seed the explicit GVR: the fake client's pluralizer maps Kind Rules to + // "ruleses", whereas the CRD resource is "rules". + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds) + for _, item := range items { + obj := item.(*unstructured.Unstructured) + if err := dyn.Tracker().Create(typesv1.RuleGvr, obj, obj.GetNamespace()); err != nil { + t.Fatalf("seed rules: %v", err) + } + } + return &fakeK8sClient{dyn: dyn} +} + +func testKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("gen key: %v", err) + } + return k +} + +// rulesObj builds a Rules fragment labelled exactly like a ContainerProfile +// fragment: signature.kubescape.io/bundle + signature.kubescape.io/fragment-class. +func rulesObj(name, namespace, bundleName, class string, ruleIDs ...string) *typesv1.Rules { + rules := make([]typesv1.Rule, 0, len(ruleIDs)) + for _, id := range ruleIDs { + rules = append(rules, typesv1.Rule{Enabled: true, ID: id, Name: "rule-" + id}) + } + labels := map[string]string{} + if class != "" { + labels[bundle.LabelFragmentClass] = class + } + if bundleName != "" { + labels[bundle.LabelBundle] = bundleName + } + return &typesv1.Rules{ + TypeMeta: metav1.TypeMeta{APIVersion: "kubescape.io/v1", Kind: "Rules"}, + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Labels: labels}, + Spec: typesv1.RulesSpec{Rules: rules}, + } +} + +func sign(t *testing.T, r *typesv1.Rules, key *ecdsa.PrivateKey) *typesv1.Rules { + t.Helper() + if err := signature.SignObject(profiles.NewRulesAdapter(r), signature.WithPrivateKey(key)); err != nil { + t.Fatalf("sign %s: %v", r.Name, err) + } + return r +} + +func signerOf(t *testing.T, r *typesv1.Rules) string { + t.Helper() + id, err := bundle.RulesSignerID(r) + if err != nil { + t.Fatalf("signer id: %v", err) + } + return id +} + +// Rule signing disabled is the pre-existing behaviour: every enabled rule from +// every Rules object in every namespace is loaded, signed or not, and carries +// cluster-wide provenance so downstream bundle scoping is a no-op. +func TestSyncAllRulesFromCluster_SigningDisabled(t *testing.T) { + unsigned := rulesObj("baseline", "kubescape", "", "", "R0001", "R0002") + rogue := rulesObj("rogue", "attacker", "", "", "R0003") + + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(newFakeK8sClient(t, unsigned, rogue), creator, nil) + + if w.ruleSigningEnabled() { + t.Fatal("rule signing must be disabled without a trust policy") + } + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("sync: %v", err) + } + + all := creator.CreateAllRules() + if len(all) != 3 { + t.Fatalf("got %d rules, want 3 (unchanged pre-signing behaviour)", len(all)) + } + for _, r := range all { + if !r.ClusterWide || r.Bundle != "" { + t.Errorf("rule %s: want cluster-wide/unbundled provenance, got clusterWide=%v bundle=%q", r.ID, r.ClusterWide, r.Bundle) + } + } +} + +// A policy that only configures ContainerProfile fragment classes must not turn +// rule signing on. +func TestSetTrustPolicy_ProfileOnlyPolicyKeepsSigningDisabled(t *testing.T) { + w := NewRulesWatcher(newFakeK8sClient(t), rulecreator.NewRuleCreator(), nil) + w.SetTrustPolicy(&bundle.TrustPolicy{Classes: map[bundle.FragmentClass]bundle.ClassPolicy{bundle.ClassBase: {}}}) + if w.ruleSigningEnabled() { + t.Fatal("a policy without ruleClasses must leave rule signing disabled") + } + w.SetTrustPolicy(nil) + if w.ruleSigningEnabled() { + t.Fatal("nil policy must leave rule signing disabled") + } +} + +// With rule signing enabled the watcher fails closed: an unsigned or +// untrusted Rules object is dropped whole, so an attacker who can create a +// Rules object in any namespace cannot override or disable a base rule. +func TestSyncAllRulesFromCluster_SigningEnabledFailsClosed(t *testing.T) { + vendor, operator, attacker := testKey(t), testKey(t), testKey(t) + + baseline := sign(t, rulesObj("baseline", "kubescape", "", string(bundle.RuleClassBase), "R0001", "R0002"), vendor) + overlay := sign(t, rulesObj("redis-tuning", "redis", "redis", string(bundle.RuleClassOverlay), "R0001"), operator) + // Same shape as the real attack: a Rules object in an arbitrary namespace + // that redefines R0001, signed with a key nobody trusts. + rogueSigned := sign(t, rulesObj("rogue", "attacker", "", string(bundle.RuleClassBase), "R0001"), attacker) + rogueUnsigned := rulesObj("rogue-unsigned", "attacker", "", string(bundle.RuleClassBase), "R0002") + + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(newFakeK8sClient(t, baseline, overlay, rogueSigned, rogueUnsigned), creator, nil) + w.SetTrustPolicy(&bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{ + bundle.RuleClassBase: {Signers: []string{signerOf(t, baseline)}, AllowedRuleIDs: []string{"*"}}, + bundle.RuleClassOverlay: {Signers: []string{signerOf(t, overlay)}, AllowedRuleIDs: []string{"R0001"}}, + }}) + if !w.ruleSigningEnabled() { + t.Fatal("rule signing must be enabled with a ruleClasses policy") + } + + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("sync: %v", err) + } + + all := creator.CreateAllRules() + // 2 base rules + 1 bundle-scoped R0001; both rogue objects dropped. + if len(all) != 3 { + t.Fatalf("got %d rules, want 3: %+v", len(all), all) + } + var baseR1, overlayR1, r2 int + for _, r := range all { + if r.ID == "R0002" { + r2++ + } + if r.ID != "R0001" { + continue + } + if r.ClusterWide { + baseR1++ + if r.Bundle != "" { + t.Errorf("base R0001 bundle = %q, want empty", r.Bundle) + } + } else { + overlayR1++ + if r.Bundle != "redis" { + t.Errorf("overlay R0001 bundle = %q, want redis", r.Bundle) + } + } + } + // The composite (bundle, ID) key in SyncRules is what lets both survive. + if baseR1 != 1 || overlayR1 != 1 { + t.Fatalf("want one base and one overlay R0001, got base=%d overlay=%d", baseR1, overlayR1) + } + // The rogue objects redefine R0001 (signed, untrusted) and R0002 (unsigned). + // Exactly one of each surviving proves neither contributed anything: a rogue + // R0001 would be a second base variant, a rogue R0002 a second R0002. + if r2 != 1 { + t.Fatalf("want exactly one R0002, got %d — a rogue fragment leaked", r2) + } +} + +// The bundle a rule is scoped by is the SIGNED one, and metadata.namespace has +// no say: the same signed overlay installed into two different namespaces yields +// the same bundle provenance. +func TestSyncAllRulesFromCluster_BundleProvenanceIsNamespaceIndependent(t *testing.T) { + operator := testKey(t) + overlay := sign(t, rulesObj("redis-tuning", "redis", "redis", string(bundle.RuleClassOverlay), "R0001"), operator) + + for _, ns := range []string{"redis", "team-a", "some-random-ns"} { + // Same signed bytes, only the install namespace differs. + installed := *overlay + installed.ObjectMeta = *overlay.ObjectMeta.DeepCopy() + installed.Namespace = ns + + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(newFakeK8sClient(t, &installed), creator, nil) + w.SetTrustPolicy(&bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{ + bundle.RuleClassOverlay: {Signers: []string{signerOf(t, overlay)}, AllowedRuleIDs: []string{"*"}}, + }}) + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("sync in %s: %v", ns, err) + } + + all := creator.CreateAllRules() + if len(all) != 1 { + t.Fatalf("installed in %s: got %d rules, want 1", ns, len(all)) + } + if all[0].Bundle != "redis" || all[0].ClusterWide { + t.Fatalf("installed in %s: bundle=%q clusterWide=%v, want redis/false", ns, all[0].Bundle, all[0].ClusterWide) + } + } +} + +// An overlay fragment carrying no bundle label cannot be scoped, so the whole +// fragment is dropped rather than applied to everything. +func TestSyncAllRulesFromCluster_OverlayWithoutBundleDropped(t *testing.T) { + operator := testKey(t) + bad := sign(t, rulesObj("bundleless", "redis", "", string(bundle.RuleClassOverlay), "R0001"), operator) + + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(newFakeK8sClient(t, bad), creator, nil) + w.SetTrustPolicy(&bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{ + bundle.RuleClassOverlay: {Signers: []string{signerOf(t, bad)}, AllowedRuleIDs: []string{"*"}}, + }}) + + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("sync: %v", err) + } + if all := creator.CreateAllRules(); len(all) != 0 { + t.Fatalf("got %d rules, want the whole fragment dropped: %+v", len(all), all) + } +} + +// A policy reload must change admission WITHOUT waiting for a Rules watch +// event: SetTrustPolicy + ResyncNow flips a previously loaded unsigned object +// to dropped within the same call. +func TestResyncNow_AppliesReloadedPolicyWithoutWatchEvent(t *testing.T) { + vendor := testKey(t) + signed := sign(t, rulesObj("baseline", "kubescape", "", string(bundle.RuleClassBase), "R0001"), vendor) + unsigned := rulesObj("legacy", "other", "", "", "R0002") + + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(newFakeK8sClient(t, signed, unsigned), creator, nil) + + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("sync: %v", err) + } + if got := len(creator.CreateAllRules()); got != 2 { + t.Fatalf("signing off: got %d rules, want 2", got) + } + + w.SetTrustPolicy(&bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{ + bundle.RuleClassBase: {Signers: []string{signerOf(t, signed)}, AllowedRuleIDs: []string{"*"}}, + }}) + w.ResyncNow(context.Background()) + + all := creator.CreateAllRules() + if len(all) != 1 || all[0].ID != "R0001" { + t.Fatalf("after strict reload + resync: got %+v, want only the signed R0001", all) + } + + w.SetTrustPolicy(nil) + w.ResyncNow(context.Background()) + if got := len(creator.CreateAllRules()); got != 2 { + t.Fatalf("after signing-off reload + resync: got %d rules, want 2", got) + } +} + +// An admitted embedded-content Rules object whose stored rules were edited +// after signing loads the SIGNED rules and warns once about the divergence. +func TestSyncRules_StoredDivergenceWarnsOnceAndLoadsSignedRules(t *testing.T) { + vendor := testKey(t) + r := rulesObj("baseline", "kubescape", "", string(bundle.RuleClassBase), "R0001") + if err := signature.SignObject(profiles.NewRulesAdapter(r), signature.WithPrivateKey(vendor), signature.WithEmbedContent(true)); err != nil { + t.Fatalf("sign: %v", err) + } + r.Spec.Rules = append(r.Spec.Rules, typesv1.Rule{Enabled: true, ID: "R9999", Name: "rogue"}) + + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(newFakeK8sClient(t, r), creator, nil) + w.SetTrustPolicy(&bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{ + bundle.RuleClassBase: {Signers: []string{signerOf(t, r)}, AllowedRuleIDs: []string{"*"}}, + }}) + + out := captureLoggerOutput(t, func() { + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("sync: %v", err) + } + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("resync: %v", err) + } + }) + if got := strings.Count(out, "stored spec diverges"); got != 1 { + t.Fatalf("want exactly one divergence warning across two syncs, got %d\n%s", got, out) + } + + all := creator.CreateAllRules() + if len(all) != 1 || all[0].ID != "R0001" { + t.Fatalf("only the SIGNED rules may load, got %+v", all) + } +} diff --git a/pkg/rulemanager/ruleswatcher/sync_differential_test.go b/pkg/rulemanager/ruleswatcher/sync_differential_test.go new file mode 100644 index 0000000000..51057f30fd --- /dev/null +++ b/pkg/rulemanager/ruleswatcher/sync_differential_test.go @@ -0,0 +1,330 @@ +//go:build experimental_signing + +package ruleswatcher + +import ( + "context" + "crypto/ecdsa" + "fmt" + "io" + "os" + "sort" + "strings" + "testing" + + logger "github.com/kubescape/go-logger" + "github.com/kubescape/node-agent/pkg/rulemanager/rulecreator" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/node-agent/pkg/signature/profiles" +) + +type ruleProvenance struct { + ID string + Bundle string + ClusterWide bool +} + +// archetype is one Rules-object shape with its own oracle: what the object +// contributes to the effective ruleset with signing OFF and with signing ON. +type archetype struct { + name string + build func(t *testing.T, baseKey, overlayKey, attackerKey *ecdsa.PrivateKey) *typesv1.Rules + whenOff []ruleProvenance + whenOn []ruleProvenance + admits bool +} + +func rulesObjMixed(name, namespace, bundleName, class string) *typesv1.Rules { + r := rulesObj(name, namespace, bundleName, class, "R0001", "R0002") + r.Spec.Rules = append(r.Spec.Rules, typesv1.Rule{Enabled: false, ID: "R0003", Name: "rule-R0003"}) + return r +} + +func syncArchetypes() []archetype { + cw := func(ids ...string) []ruleProvenance { + out := make([]ruleProvenance, 0, len(ids)) + for _, id := range ids { + out = append(out, ruleProvenance{ID: id, ClusterWide: true}) + } + return out + } + return []archetype{ + { + name: "unsigned-baseline", + build: func(t *testing.T, _, _, _ *ecdsa.PrivateKey) *typesv1.Rules { + return rulesObjMixed("unsigned-baseline", "kubescape", "", "") + }, + whenOff: cw("R0001", "R0002"), whenOn: nil, admits: false, + }, + { + name: "signed-baseline", + build: func(t *testing.T, baseKey, _, _ *ecdsa.PrivateKey) *typesv1.Rules { + return sign(t, rulesObjMixed("signed-baseline", "kubescape", "", string(bundle.RuleClassBase)), baseKey) + }, + whenOff: cw("R0001", "R0002"), whenOn: cw("R0001", "R0002"), admits: true, + }, + { + name: "wrong-signer-baseline", + build: func(t *testing.T, _, _, attackerKey *ecdsa.PrivateKey) *typesv1.Rules { + return sign(t, rulesObjMixed("wrong-signer", "attacker", "", string(bundle.RuleClassBase)), attackerKey) + }, + whenOff: cw("R0001", "R0002"), whenOn: nil, admits: false, + }, + { + name: "corrupt-signature", + build: func(t *testing.T, baseKey, _, _ *ecdsa.PrivateKey) *typesv1.Rules { + r := sign(t, rulesObjMixed("corrupt-signature", "kubescape", "", string(bundle.RuleClassBase)), baseKey) + r.Annotations[signature.AnnotationSignature] = "AAAA" + r.Annotations[signature.AnnotationSignature][4:] + return r + }, + whenOff: cw("R0001", "R0002"), whenOn: nil, admits: false, + }, + { + // Non-embedded signature + stored-spec tamper: the signature binds + // the stored content, so the whole object is rejected. + name: "stale-spec-injection", + build: func(t *testing.T, baseKey, _, _ *ecdsa.PrivateKey) *typesv1.Rules { + r := sign(t, rulesObjMixed("stale-spec", "kubescape", "", string(bundle.RuleClassBase)), baseKey) + r.Spec.Rules = append(r.Spec.Rules, typesv1.Rule{Enabled: true, ID: "R9999", Name: "rule-R9999"}) + return r + }, + whenOff: cw("R0001", "R0002", "R9999"), whenOn: nil, admits: false, + }, + { + // Embedded-content signature + stored-spec tamper: only the signed + // embedded rules load; the injected stored rule is inert. + name: "stale-spec-embedded", + build: func(t *testing.T, baseKey, _, _ *ecdsa.PrivateKey) *typesv1.Rules { + r := signEmbedded(t, rulesObjMixed("stale-spec-embedded", "kubescape", "", string(bundle.RuleClassBase)), baseKey) + r.Spec.Rules = append(r.Spec.Rules, typesv1.Rule{Enabled: true, ID: "R9999", Name: "rule-R9999"}) + return r + }, + whenOff: cw("R0001", "R0002", "R9999"), whenOn: cw("R0001", "R0002"), admits: true, + }, + { + name: "overlay-bundled", + build: func(t *testing.T, _, overlayKey, _ *ecdsa.PrivateKey) *typesv1.Rules { + return sign(t, rulesObj("overlay-bundled", "redis", "redis", string(bundle.RuleClassOverlay), "R0001"), overlayKey) + }, + whenOff: []ruleProvenance{{ID: "R0001", ClusterWide: true}}, + whenOn: []ruleProvenance{{ID: "R0001", Bundle: "redis", ClusterWide: false}}, + admits: true, + }, + { + name: "overlay-no-bundle", + build: func(t *testing.T, _, overlayKey, _ *ecdsa.PrivateKey) *typesv1.Rules { + return sign(t, rulesObj("overlay-no-bundle", "redis", "", string(bundle.RuleClassOverlay), "R0001"), overlayKey) + }, + whenOff: []ruleProvenance{{ID: "R0001", ClusterWide: true}}, whenOn: nil, admits: false, + }, + { + // A key trusted for base signing an overlay fragment: cross-class + // use of a legitimate key is refused like an unknown key. + name: "cross-class-trusted-key", + build: func(t *testing.T, baseKey, _, _ *ecdsa.PrivateKey) *typesv1.Rules { + return sign(t, rulesObj("cross-class", "redis", "redis", string(bundle.RuleClassOverlay), "R0001"), baseKey) + }, + whenOff: []ruleProvenance{{ID: "R0001", ClusterWide: true}}, whenOn: nil, admits: false, + }, + { + name: "overlay-id-not-allowed", + build: func(t *testing.T, _, overlayKey, _ *ecdsa.PrivateKey) *typesv1.Rules { + return sign(t, rulesObj("overlay-bad-id", "redis", "redis", string(bundle.RuleClassOverlay), "R0040"), overlayKey) + }, + whenOff: []ruleProvenance{{ID: "R0040", ClusterWide: true}}, whenOn: nil, admits: false, + }, + { + // Admits but contributes nothing: distinguishes "zero enabled rules" + // from the zero-ADMITTED detection outage. + name: "signed-all-disabled", + build: func(t *testing.T, baseKey, _, _ *ecdsa.PrivateKey) *typesv1.Rules { + r := rulesObj("signed-all-disabled", "kubescape", "", string(bundle.RuleClassBase)) + r.Spec.Rules = []typesv1.Rule{{Enabled: false, ID: "R0005", Name: "rule-R0005"}} + return sign(t, r, baseKey) + }, + whenOff: nil, whenOn: nil, admits: true, + }, + } +} + +func signEmbedded(t *testing.T, r *typesv1.Rules, key *ecdsa.PrivateKey) *typesv1.Rules { + t.Helper() + if err := signature.SignObject(profiles.NewRulesAdapter(r), signature.WithPrivateKey(key), signature.WithEmbedContent(true)); err != nil { + t.Fatalf("sign embedded %s: %v", r.Name, err) + } + return r +} + +func provKey(p ruleProvenance) string { + return fmt.Sprintf("%s|%s|%v", p.ID, p.Bundle, p.ClusterWide) +} + +// sortedKeys dedups by (bundle, ID) — SyncRules keys rules that way, so two +// objects contributing the same cluster-wide rule collapse to one. +func sortedKeys(ps []ruleProvenance) []string { + seen := map[string]bool{} + out := make([]string, 0, len(ps)) + for _, p := range ps { + k := provKey(p) + if seen[k] { + continue + } + seen[k] = true + out = append(out, k) + } + sort.Strings(out) + return out +} + +// TestSyncDifferential_EffectiveRulesetMatrix runs scenario sets of Rules +// objects through the real watcher sync, signing OFF and ON, and compares the +// SyncRules payload against an independent per-archetype oracle. The oracle +// also predicts the detection-outage backstop condition (zero fragments +// admitted while Rules objects exist). +func TestSyncDifferential_EffectiveRulesetMatrix(t *testing.T) { + arch := syncArchetypes() + byName := map[string]archetype{} + for _, a := range arch { + byName[a.name] = a + } + scenarios := [][]string{ + {}, + {"unsigned-baseline"}, + {"signed-baseline"}, + {"unsigned-baseline", "signed-baseline"}, + {"wrong-signer-baseline"}, + {"corrupt-signature"}, + {"stale-spec-injection"}, + {"stale-spec-embedded"}, + {"signed-baseline", "overlay-bundled"}, + {"overlay-no-bundle"}, + {"cross-class-trusted-key"}, + {"overlay-id-not-allowed"}, + {"signed-all-disabled"}, + {"unsigned-baseline", "wrong-signer-baseline", "corrupt-signature", "overlay-no-bundle", "overlay-id-not-allowed"}, + {"unsigned-baseline", "signed-baseline", "wrong-signer-baseline", "corrupt-signature", "stale-spec-injection", "stale-spec-embedded", "overlay-bundled", "overlay-no-bundle", "overlay-id-not-allowed", "signed-all-disabled"}, + } + + checks := 0 + for _, names := range scenarios { + for _, signingOn := range []bool{false, true} { + baseKey, overlayKey, attackerKey := testKey(t), testKey(t), testKey(t) + + objs := make([]*typesv1.Rules, 0, len(names)) + var want []ruleProvenance + admitted, objects := 0, len(names) + for _, n := range names { + a := byName[n] + objs = append(objs, a.build(t, baseKey, overlayKey, attackerKey)) + if signingOn { + want = append(want, a.whenOn...) + if a.admits { + admitted++ + } + } else { + want = append(want, a.whenOff...) + } + } + + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(newFakeK8sClient(t, objs...), creator, nil) + if signingOn { + probe := sign(t, rulesObj("probe-base", "x", "", string(bundle.RuleClassBase), "R0001"), baseKey) + probeOvl := sign(t, rulesObj("probe-ovl", "x", "b", string(bundle.RuleClassOverlay), "R0001"), overlayKey) + w.SetTrustPolicy(&bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{ + bundle.RuleClassBase: {Signers: []string{signerOf(t, probe)}, AllowedRuleIDs: []string{"*"}}, + bundle.RuleClassOverlay: {Signers: []string{signerOf(t, probeOvl)}, AllowedRuleIDs: []string{"R0001", "R0002"}}, + }}) + } + + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("scenario %v signingOn=%v: sync: %v", names, signingOn, err) + } + + got := make([]ruleProvenance, 0) + for _, r := range creator.CreateAllRules() { + got = append(got, ruleProvenance{ID: r.ID, Bundle: r.Bundle, ClusterWide: r.ClusterWide}) + } + g, wnt := sortedKeys(got), sortedKeys(want) + if strings.Join(g, ",") != strings.Join(wnt, ",") { + t.Fatalf("scenario %v signingOn=%v: effective ruleset mismatch\n got: %v\nwant: %v", names, signingOn, g, wnt) + } + + if signingOn { + wantOutage := admitted == 0 && objects > 0 + zeroEnabled := len(got) == 0 + if wantOutage && !zeroEnabled { + t.Fatalf("scenario %v: oracle says outage but rules loaded: %v", names, g) + } + _ = wantOutage + } + checks++ + } + } + t.Logf("sync differential: %d scenario cells matched the independent oracle", checks) +} + +// The detection-outage backstop must fire exactly when signing is on, Rules +// objects exist, and none admitted — and must NOT fire when a fragment admits +// with only disabled rules (zero enabled != zero admitted). +func TestSyncBackstop_FiresOnZeroAdmittedOnly(t *testing.T) { + cases := []struct { + name string + objects []string + wantOutage bool + }{ + {"all-rejected", []string{"unsigned-baseline", "wrong-signer-baseline"}, true}, + {"admitted-but-all-disabled", []string{"signed-all-disabled"}, false}, + {"no-objects", nil, false}, + {"one-admits", []string{"unsigned-baseline", "signed-baseline"}, false}, + } + arch := syncArchetypes() + byName := map[string]archetype{} + for _, a := range arch { + byName[a.name] = a + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + baseKey, overlayKey, attackerKey := testKey(t), testKey(t), testKey(t) + objs := make([]*typesv1.Rules, 0, len(tc.objects)) + for _, n := range tc.objects { + objs = append(objs, byName[n].build(t, baseKey, overlayKey, attackerKey)) + } + creator := rulecreator.NewRuleCreator() + w := NewRulesWatcher(newFakeK8sClient(t, objs...), creator, nil) + probe := sign(t, rulesObj("probe-base", "x", "", string(bundle.RuleClassBase), "R0001"), baseKey) + w.SetTrustPolicy(&bundle.TrustPolicy{RuleClasses: map[bundle.RuleClass]bundle.RuleClassPolicy{ + bundle.RuleClassBase: {Signers: []string{signerOf(t, probe)}, AllowedRuleIDs: []string{"*"}}, + }}) + + out := captureLoggerOutput(t, func() { + if err := w.syncAllRulesFromCluster(context.Background()); err != nil { + t.Fatalf("sync: %v", err) + } + }) + fired := strings.Contains(out, "detection is effectively OFF") + if fired != tc.wantOutage { + t.Fatalf("backstop fired=%v want=%v\nlogs:\n%s", fired, tc.wantOutage, out) + } + }) + } +} + +func captureLoggerOutput(t *testing.T, fn func()) string { + t.Helper() + origStderr, origStdout := os.Stderr, os.Stdout + r, wPipe, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stderr, os.Stdout = wPipe, wPipe + logger.InitLogger("pretty") + fn() + wPipe.Close() + os.Stderr, os.Stdout = origStderr, origStdout + logger.InitLogger("pretty") + b, _ := io.ReadAll(r) + return string(b) +} diff --git a/pkg/rulemanager/ruleswatcher/watcher.go b/pkg/rulemanager/ruleswatcher/watcher.go index 47c186c213..7da3172b60 100644 --- a/pkg/rulemanager/ruleswatcher/watcher.go +++ b/pkg/rulemanager/ruleswatcher/watcher.go @@ -1,3 +1,5 @@ +//go:build !experimental_signing + package ruleswatcher import ( diff --git a/pkg/rulemanager/ruleswatcher/watcher_signing.go b/pkg/rulemanager/ruleswatcher/watcher_signing.go new file mode 100644 index 0000000000..39dbe3626d --- /dev/null +++ b/pkg/rulemanager/ruleswatcher/watcher_signing.go @@ -0,0 +1,329 @@ +//go:build experimental_signing + +package ruleswatcher + +import ( + "context" + "fmt" + "os" + "sync" + + "github.com/Masterminds/semver/v3" + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + "github.com/kubescape/node-agent/pkg/k8sclient" + "github.com/kubescape/node-agent/pkg/rulemanager/rulecreator" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/bundle" + "github.com/kubescape/node-agent/pkg/watcher" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + k8sruntime "k8s.io/apimachinery/pkg/runtime" +) + +var _ RulesWatcher = (*RulesWatcherImpl)(nil) + +type RulesWatcherImpl struct { + ruleCreator rulecreator.RuleCreator + k8sClient k8sclient.K8sClientInterface + callback RulesWatcherCallback + watchResources []watcher.WatchResource + specDivergence sync.Map + + // policyMutex coordinates policy replacement with publication of admitted rules. + // Admission and cluster requests run outside the lock. + policyMutex sync.RWMutex + trustPolicy *bundle.TrustPolicy + policyGeneration uint64 +} + +func NewRulesWatcher(k8sClient k8sclient.K8sClientInterface, ruleCreator rulecreator.RuleCreator, callback RulesWatcherCallback) *RulesWatcherImpl { + return &RulesWatcherImpl{ + ruleCreator: ruleCreator, + k8sClient: k8sClient, + callback: callback, + watchResources: []watcher.WatchResource{ + watcher.NewWatchResource(typesv1.RuleGvr, metav1.ListOptions{}), + }, + } +} + +func (w *RulesWatcherImpl) WatchResources() []watcher.WatchResource { + return w.watchResources +} + +// SetTrustPolicy enables signed, bundle-scoped rule fragments. Passing a +// policy without ruleClasses (or nil) leaves rule signing disabled. +func (w *RulesWatcherImpl) SetTrustPolicy(p *bundle.TrustPolicy) { + w.policyMutex.Lock() + defer w.policyMutex.Unlock() + w.trustPolicy = p + w.policyGeneration++ +} + +// trustPolicySnapshot returns the policy, whether rule signing is enabled, +// and the generation used to guard publication against concurrent reloads. +func (w *RulesWatcherImpl) trustPolicySnapshot() (*bundle.TrustPolicy, bool, uint64) { + w.policyMutex.RLock() + defer w.policyMutex.RUnlock() + if w.trustPolicy == nil || !w.trustPolicy.RuleSigningEnabled() { + return nil, false, w.policyGeneration + } + return w.trustPolicy, true, w.policyGeneration +} + +// ruleSigningEnabled reports whether a trust policy with rule classes is set. +func (w *RulesWatcherImpl) ruleSigningEnabled() bool { + _, ok, _ := w.trustPolicySnapshot() + return ok +} + +// ResyncNow re-evaluates every Rules object under the policy currently set. +// Called after a policy reload so admission changes apply within one reload +// interval instead of waiting for the next Rules watch event. +func (w *RulesWatcherImpl) ResyncNow(ctx context.Context) { + w.syncAllRulesAndNotify(ctx) +} + +// reportRulesDivergence warns when an admitted Rules object's STORED rules +// list no longer matches its embedded signed one: kubectl shows rules that are +// not enforced (or hides ones that are). Observability only; counts only, +// never rule content; one warning per distinct stored content. +func (w *RulesWatcherImpl) reportRulesDivergence(rules *typesv1.Rules) { + diverged, storedCount, signedCount, storedHash, ok := bundle.RulesStoredDivergence(rules) + if !ok { + return + } + key := rules.Namespace + "/" + rules.Name + if !diverged { + w.specDivergence.Delete(key) + return + } + if prev, loaded := w.specDivergence.Load(key); loaded && prev.(string) == storedHash { + return + } + w.specDivergence.Store(key, storedHash) + logger.L().Warning("signed Rules stored spec diverges from the signed content: the stored rules are display-only and are NOT enforced; enforcement uses the signed content", + helpers.String("name", rules.Name), + helpers.String("namespace", rules.Namespace), + helpers.Int("storedRules", storedCount), + helpers.Int("signedRules", signedCount), + helpers.String("hint", "the object was edited after signing; re-apply the signed artifact to realign what kubectl shows with what is enforced")) +} + +func (w *RulesWatcherImpl) AddHandler(ctx context.Context, obj runtime.Object) { + logger.L().Debug("RulesWatcher - rule added, syncing all rules") + w.syncAllRulesAndNotify(ctx) +} + +func (w *RulesWatcherImpl) ModifyHandler(ctx context.Context, obj runtime.Object) { + logger.L().Debug("RulesWatcher - rule modified, syncing all rules") + w.syncAllRulesAndNotify(ctx) +} + +func (w *RulesWatcherImpl) DeleteHandler(ctx context.Context, obj runtime.Object) { + logger.L().Debug("RulesWatcher - rule deleted, syncing all rules") + w.syncAllRulesAndNotify(ctx) +} + +func (w *RulesWatcherImpl) syncAllRulesAndNotify(ctx context.Context) { + if err := w.syncAllRulesFromCluster(ctx); err != nil { + logger.L().Warning("RulesWatcher - failed to sync all rules from cluster", helpers.Error(err)) + return + } + + if w.callback != nil { + w.callback() + logger.L().Debug("RulesWatcher - notified callback with updated rules") + } +} + +// syncAllRulesFromCluster fetches all rules from the cluster and syncs them with the rule creator. +// Rules are filtered by: +// 1. Signature admissibility - when a trust policy with rule classes is configured, every +// Rules object must be signed by a class-trusted signer and may only carry rule IDs its +// class allows. A Rules object that fails admission is dropped WHOLE (fail closed), so an +// attacker who can create a Rules object in some namespace cannot disable detections. +// 2. Enabled status - only enabled rules are considered +// 3. Agent version compatibility - rules with AgentVersionRequirement are checked against AGENT_VERSION env var using semver +func (w *RulesWatcherImpl) syncAllRulesFromCluster(ctx context.Context) error { + unstructuredList, err := w.k8sClient.GetDynamicClient().Resource(typesv1.RuleGvr).List(ctx, metav1.ListOptions{}) + if err != nil { + return err + } + + enabledRules, tally, signingEnabled, err := w.admitAndSyncRules(ctx, unstructuredList.Items, w.reportRulesDivergence) + if err != nil { + return err + } + + logger.L().Info("RulesWatcher - synced rules from cluster", + helpers.Int("enabledRules", len(enabledRules)), + helpers.Int("totalRules", len(unstructuredList.Items)), + helpers.Int("skippedByVersion", tally.skippedVersion)) + if signingEnabled { + bundle.RecordRuleAdmission(tally.admitted, tally.rejected, len(enabledRules)) + logger.L().Info("RulesWatcher - signed rule fragments", + helpers.Int("admitted", tally.admitted), + helpers.Int("rejected", tally.rejected)) + // Backstop against a silent detection outage: signing is on and rules + // objects exist, but none were admitted, so the effective ruleset is + // empty and nothing will alert. Enforce fails rules closed to "no rules", + // which is safe from tampered detections but is NOT silent — scream every + // sync (persistent signal) rather than crash-loop (which would keep the + // operator from signing the baseline to fix it). + if tally.admitted == 0 && len(unstructuredList.Items) > 0 { + logger.L().Error("RulesWatcher - signing enabled but NO rule fragment admitted while rules objects exist: detection is effectively OFF; sign the baseline ruleset as a base-class fragment or correct the trust policy", + helpers.Int("rulesObjects", len(unstructuredList.Items)), + helpers.Int("rejected", tally.rejected)) + } + } + return nil +} + +// admitAndSyncRules retries admission if the policy changes while signatures are +// being checked. Holding policyMutex through SyncRules makes the generation +// check and publication atomic with respect to SetTrustPolicy. Neither network +// requests nor the watcher callback run under this lock. +func (w *RulesWatcherImpl) admitAndSyncRules(ctx context.Context, items []unstructured.Unstructured, onAdmitted func(*typesv1.Rules)) ([]typesv1.Rule, admissionTally, bool, error) { + for { + if err := ctx.Err(); err != nil { + return nil, admissionTally{}, false, err + } + policy, signingEnabled, generation := w.trustPolicySnapshot() + fragments := make([]*typesv1.Rules, 0, len(items)) + for i := range items { + rules, err := rulesForAdmission(&items[i], signingEnabled) + if err != nil { + logger.L().Warning("RulesWatcher - failed to convert rule during sync", helpers.Error(err)) + continue + } + fragments = append(fragments, rules) + } + enabledRules, tally := admitFragments(fragments, policy, signingEnabled, onAdmitted) + w.policyMutex.RLock() + if generation != w.policyGeneration { + w.policyMutex.RUnlock() + continue + } + w.ruleCreator.SyncRules(enabledRules) + w.policyMutex.RUnlock() + return enabledRules, tally, signingEnabled, nil + } +} + +func (w *RulesWatcherImpl) InitialSync(ctx context.Context) error { + return w.syncAllRulesFromCluster(ctx) +} + +// Embedded signatures authenticate their own spec. Decode only the carrier's +// identity before admission so malformed display-only spec cannot remove rules. +func rulesForAdmission(obj *unstructured.Unstructured, signingEnabled bool) (*typesv1.Rules, error) { + if _, embedded := obj.GetAnnotations()[signature.AnnotationContent]; !signingEnabled || !embedded { + return unstructuredToRules(obj) + } + carrier := obj.DeepCopy() + delete(carrier.Object, "spec") + rules := &typesv1.Rules{} + if err := k8sruntime.DefaultUnstructuredConverter.FromUnstructured(carrier.Object, rules); err != nil { + return nil, err + } + // Preserve a decodable stored spec only for divergence diagnostics. + stored := &typesv1.Rules{} + if err := k8sruntime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, stored); err == nil { + rules.Spec = stored.Spec + } + return rules, nil +} + +func unstructuredToRules(obj *unstructured.Unstructured) (*typesv1.Rules, error) { + if err := validateRawProfileDataInUnstructured(obj); err != nil { + return nil, err + } + + rule := &typesv1.Rules{} + if err := k8sruntime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &rule); err != nil { + return nil, err + } + + for _, r := range rule.Spec.Rules { + if r.ProfileDataRequired != nil { + if err := r.ProfileDataRequired.Validate(); err != nil { + return nil, fmt.Errorf("rule %q invalid profileDataRequired: %w", r.ID, err) + } + } + } + + return rule, nil +} + +func validateRawProfileDataInUnstructured(obj *unstructured.Unstructured) error { + if obj == nil || obj.Object == nil { + return nil + } + spec, ok := obj.Object["spec"].(map[string]any) + if !ok { + return nil + } + rulesRaw, ok := spec["rules"].([]any) + if !ok { + return nil + } + for i, r := range rulesRaw { + ruleMap, ok := r.(map[string]any) + if !ok { + continue + } + if pdr, exists := ruleMap["profileDataRequired"]; exists && pdr != nil { + if err := typesv1.ValidateRawProfileDataRequired(pdr); err != nil { + ruleID, _ := ruleMap["id"].(string) + if ruleID != "" { + return fmt.Errorf("rule %q: %w", ruleID, err) + } + return fmt.Errorf("rule[%d]: %w", i, err) + } + } + } + return nil +} + +// isAgentVersionCompatible checks if the current agent version satisfies the given requirement +// using semantic versioning constraints. Returns true if compatible, false otherwise. +func isAgentVersionCompatible(requirement string) bool { + agentVersion := os.Getenv("AGENT_VERSION") + if agentVersion == "" { + // If AGENT_VERSION is not set, log a warning and allow all rules for backward compatibility + logger.L().Warning("RulesWatcher - AGENT_VERSION environment variable not set, allowing all rules") + return true + } + + // Parse the agent version + currentVersion, err := semver.NewVersion(agentVersion) + if err != nil { + logger.L().Warning("RulesWatcher - invalid agent version format", + helpers.String("agentVersion", agentVersion), + helpers.Error(err)) + return true // Allow rule if we can't parse current version + } + + // Parse the requirement constraint + constraint, err := semver.NewConstraint(requirement) + if err != nil { + logger.L().Warning("RulesWatcher - invalid version constraint in rule", + helpers.String("constraint", requirement), + helpers.Error(err)) + return true // Allow rule if we can't parse the constraint + } + + // Check if current version satisfies the constraint + compatible := constraint.Check(currentVersion) + + logger.L().Debug("RulesWatcher - version compatibility check", + helpers.String("agentVersion", agentVersion), + helpers.String("requirement", requirement), + helpers.Interface("compatible", compatible)) + + return compatible +} diff --git a/pkg/rulemanager/types/v1/profiledata_signing_test.go b/pkg/rulemanager/types/v1/profiledata_signing_test.go new file mode 100644 index 0000000000..62f7024ca8 --- /dev/null +++ b/pkg/rulemanager/types/v1/profiledata_signing_test.go @@ -0,0 +1,64 @@ +//go:build experimental_signing + +package types + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// TestProfileDataRequired_Unmarshal_ExplicitNullJSON verifies that an EXPLICIT +// JSON null from previously serialized signing payloads unmarshals +// back to a nil surface instead of being rejected. Without this, any Rules +// object that has been re-serialised through this type — the offline signing +// CLI, the signed-content blob embedded in a signed Rules fragment, an +// apiserver round-trip — fails to parse and the whole fragment is dropped. +func TestProfileDataRequired_Unmarshal_ExplicitNullJSON(t *testing.T) { + data := `{"execs":"all","capabilities":null,"opens":null}` + var pdr ProfileDataRequired + require.NoError(t, json.Unmarshal([]byte(data), &pdr)) + + require.NotNil(t, pdr.Execs) + assert.True(t, pdr.Execs.All) + assert.Nil(t, pdr.Capabilities, "explicit null must mean undeclared") + assert.Nil(t, pdr.Opens, "explicit null must mean undeclared") +} + +// TestProfileDataRequired_Unmarshal_ExplicitNullYAML is the YAML twin of +// TestProfileDataRequired_Unmarshal_ExplicitNullJSON. +func TestProfileDataRequired_Unmarshal_ExplicitNullYAML(t *testing.T) { + input := "execs: all\ncapabilities: null\nopens: ~\n" + var pdr ProfileDataRequired + require.NoError(t, yaml.Unmarshal([]byte(input), &pdr)) + + require.NotNil(t, pdr.Execs) + assert.True(t, pdr.Execs.All) + assert.Nil(t, pdr.Capabilities, "explicit null must mean undeclared") + assert.Nil(t, pdr.Opens, "explicit ~ must mean undeclared") +} + +// TestProfileDataRequired_MarshalUnmarshal_RoundTrip pins the symmetry the two +// tests above rely on: marshal → unmarshal must preserve every declaration. +func TestProfileDataRequired_MarshalUnmarshal_RoundTrip(t *testing.T) { + original := ProfileDataRequired{ + Execs: &FieldRequirement{All: true}, + Opens: &FieldRequirement{Patterns: []PatternObject{{Prefix: "/etc/"}}}, + } + data, err := json.Marshal(original) + require.NoError(t, err) + + var round ProfileDataRequired + require.NoError(t, json.Unmarshal(data, &round), "marshalled form must be re-parseable: %s", data) + assert.Equal(t, original, round) +} + +// TestProfileDataRequired_Unmarshal_EmptyStringStillRejected verifies the null +// acceptance did not open the door to an empty string, which stays invalid. +func TestProfileDataRequired_Unmarshal_EmptyStringStillRejected(t *testing.T) { + var pdr ProfileDataRequired + assert.Error(t, json.Unmarshal([]byte(`{"execs":""}`), &pdr)) +} diff --git a/pkg/rulemanager/types/v1/types.go b/pkg/rulemanager/types/v1/types.go index d3f16fd159..86e7998068 100644 --- a/pkg/rulemanager/types/v1/types.go +++ b/pkg/rulemanager/types/v1/types.go @@ -1,3 +1,5 @@ +//go:build !experimental_signing + package types import ( diff --git a/pkg/rulemanager/types/v1/types_signing.go b/pkg/rulemanager/types/v1/types_signing.go new file mode 100644 index 0000000000..d3146c2099 --- /dev/null +++ b/pkg/rulemanager/types/v1/types_signing.go @@ -0,0 +1,67 @@ +//go:build experimental_signing + +package types + +import ( + "github.com/armosec/armoapi-go/armotypes" + "github.com/kubescape/node-agent/pkg/rulemanager/prefilter" + "github.com/kubescape/node-agent/pkg/utils" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type Rules struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + + Spec RulesSpec `json:"spec"` +} + +type RulesSpec struct { + Rules []Rule `json:"rules" yaml:"rules"` +} + +type Rule struct { + Enabled bool `json:"enabled" yaml:"enabled"` + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + Expressions RuleExpressions `json:"expressions" yaml:"expressions"` + ProfileDependency armotypes.ProfileDependency `json:"profileDependency" yaml:"profileDependency"` + ProfileDataRequired *ProfileDataRequired `json:"profileDataRequired,omitempty" yaml:"profileDataRequired,omitempty"` + Severity int `json:"severity" yaml:"severity"` + SupportPolicy bool `json:"supportPolicy" yaml:"supportPolicy"` + Tags []string `json:"tags" yaml:"tags"` + State map[string]any `json:"state,omitempty" yaml:"state,omitempty"` + AgentVersionRequirement string `json:"agentVersionRequirement" yaml:"agentVersionRequirement"` + IsTriggerAlert bool `json:"isTriggerAlert" yaml:"isTriggerAlert"` + MitreTactic string `json:"mitreTactic" yaml:"mitreTactic"` + MitreTechnique string `json:"mitreTechnique" yaml:"mitreTechnique"` + Prefilter *prefilter.Params `json:"-" yaml:"-"` + + // Provenance of the rule, established by the rules watcher after the + // carrying Rules object was admitted. Bundle is the SIGNED bundle the + // admitted overlay fragment belongs to — the same bundle its ContainerProfile + // half carries — so the rule applies to exactly the workloads that opted into + // that bundle via the kubescape.io/user-defined-profile pod label, in any + // namespace. ClusterWide marks a rule from a base fragment, which applies to + // every workload and belongs to no bundle. Both bundle membership and the + // fragment class are signed, so an installer can neither re-target an overlay + // nor promote it to cluster-wide. + // + // These MUST stay json:"-" / yaml:"-": Rule is inside RulesSpec, which IS + // the signed content, so making them serialisable would change every + // existing content hash and invalidate every existing signature. + Bundle string `json:"-" yaml:"-"` + ClusterWide bool `json:"-" yaml:"-"` +} + +type RuleExpressions struct { + Message string `json:"message" yaml:"message"` + UniqueID string `json:"uniqueId" yaml:"uniqueId"` + RuleExpression []RuleExpression `json:"ruleExpression" yaml:"ruleExpression"` +} + +type RuleExpression struct { + EventType utils.EventType `json:"eventType" yaml:"eventType"` + Expression string `json:"expression" yaml:"expression"` +} diff --git a/pkg/signature/annotations.go b/pkg/signature/annotations.go new file mode 100644 index 0000000000..0296ebceef --- /dev/null +++ b/pkg/signature/annotations.go @@ -0,0 +1,33 @@ +//go:build experimental_signing + +package signature + +import "errors" + +const ( + AnnotationPrefix = "signature.kubescape.io" + + AnnotationSignature = AnnotationPrefix + "/signature" + AnnotationCertificate = AnnotationPrefix + "/certificate" + AnnotationRekorBundle = AnnotationPrefix + "/rekor-bundle" + AnnotationIssuer = AnnotationPrefix + "/issuer" + AnnotationIdentity = AnnotationPrefix + "/identity" + AnnotationTimestamp = AnnotationPrefix + "/timestamp" + + // AnnotationContent carries the exact canonical content the signature was + // computed over (base64(gzip(JSON))). Storage normalises object SPECS on + // save (deflate — cluster-configurable, so not reproducible offline) but + // never touches annotations; embedding the signed content makes the + // signature chain independent of any server-side conversion: verification + // binds to these bytes, and consumers use them as the verified source of + // truth. + AnnotationContent = AnnotationPrefix + "/content" +) + +var ErrObjectNotSigned = errors.New("object is not signed (missing signature annotation)") + +// ErrSignatureMismatch reports invalid signature material, including malformed +// annotations, or a signature that does not verify against the object's content +// and certificate. Callers distinguish this from verification machinery errors +// such as hash computation or verifier construction failures. +var ErrSignatureMismatch = errors.New("signature verification failed") diff --git a/pkg/signature/bundle/admit_differential_test.go b/pkg/signature/bundle/admit_differential_test.go new file mode 100644 index 0000000000..40119e5702 --- /dev/null +++ b/pkg/signature/bundle/admit_differential_test.go @@ -0,0 +1,265 @@ +//go:build experimental_signing + +package bundle + +import ( + "crypto/ecdsa" + "errors" + "testing" + + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func cpSpecForPath(path string) v1beta1.ContainerProfileSpec { + switch path { + case "execs": + return v1beta1.ContainerProfileSpec{Execs: []v1beta1.ExecCalls{{Path: "/bin/x"}}} + case "opens": + return v1beta1.ContainerProfileSpec{Opens: []v1beta1.OpenCalls{{Path: "/data/x", Flags: []string{"O_RDONLY"}}}} + case "ingress": + port := int32(80) + return v1beta1.ContainerProfileSpec{Ingress: []v1beta1.NetworkNeighbor{{Identifier: "n", Type: "internal", Ports: []v1beta1.NetworkPort{{Name: "TCP-80", Port: &port, Protocol: "TCP"}}}}} + default: + return v1beta1.ContainerProfileSpec{Capabilities: []string{"CAP_NET_ADMIN"}} + } +} + +func buildFragment(t *testing.T, name string, bundleLabel, class string, spec v1beta1.ContainerProfileSpec, key *ecdsa.PrivateKey, signed bool) *v1beta1.ContainerProfile { + t.Helper() + labels := map[string]string{} + if bundleLabel != "" { + labels[LabelBundle] = bundleLabel + } + if class != "" { + labels[LabelFragmentClass] = class + } + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "any", Labels: labels}, + Spec: spec, + } + if signed { + if err := signature.SignObject(profiles.NewContainerProfileAdapter(cp), signature.WithPrivateKey(key)); err != nil { + t.Fatalf("sign %s: %v", name, err) + } + } + return cp +} + +func TestAdmitDifferential_ContainerProfileMatrix(t *testing.T) { + baseKey, admKey, ovlKey, attackerKey := genKey(t), genKey(t), genKey(t), genKey(t) + + fpOf := func(key *ecdsa.PrivateKey) string { + cp := buildFragment(t, "probe", "bundleX", "base", cpSpecForPath("execs"), key, true) + return signerIDOf(t, cp) + } + baseFp, admFp, ovlFp := fpOf(baseKey), fpOf(admKey), fpOf(ovlKey) + + const bundleName = "bundleX" + policy := TrustPolicy{Classes: map[FragmentClass]ClassPolicy{ + ClassBase: {Signers: []string{baseFp}, AllowedSpecPaths: []string{"execs"}}, + ClassAdmission: {Signers: []string{admFp}, AllowedSpecPaths: []string{"ingress"}}, + ClassOverlay: {Signers: []string{ovlFp}, AllowedSpecPaths: []string{"opens"}}, + }} + + type classInfo struct { + name string + inPolicy bool + trustedKey *ecdsa.PrivateKey + allowedPath string + forbidPath string + } + classes := []classInfo{ + {"base", true, baseKey, "execs", "ingress"}, + {"admission", true, admKey, "ingress", "execs"}, + {"overlay", true, ovlKey, "opens", "ingress"}, + {"unknown", false, attackerKey, "execs", "execs"}, + {"", false, attackerKey, "execs", "execs"}, + } + + expected := func(signed, bundleMatch bool, ci classInfo, signerTrusted, pathAllowed bool) error { + if !signed { + return ErrFragmentUnsigned + } + if !bundleMatch { + return ErrNoClass + } + if ci.name == "" { + return ErrNoClass + } + if !ci.inPolicy { + return ErrClassNotAllowed + } + if !signerTrusted { + return ErrSignerNotTrusted + } + if !pathAllowed { + return ErrPathNotAllowed + } + return nil + } + + checks := 0 + admitted := 0 + for _, signed := range []bool{true, false} { + for _, bundleMatch := range []bool{true, false} { + for _, ci := range classes { + for _, signerTrusted := range []bool{true, false} { + for _, pathAllowed := range []bool{true, false} { + key := ci.trustedKey + if !signerTrusted { + key = attackerKey + } + path := ci.allowedPath + if !pathAllowed { + path = ci.forbidPath + } + bl := bundleName + if !bundleMatch { + bl = "otherBundle" + } + cp := buildFragment(t, "matrix-frag", bl, ci.name, cpSpecForPath(path), key, signed) + + want := expected(signed, bundleMatch, ci, signerTrusted, pathAllowed) + _, err := admitFragment(cp, bundleName, policy) + + if want == nil { + if err != nil { + t.Fatalf("signed=%v bundleMatch=%v class=%q signerTrusted=%v pathAllowed=%v: expected ADMIT, got %v", signed, bundleMatch, ci.name, signerTrusted, pathAllowed, err) + } + admitted++ + } else { + if !errors.Is(err, want) { + t.Fatalf("signed=%v bundleMatch=%v class=%q signerTrusted=%v pathAllowed=%v: expected errors.Is(%v), got %v", signed, bundleMatch, ci.name, signerTrusted, pathAllowed, want, err) + } + } + checks++ + } + } + } + } + } + t.Logf("admitFragment matched an independent precedence model on all %d matrix cells (%d admits, %d rejections)", checks, admitted, checks-admitted) +} + +func buildRules(t *testing.T, name, bundleLabel, class string, ids []string, key *ecdsa.PrivateKey, signed bool) *rulemanagertypesv1.Rules { + t.Helper() + labels := map[string]string{} + if bundleLabel != "" { + labels[LabelBundle] = bundleLabel + } + if class != "" { + labels[LabelFragmentClass] = class + } + var rules []rulemanagertypesv1.Rule + for _, id := range ids { + rules = append(rules, rulemanagertypesv1.Rule{Enabled: true, ID: id, Name: "rule-" + id}) + } + r := &rulemanagertypesv1.Rules{ + TypeMeta: metav1.TypeMeta{APIVersion: "kubescape.io/v1", Kind: "Rules"}, + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "any", Labels: labels}, + Spec: rulemanagertypesv1.RulesSpec{Rules: rules}, + } + if signed { + if err := signature.SignObject(profiles.NewRulesAdapter(r), signature.WithPrivateKey(key)); err != nil { + t.Fatalf("sign rules %s: %v", name, err) + } + } + return r +} + +func TestAdmitDifferential_RulesMatrix(t *testing.T) { + baseKey, ovlKey, attackerKey := genKey(t), genKey(t), genKey(t) + + fpOf := func(key *ecdsa.PrivateKey) string { + return rulesSignerIDOf(t, buildRules(t, "probe", "bundleX", "base", []string{"R0001"}, key, true)) + } + baseFp, ovlFp := fpOf(baseKey), fpOf(ovlKey) + + policy := TrustPolicy{RuleClasses: map[RuleClass]RuleClassPolicy{ + RuleClassBase: {Signers: []string{baseFp}, AllowedRuleIDs: []string{"R0001", "R0002"}}, + RuleClassOverlay: {Signers: []string{ovlFp}, AllowedRuleIDs: []string{"R0001"}}, + }} + + type rClassInfo struct { + name string + inPolicy bool + trustedKey *ecdsa.PrivateKey + } + rclasses := []rClassInfo{ + {"base", true, baseKey}, + {"overlay", true, ovlKey}, + {"unknown", false, attackerKey}, + {"", false, attackerKey}, + } + + allowedIDs := []string{"R0001"} + disallowedIDs := []string{"R0001", "R9999"} + + expected := func(signed bool, ci rClassInfo, signerTrusted, idsAllowed, bundlePresent bool) error { + if !signed { + return ErrFragmentUnsigned + } + if ci.name == "" { + return ErrNoClass + } + if !ci.inPolicy { + return ErrRuleClassNotAllowed + } + if !signerTrusted { + return ErrSignerNotTrusted + } + if !idsAllowed { + return ErrRuleIDNotAllowed + } + if ci.name == "overlay" && !bundlePresent { + return ErrRuleBundleRequired + } + return nil + } + + checks := 0 + admitted := 0 + for _, signed := range []bool{true, false} { + for _, ci := range rclasses { + for _, signerTrusted := range []bool{true, false} { + for _, idsAllowed := range []bool{true, false} { + for _, bundlePresent := range []bool{true, false} { + key := ci.trustedKey + if !signerTrusted { + key = attackerKey + } + ids := allowedIDs + if !idsAllowed { + ids = disallowedIDs + } + bl := "bundleX" + if !bundlePresent { + bl = "" + } + r := buildRules(t, "matrix-rules", bl, ci.name, ids, key, signed) + + want := expected(signed, ci, signerTrusted, idsAllowed, bundlePresent) + _, err := AdmitRulesFragment(r, policy) + + if want == nil { + if err != nil { + t.Fatalf("signed=%v class=%q signerTrusted=%v idsAllowed=%v bundlePresent=%v: expected ADMIT, got %v", signed, ci.name, signerTrusted, idsAllowed, bundlePresent, err) + } + admitted++ + } else { + if !errors.Is(err, want) { + t.Fatalf("signed=%v class=%q signerTrusted=%v idsAllowed=%v bundlePresent=%v: expected errors.Is(%v), got %v", signed, ci.name, signerTrusted, idsAllowed, bundlePresent, want, err) + } + } + checks++ + } + } + } + } + } + t.Logf("AdmitRulesFragment matched an independent precedence model on all %d matrix cells (%d admits, %d rejections)", checks, admitted, checks-admitted) +} diff --git a/pkg/signature/bundle/adversarial_test.go b/pkg/signature/bundle/adversarial_test.go new file mode 100644 index 0000000000..962b4f2f46 --- /dev/null +++ b/pkg/signature/bundle/adversarial_test.go @@ -0,0 +1,157 @@ +//go:build experimental_signing + +package bundle + +// Adversarial tests: an attacker with full kubectl on ContainerProfiles tries to +// get malicious content enforced through the bundle path. node-agent only +// VERIFIES fragment signatures against the trust policy (public-key +// fingerprints) — it holds no signing key — so every one of these must fail +// closed. Each test is one attack vector. + +import ( + "crypto/ecdsa" + "errors" + "testing" + + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +// redisTrust builds the demo trust policy and returns it alongside the vendor +// (base) and operator (admission) keys. The attacker key is deliberately NOT in +// the policy. +func redisTrust(t *testing.T) (policy TrustPolicy, vendor, operator *ecdsa.PrivateKey) { + t.Helper() + vendor, operator = genKey(t), genKey(t) + vendorID := signerIDOf(t, fragment(t, "id-base", string(ClassBase), baseSpec(), vendor)) + operatorID := signerIDOf(t, fragment(t, "id-adm", string(ClassAdmission), admissionSpec(), operator)) + return redisPolicy(vendorID, operatorID), vendor, operator +} + +// Attack 1 — the scenario from the design review: an attacker injects an extra +// admission "client" fragment signed with their OWN key. Even though it is a +// syntactically valid, signed, correctly-classed fragment, the signer is not +// trusted for the class, so the whole bundle is rejected. +func TestAdversarial_ClientInjection_SelfSignedKeyRejected(t *testing.T) { + policy, vendor, operator := redisTrust(t) + attacker := genKey(t) + + base := fragment(t, "redis-base", string(ClassBase), baseSpec(), vendor) + legit := fragment(t, "redis-client", string(ClassAdmission), admissionSpec(), operator) + evil := fragment(t, "evil-client", string(ClassAdmission), admissionSpec(), attacker) + + _, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, legit, evil}, policy) + if !errors.Is(err, ErrSignerNotTrusted) { + t.Fatalf("attacker-signed client fragment must be rejected as untrusted signer, got: %v", err) + } +} + +// Attack 2 — cross-class key confusion: the vendor key IS trusted, but only for +// the base class. Using it to sign an admission fragment (to smuggle ingress) +// must still be rejected: trust is bound per class, not per key. +func TestAdversarial_CrossClassKeyConfusion_VendorKeyOnAdmission(t *testing.T) { + policy, vendor, _ := redisTrust(t) + + base := fragment(t, "redis-base", string(ClassBase), baseSpec(), vendor) + // vendor (a trusted key) signs an admission-class fragment it is not + // permitted to sign. + crossClass := fragment(t, "sneaky-admission", string(ClassAdmission), admissionSpec(), vendor) + + _, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, crossClass}, policy) + if !errors.Is(err, ErrSignerNotTrusted) { + t.Fatalf("a key trusted for base must not sign admission fragments, got: %v", err) + } +} + +// Attack 3 — signature stripping: take a validly-signed fragment and delete its +// signature annotation, hoping an unsigned fragment slips through. It must be +// rejected as unsigned (signing is not optional inside a bundle). +func TestAdversarial_SignatureStripped_Rejected(t *testing.T) { + policy, vendor, operator := redisTrust(t) + + base := fragment(t, "redis-base", string(ClassBase), baseSpec(), vendor) + adm := fragment(t, "redis-client", string(ClassAdmission), admissionSpec(), operator) + delete(adm.Annotations, signature.AnnotationSignature) + + _, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, adm}, policy) + if !errors.Is(err, ErrFragmentUnsigned) { + t.Fatalf("signature-stripped fragment must be rejected as unsigned, got: %v", err) + } +} + +// Attack 4 — forged identity via certificate swap: the attacker signs a +// malicious fragment with their own key, then overwrites the certificate +// annotation with the trusted operator's certificate to *claim* the operator's +// identity. The signature was made by the attacker's key, so it does not verify +// against the operator's certificate → tamper. You cannot borrow an identity by +// copying its public certificate. +func TestAdversarial_CertificateSwap_ForgedIdentityRejected(t *testing.T) { + policy, vendor, operator := redisTrust(t) + attacker := genKey(t) + + base := fragment(t, "redis-base", string(ClassBase), baseSpec(), vendor) + legit := fragment(t, "redis-client", string(ClassAdmission), admissionSpec(), operator) + evil := fragment(t, "redis-client", string(ClassAdmission), admissionSpec(), attacker) + // Claim the operator's identity by stapling their certificate onto the + // attacker-signed object. + evil.Annotations[signature.AnnotationCertificate] = legit.Annotations[signature.AnnotationCertificate] + + _, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, evil}, policy) + if !errors.Is(err, ErrFragmentTampered) { + t.Fatalf("attacker signature under a swapped operator certificate must fail verification, got: %v", err) + } +} + +// Attack 5 — a fragment whose class is not present in the trust policy at all +// (e.g. a made-up class) is rejected: the policy is an allowlist of classes. +func TestAdversarial_UnknownClassRejected(t *testing.T) { + policy, vendor, operator := redisTrust(t) + + base := fragment(t, "redis-base", string(ClassBase), baseSpec(), vendor) + // operator is a trusted key, but "overlay" is not a class in this policy. + unknown := fragment(t, "mystery", "overlay", baseSpec(), operator) + + _, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, unknown}, policy) + if !errors.Is(err, ErrClassNotAllowed) { + t.Fatalf("fragment of a class absent from the policy must be rejected, got: %v", err) + } +} + +// Attack 6 — rollback / replay. KNOWN GAP, asserted so the limitation is +// visible and any future fix flips this test. +// +// Each fragment is signed independently with no monotonic version bound into the +// signed content. So an attacker who possesses an OLDER, more-permissive but +// still validly-signed version of a fragment (e.g. one the operator later +// tightened) can substitute it, and it verifies cleanly — node-agent has no way +// to know it was superseded. This test documents that today the rollback is +// ACCEPTED. Closing it requires a monotonic version/nonce inside the signed +// content (and rejecting a lower version than last seen). See the design notes +// on trust-policy hardening + fragment versioning. +// TestAdversarial_RollbackReplay_GuardedByVersion documents WHERE the rollback +// defense lives. AssembleAndVerify is deliberately STATELESS: an older, +// validly-signed fragment still verifies and assembles here, because a signature +// alone cannot report that it has been superseded. The monotonic guard is one +// layer up, in the cache (checkAndAdvanceVersions), keyed on LeafRef.Version — +// which admitFragment parses from the SIGNED labels (LabelVersion). This test +// pins that the signed version reaches the leaf, so the cache guard has an +// unforgeable value to enforce against its per-slot high-water-mark. +func TestAdversarial_RollbackReplay_GuardedByVersion(t *testing.T) { + policy, vendor, operator := redisTrust(t) + + base := fragment(t, "redis-base", string(ClassBase), baseSpec(), vendor) + oldPermissive := fragment(t, "redis-client", string(ClassAdmission), admissionSpec(), operator) + + _, manifest, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, oldPermissive}, policy) + if err != nil { + t.Fatalf("stateless assembly must still accept a validly-signed fragment: %v", err) + } + // Unversioned fragments surface as version 0 on the leaves; the cache guard + // treats 0 as the floor, so once a versioned fragment is shipped an + // unversioned replay is a rollback (see TestRollbackGuard_UnversionedIsZero). + for _, lf := range manifest.Leaves { + if lf.Version != 0 { + t.Fatalf("unversioned fragment %q should carry version 0, got %d", lf.Name, lf.Version) + } + } +} diff --git a/pkg/signature/bundle/assemble.go b/pkg/signature/bundle/assemble.go new file mode 100644 index 0000000000..885487c771 --- /dev/null +++ b/pkg/signature/bundle/assemble.go @@ -0,0 +1,136 @@ +//go:build experimental_signing + +package bundle + +import ( + "encoding/json" + "reflect" + "sort" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +// jsonKey is a stable dedup key for a value (deterministic struct field order). +func jsonKey(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + return "" + } + return string(b) +} + +// assembleSpec merges fragment specs (already in canonical order) into one +// effective spec. Append-then-dedup mirrors storage's merge discipline; string +// slices are deduped and sorted; ingress/egress merge by Identifier +// (later fragment wins); PolicyByRuleId and LabelSelector override-merge; scalar +// fields are last-writer-wins in fragment order (= class precedence). +func assembleSpec(frags []verifiedFragment) v1beta1.ContainerProfileSpec { + out := v1beta1.ContainerProfileSpec{PolicyByRuleId: map[string]v1beta1.RulePolicy{}} + matchLabels := map[string]string{} + + archSet := map[string]bool{} + capSet := map[string]bool{} + sysSet := map[string]bool{} + seenExec := map[string]bool{} + seenOpen := map[string]bool{} + seenEndpoint := map[string]bool{} + seenStack := map[string]bool{} + + ingressByID := map[string]v1beta1.NetworkNeighbor{} + egressByID := map[string]v1beta1.NetworkNeighbor{} + var ingressOrder, egressOrder []string + + for _, vf := range frags { + s := vf.spec + for _, a := range s.Architectures { + archSet[a] = true + } + for _, c := range s.Capabilities { + capSet[c] = true + } + for _, sc := range s.Syscalls { + sysSet[sc] = true + } + for _, e := range s.Execs { + if k := jsonKey(e); !seenExec[k] { + seenExec[k] = true + out.Execs = append(out.Execs, e) + } + } + for _, o := range s.Opens { + if k := jsonKey(o); !seenOpen[k] { + seenOpen[k] = true + out.Opens = append(out.Opens, o) + } + } + for _, ep := range s.Endpoints { + if k := jsonKey(ep); !seenEndpoint[k] { + seenEndpoint[k] = true + out.Endpoints = append(out.Endpoints, ep) + } + } + for _, cs := range s.IdentifiedCallStacks { + if k := jsonKey(cs); !seenStack[k] { + seenStack[k] = true + out.IdentifiedCallStacks = append(out.IdentifiedCallStacks, cs) + } + } + for _, in := range s.Ingress { + if _, ok := ingressByID[in.Identifier]; !ok { + ingressOrder = append(ingressOrder, in.Identifier) + } + ingressByID[in.Identifier] = in + } + for _, eg := range s.Egress { + if _, ok := egressByID[eg.Identifier]; !ok { + egressOrder = append(egressOrder, eg.Identifier) + } + egressByID[eg.Identifier] = eg + } + for id, p := range s.PolicyByRuleId { + out.PolicyByRuleId[id] = p + } + for k, v := range s.MatchLabels { + matchLabels[k] = v + } + out.MatchExpressions = append(out.MatchExpressions, s.MatchExpressions...) + if s.ImageID != "" { + out.ImageID = s.ImageID + } + if s.ImageTag != "" { + out.ImageTag = s.ImageTag + } + if !reflect.DeepEqual(s.SeccompProfile, v1beta1.SingleSeccompProfile{}) { + out.SeccompProfile = s.SeccompProfile + } + } + + out.Architectures = sortedKeys(archSet) + out.Capabilities = sortedKeys(capSet) + out.Syscalls = sortedKeys(sysSet) + for _, id := range ingressOrder { + out.Ingress = append(out.Ingress, ingressByID[id]) + } + for _, id := range egressOrder { + out.Egress = append(out.Egress, egressByID[id]) + } + if len(matchLabels) > 0 { + out.MatchLabels = matchLabels + } + if len(out.PolicyByRuleId) == 0 { + out.PolicyByRuleId = nil + } + return out +} + +func sortedKeys(m map[string]bool) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/pkg/signature/bundle/binding.go b/pkg/signature/bundle/binding.go new file mode 100644 index 0000000000..db9f09a876 --- /dev/null +++ b/pkg/signature/bundle/binding.go @@ -0,0 +1,106 @@ +//go:build experimental_signing + +package bundle + +import ( + "encoding/json" + "errors" + "fmt" + + rulebindingtypesv1 "github.com/kubescape/node-agent/pkg/rulebindingmanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" +) + +var ErrBindingClassNotAllowed = errors.New("rule binding class not present in trust policy") + +type BindingClassPolicy struct { + Signers []string `json:"signers"` +} + +func (p BindingClassPolicy) allowsSigner(id string) bool { + for _, s := range p.Signers { + if s == id { + return true + } + } + return false +} + +// VerifiedBinding carries the authenticated selectors and rules for enforcement. +type VerifiedBinding struct { + Spec rulebindingtypesv1.RuntimeAlertRuleBindingSpec + Class FragmentClass + Signer string +} + +type bindingEmbeddedView struct { + Metadata struct { + Name string `json:"name"` + Labels map[string]string `json:"labels"` + } `json:"metadata"` + Spec rulebindingtypesv1.RuntimeAlertRuleBindingSpec `json:"spec"` +} + +func AdmitBinding(b *rulebindingtypesv1.RuntimeAlertRuleBinding, policy TrustPolicy) (VerifiedBinding, error) { + if b == nil { + return VerifiedBinding{}, fmt.Errorf("nil RuntimeAlertRuleBinding object") + } + adapter := profiles.NewRuleBindingAdapter(b) + if !signature.IsSigned(adapter) { + return VerifiedBinding{}, fmt.Errorf("%w: %q", ErrFragmentUnsigned, b.Name) + } + if err := signature.VerifyObjectAllowUntrusted(adapter); err != nil { + if errors.Is(err, signature.ErrSignatureMismatch) { + return VerifiedBinding{}, fmt.Errorf("%w: %q: %v", ErrFragmentTampered, b.Name, err) + } + return VerifiedBinding{}, fmt.Errorf("verify rule binding %q: %w", b.Name, err) + } + + name := b.Name + labels := b.Labels + spec := b.Spec + if embedded, present, embErr := signature.EmbeddedContent(adapter); present { + if embErr != nil { + return VerifiedBinding{}, fmt.Errorf("embedded content of %q: %w", b.Name, embErr) + } + var view bindingEmbeddedView + if err := json.Unmarshal(embedded, &view); err != nil { + return VerifiedBinding{}, fmt.Errorf("parse embedded content of %q: %w", b.Name, err) + } + name = view.Metadata.Name + labels = view.Metadata.Labels + spec = view.Spec + } + + class := FragmentClass(labels[LabelFragmentClass]) + if class == "" { + return VerifiedBinding{}, fmt.Errorf("%w: %q", ErrNoClass, name) + } + bpol, ok := policy.BindingClasses[class] + if !ok { + return VerifiedBinding{}, fmt.Errorf("%w: class %q (rule binding %q)", ErrBindingClassNotAllowed, class, name) + } + + sig, err := signature.GetObjectSignature(adapter) + if err != nil { + return VerifiedBinding{}, fmt.Errorf("read signature of %q: %w", name, err) + } + signer, err := signerIdentity(sig) + if err != nil { + return VerifiedBinding{}, fmt.Errorf("signer identity of %q: %w", name, err) + } + if !bpol.allowsSigner(signer) { + return VerifiedBinding{}, fmt.Errorf("%w: signer %q, class %q (rule binding %q)", ErrSignerNotTrusted, signer, class, name) + } + + return VerifiedBinding{Class: class, Signer: signer, Spec: spec}, nil +} + +func BindingSignerID(b *rulebindingtypesv1.RuntimeAlertRuleBinding) (string, error) { + sig, err := signature.GetObjectSignature(profiles.NewRuleBindingAdapter(b)) + if err != nil { + return "", err + } + return signerIdentity(sig) +} diff --git a/pkg/signature/bundle/binding_test.go b/pkg/signature/bundle/binding_test.go new file mode 100644 index 0000000000..388973840e --- /dev/null +++ b/pkg/signature/bundle/binding_test.go @@ -0,0 +1,262 @@ +//go:build experimental_signing + +package bundle + +import ( + "crypto/ecdsa" + "errors" + "fmt" + "reflect" + "testing" + + rulebindingtypesv1 "github.com/kubescape/node-agent/pkg/rulebindingmanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func bindingObject(name, namespace, class string, ruleIDs []string) *rulebindingtypesv1.RuntimeAlertRuleBinding { + labels := map[string]string{} + if class != "" { + labels[LabelFragmentClass] = class + } + rules := make([]rulebindingtypesv1.RuntimeAlertRuleBindingRule, 0, len(ruleIDs)) + for _, id := range ruleIDs { + rules = append(rules, rulebindingtypesv1.RuntimeAlertRuleBindingRule{RuleID: id}) + } + return &rulebindingtypesv1.RuntimeAlertRuleBinding{ + TypeMeta: metav1.TypeMeta{APIVersion: "kubescape.io/v1", Kind: "RuntimeRuleAlertBinding"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + }, + Spec: rulebindingtypesv1.RuntimeAlertRuleBindingSpec{ + Rules: rules, + PodSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "redis"}}, + }, + } +} + +func signedBinding(t *testing.T, name, namespace, class string, ruleIDs []string, key *ecdsa.PrivateKey) *rulebindingtypesv1.RuntimeAlertRuleBinding { + t.Helper() + b := bindingObject(name, namespace, class, ruleIDs) + if err := signature.SignObject(profiles.NewRuleBindingAdapter(b), signature.WithPrivateKey(key)); err != nil { + t.Fatalf("sign %s: %v", name, err) + } + return b +} + +func signedBindingEmbedded(t *testing.T, name, namespace, class string, ruleIDs []string, key *ecdsa.PrivateKey) *rulebindingtypesv1.RuntimeAlertRuleBinding { + t.Helper() + b := bindingObject(name, namespace, class, ruleIDs) + if err := signature.SignObject(profiles.NewRuleBindingAdapter(b), signature.WithPrivateKey(key), signature.WithEmbedContent(true)); err != nil { + t.Fatalf("sign %s: %v", name, err) + } + return b +} + +func bindingSignerIDOf(t *testing.T, b *rulebindingtypesv1.RuntimeAlertRuleBinding) string { + t.Helper() + id, err := BindingSignerID(b) + if err != nil { + t.Fatalf("signer identity: %v", err) + } + return id +} + +func bindingPolicy(class FragmentClass, signer string) TrustPolicy { + return TrustPolicy{BindingClasses: map[FragmentClass]BindingClassPolicy{ + class: {Signers: []string{signer}}, + }} +} + +func TestBindingSigningEnabled(t *testing.T) { + if (TrustPolicy{}).BindingSigningEnabled() { + t.Error("empty policy must report binding signing disabled") + } + profileOnly := TrustPolicy{Classes: map[FragmentClass]ClassPolicy{ClassBase: {}}} + if profileOnly.BindingSigningEnabled() { + t.Error("policy without bindingClasses must report binding signing disabled") + } + rulesOnly := TrustPolicy{RuleClasses: map[RuleClass]RuleClassPolicy{RuleClassBase: {}}} + if rulesOnly.BindingSigningEnabled() { + t.Error("policy with only ruleClasses must report binding signing disabled") + } + withBindings := TrustPolicy{BindingClasses: map[FragmentClass]BindingClassPolicy{ClassBase: {}}} + if !withBindings.BindingSigningEnabled() { + t.Error("policy with bindingClasses must report binding signing enabled") + } +} + +func TestAdmitBinding_HappyPath(t *testing.T) { + key := genKey(t) + b := signedBinding(t, "redis-binding", "redis", string(ClassOverlay), []string{"R0001"}, key) + policy := bindingPolicy(ClassOverlay, bindingSignerIDOf(t, b)) + + got, err := AdmitBinding(b, policy) + if err != nil { + t.Fatalf("AdmitBinding: %v", err) + } + if got.Class != ClassOverlay { + t.Errorf("class = %q, want %q", got.Class, ClassOverlay) + } + if got.Signer != bindingSignerIDOf(t, b) { + t.Errorf("signer = %q", got.Signer) + } +} + +func TestAdmitBinding_Unsigned(t *testing.T) { + b := bindingObject("redis-binding", "redis", string(ClassOverlay), []string{"R0001"}) + policy := bindingPolicy(ClassOverlay, "key:whatever") + + _, err := AdmitBinding(b, policy) + if !errors.Is(err, ErrFragmentUnsigned) { + t.Fatalf("want ErrFragmentUnsigned, got %v", err) + } +} + +func TestAdmitBinding_Tampered(t *testing.T) { + key := genKey(t) + b := signedBinding(t, "redis-binding", "redis", string(ClassOverlay), []string{"R0001"}, key) + policy := bindingPolicy(ClassOverlay, bindingSignerIDOf(t, b)) + if _, err := AdmitBinding(b, policy); err != nil { + t.Fatalf("precondition: binding must admit before tampering: %v", err) + } + + b.Spec.PodSelector = metav1.LabelSelector{MatchLabels: map[string]string{"app": "nothing-matches-this"}} + + _, err := AdmitBinding(b, policy) + if !errors.Is(err, ErrFragmentTampered) { + t.Fatalf("want ErrFragmentTampered after unbinding every pod, got %v", err) + } +} + +func TestAdmitBinding_TamperedRuleListEmptied(t *testing.T) { + key := genKey(t) + b := signedBinding(t, "redis-binding", "redis", string(ClassOverlay), []string{"R0001", "R0002"}, key) + policy := bindingPolicy(ClassOverlay, bindingSignerIDOf(t, b)) + if _, err := AdmitBinding(b, policy); err != nil { + t.Fatalf("precondition: binding must admit before tampering: %v", err) + } + + b.Spec.Rules = nil + + _, err := AdmitBinding(b, policy) + if !errors.Is(err, ErrFragmentTampered) { + t.Fatalf("want ErrFragmentTampered after stripping every bound rule, got %v", err) + } +} + +func TestAdmitBinding_UntrustedSigner(t *testing.T) { + vendor, attacker := genKey(t), genKey(t) + trusted := signedBinding(t, "redis-binding", "redis", string(ClassOverlay), []string{"R0001"}, vendor) + rogue := signedBinding(t, "redis-binding", "redis", string(ClassOverlay), []string{"R0001"}, attacker) + policy := bindingPolicy(ClassOverlay, bindingSignerIDOf(t, trusted)) + + _, err := AdmitBinding(rogue, policy) + if !errors.Is(err, ErrSignerNotTrusted) { + t.Fatalf("want ErrSignerNotTrusted, got %v", err) + } +} + +func TestAdmitBinding_ClassNotAllowed(t *testing.T) { + key := genKey(t) + b := signedBinding(t, "redis-binding", "redis", string(ClassOverlay), []string{"R0001"}, key) + policy := bindingPolicy(ClassBase, bindingSignerIDOf(t, b)) + + _, err := AdmitBinding(b, policy) + if !errors.Is(err, ErrBindingClassNotAllowed) { + t.Fatalf("want ErrBindingClassNotAllowed, got %v", err) + } +} + +func TestAdmitBinding_NoClassLabel(t *testing.T) { + key := genKey(t) + b := signedBinding(t, "unclassed-binding", "redis", "", []string{"R0001"}, key) + policy := bindingPolicy(ClassOverlay, bindingSignerIDOf(t, b)) + + _, err := AdmitBinding(b, policy) + if !errors.Is(err, ErrNoClass) { + t.Fatalf("want ErrNoClass, got %v", err) + } +} + +func TestAdmitBinding_NamespaceIsNotSigned(t *testing.T) { + key := genKey(t) + b := signedBinding(t, "redis-binding", "redis", string(ClassOverlay), []string{"R0001"}, key) + policy := bindingPolicy(ClassOverlay, bindingSignerIDOf(t, b)) + + for _, ns := range []string{"payments", "team-a", ""} { + b.Namespace = ns + + got, err := AdmitBinding(b, policy) + if err != nil { + t.Fatalf("a signed binding must admit in any namespace (%q), got %v", ns, err) + } + if got.Class != ClassOverlay { + t.Fatalf("installed in %q: class = %q, want %q", ns, got.Class, ClassOverlay) + } + } +} + +func TestAdmitBinding_ClassComesFromSignedLabels(t *testing.T) { + key := genKey(t) + b := signedBindingEmbedded(t, "redis-binding", "redis", string(ClassOverlay), []string{"R0001"}, key) + policy := bindingPolicy(ClassOverlay, bindingSignerIDOf(t, b)) + + if _, present, _ := signature.EmbeddedContent(profiles.NewRuleBindingAdapter(b)); !present { + t.Fatal("binding must carry embedded content for this test to mean anything") + } + + b.Labels[LabelFragmentClass] = string(ClassBase) + + got, err := AdmitBinding(b, policy) + if err != nil { + t.Fatalf("AdmitBinding: %v", err) + } + if got.Class != ClassOverlay { + t.Fatalf("class = %q, want the SIGNED class %q", got.Class, ClassOverlay) + } +} + +func TestAdmitBinding_Nil(t *testing.T) { + _, err := AdmitBinding(nil, bindingPolicy(ClassOverlay, "key:whatever")) + if err == nil { + t.Fatal("nil binding must not admit") + } +} + +func TestBindingClassPolicy_AllowsSigner(t *testing.T) { + p := BindingClassPolicy{Signers: []string{"key:a"}} + if !p.allowsSigner("key:a") || p.allowsSigner("key:b") { + t.Error("allowsSigner mismatch") + } + w := BindingClassPolicy{Signers: []string{"*"}} + if !w.allowsSigner("*") || w.allowsSigner("key:a") { + t.Error("signers must be matched literally, no wildcard") + } +} + +func TestAdmitBinding_ReturnsAuthenticatedSpec(t *testing.T) { + for _, embedded := range []bool{false, true} { + t.Run(fmt.Sprint(embedded), func(t *testing.T) { + b := bindingObject("redis-binding", "redis", string(ClassOverlay), []string{"R0001"}) + want := b.Spec + if err := signature.SignObject(profiles.NewRuleBindingAdapter(b), signature.WithPrivateKey(genKey(t)), signature.WithEmbedContent(embedded)); err != nil { + t.Fatal(err) + } + policy := bindingPolicy(ClassOverlay, bindingSignerIDOf(t, b)) + if embedded { + b.Spec = rulebindingtypesv1.RuntimeAlertRuleBindingSpec{} + } + got, err := AdmitBinding(b, policy) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got.Spec, want) { + t.Fatalf("spec = %+v, want authenticated %+v", got.Spec, want) + } + }) + } +} diff --git a/pkg/signature/bundle/bundle_test.go b/pkg/signature/bundle/bundle_test.go new file mode 100644 index 0000000000..e0d28750be --- /dev/null +++ b/pkg/signature/bundle/bundle_test.go @@ -0,0 +1,348 @@ +//go:build experimental_signing + +package bundle + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "errors" + "testing" + + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func genKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("gen key: %v", err) + } + return k +} + +// fragment builds a class-labeled ContainerProfile and signs it with key. +// Labels are set BEFORE signing because they are part of the signed content +// (metadata.labels), so the signature binds the fragment's class. +func fragment(t *testing.T, name, class string, spec v1beta1.ContainerProfileSpec, key *ecdsa.PrivateKey) *v1beta1.ContainerProfile { + t.Helper() + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "redis", + Labels: map[string]string{ + LabelBundle: "redis", + LabelFragmentClass: class, + }, + }, + Spec: spec, + } + if err := signature.SignObject(profiles.NewContainerProfileAdapter(cp), signature.WithPrivateKey(key)); err != nil { + t.Fatalf("sign %s: %v", name, err) + } + return cp +} + +func signerIDOf(t *testing.T, cp *v1beta1.ContainerProfile) string { + t.Helper() + sig, err := signature.GetObjectSignature(profiles.NewContainerProfileAdapter(cp)) + if err != nil { + t.Fatalf("get signature: %v", err) + } + id, err := signerIdentity(sig) + if err != nil { + t.Fatalf("signer identity: %v", err) + } + return id +} + +// baseSpec is a vendor default: execs/opens, no ingress (cp-redis.yaml shape). +func baseSpec() v1beta1.ContainerProfileSpec { + return v1beta1.ContainerProfileSpec{ + Architectures: []string{"amd64"}, + Execs: []v1beta1.ExecCalls{{Path: "/opt/bitnami/redis/bin/redis-server", Args: []string{"redis-server"}}}, + Opens: []v1beta1.OpenCalls{{Path: "/data/dump.rdb", Flags: []string{"O_RDWR"}}}, + } +} + +// admissionSpec is the "allowlist a client later" ingress fragment (DEMO.md §5). +func admissionSpec() v1beta1.ContainerProfileSpec { + port := int32(6379) + return v1beta1.ContainerProfileSpec{ + Ingress: []v1beta1.NetworkNeighbor{{ + Identifier: "allowed-client", + Type: "internal", + PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "redis-client"}}, + NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"kubernetes.io/metadata.name": "redis"}}, + Ports: []v1beta1.NetworkPort{{Name: "TCP-6379", Port: &port, Protocol: "TCP"}}, + }}, + } +} + +func redisPolicy(vendorID, operatorID string) TrustPolicy { + return TrustPolicy{Classes: map[FragmentClass]ClassPolicy{ + ClassBase: {Signers: []string{vendorID}, AllowedSpecPaths: []string{"architectures", "capabilities", "execs", "opens", "syscalls", "rulePolicies", "endpoints", "seccompProfile", "imageID", "imageTag", "identifiedCallStacks", "labelSelector"}}, + ClassAdmission: {Signers: []string{operatorID}, AllowedSpecPaths: []string{"ingress", "egress"}}, + }} +} + +func TestAssembleAndVerify_HappyPath(t *testing.T) { + vendor, operator := genKey(t), genKey(t) + base := fragment(t, "redis", "base", baseSpec(), vendor) + adm := fragment(t, "redis-ingress-client", "admission", admissionSpec(), operator) + policy := redisPolicy(signerIDOf(t, base), signerIDOf(t, adm)) + + composite, manifest, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, adm}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + // Composite carries the base's execs AND the admission's ingress. + if len(composite.Spec.Execs) != 1 || composite.Spec.Execs[0].Path != "/opt/bitnami/redis/bin/redis-server" { + t.Errorf("composite missing base execs: %+v", composite.Spec.Execs) + } + if len(composite.Spec.Ingress) != 1 || composite.Spec.Ingress[0].Identifier != "allowed-client" { + t.Errorf("composite missing admission ingress: %+v", composite.Spec.Ingress) + } + if len(manifest.Leaves) != 2 { + t.Fatalf("manifest has %d leaves; want 2", len(manifest.Leaves)) + } + if err := VerifyManifestRoot(manifest); err != nil { + t.Errorf("manifest root: %v", err) + } + // Round-trip the manifest from the composite annotation. + got, err := ManifestFromComposite(composite) + if err != nil { + t.Fatalf("ManifestFromComposite: %v", err) + } + if got.Root != manifest.Root { + t.Errorf("round-tripped root %s != %s", got.Root, manifest.Root) + } + // The composite is NOT signed on-cluster: node-agent only verifies fragments. + // It is verified by construction and carries the assembled spec directly. +} + +func TestAssembleAndVerify_OrderIndependent(t *testing.T) { + vendor, operator := genKey(t), genKey(t) + base := fragment(t, "redis", "base", baseSpec(), vendor) + adm := fragment(t, "redis-ingress-client", "admission", admissionSpec(), operator) + policy := redisPolicy(signerIDOf(t, base), signerIDOf(t, adm)) + + _, m1, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, adm}, policy) + if err != nil { + t.Fatal(err) + } + _, m2, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{adm, base}, policy) + if err != nil { + t.Fatal(err) + } + if m1.Root != m2.Root { + t.Errorf("root depends on input order: %s vs %s", m1.Root, m2.Root) + } +} + +func TestAssembleAndVerify_Tampered(t *testing.T) { + vendor, operator := genKey(t), genKey(t) + base := fragment(t, "redis", "base", baseSpec(), vendor) + adm := fragment(t, "redis-ingress-client", "admission", admissionSpec(), operator) + policy := redisPolicy(signerIDOf(t, base), signerIDOf(t, adm)) + + // Tamper the base fragment's spec after signing. + base.Spec.Execs[0].Path = "/bin/evil" + + _, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, adm}, policy) + if !errors.Is(err, ErrFragmentTampered) { + t.Errorf("want ErrFragmentTampered, got %v", err) + } +} + +func TestAssembleAndVerify_UntrustedSigner(t *testing.T) { + vendor, operator, attacker := genKey(t), genKey(t), genKey(t) + base := fragment(t, "redis", "base", baseSpec(), vendor) + // Policy trusts the operator for admission fragments. + operatorID := signerIDOf(t, fragment(t, "op-probe", "admission", admissionSpec(), operator)) + policy := redisPolicy(signerIDOf(t, base), operatorID) + // But the admission fragment is signed by an attacker key not in the policy. + adm := fragment(t, "redis-ingress-client", "admission", admissionSpec(), attacker) + + _, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, adm}, policy) + if !errors.Is(err, ErrSignerNotTrusted) { + t.Errorf("want ErrSignerNotTrusted, got %v", err) + } +} + +func TestAssembleAndVerify_ClassConfinement(t *testing.T) { + vendor, operator := genKey(t), genKey(t) + base := fragment(t, "redis", "base", baseSpec(), vendor) + // An admission-class fragment that illegally sets execs (server-side code), + // signed by the trusted operator key — must still be rejected on path. + badSpec := admissionSpec() + badSpec.Execs = []v1beta1.ExecCalls{{Path: "/bin/backdoor", Args: []string{"backdoor"}}} + adm := fragment(t, "redis-ingress-client", "admission", badSpec, operator) + policy := redisPolicy(signerIDOf(t, base), signerIDOf(t, adm)) + + _, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, adm}, policy) + if !errors.Is(err, ErrPathNotAllowed) { + t.Errorf("want ErrPathNotAllowed, got %v", err) + } +} + +func TestAssembleAndVerify_Unsigned(t *testing.T) { + vendor := genKey(t) + base := fragment(t, "redis", "base", baseSpec(), vendor) + // Unsigned admission fragment. + adm := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "redis-ingress-client", Namespace: "redis", + Labels: map[string]string{LabelBundle: "redis", LabelFragmentClass: "admission"}}, + Spec: admissionSpec(), + } + policy := redisPolicy(signerIDOf(t, base), "unused") + + _, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, adm}, policy) + if !errors.Is(err, ErrFragmentUnsigned) { + t.Errorf("want ErrFragmentUnsigned, got %v", err) + } +} + +// efrag signs a fragment WITH embedded content — the vendor-artifact flow. +func efrag(t *testing.T, name, class string, spec v1beta1.ContainerProfileSpec, key *ecdsa.PrivateKey) *v1beta1.ContainerProfile { + t.Helper() + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "redis", + Labels: map[string]string{ + LabelBundle: "redis", + LabelFragmentClass: class, + }, + }, + Spec: spec, + } + if err := signature.SignObject(profiles.NewContainerProfileAdapter(cp), signature.WithPrivateKey(key), signature.WithEmbedContent(true)); err != nil { + t.Fatalf("sign %s: %v", name, err) + } + return cp +} + +// TestAssembleAndVerify_EmbeddedContent_SurvivesStoredSpecDrift pins the +// vendor-artifact chain: the server may normalise the STORED spec arbitrarily; +// verification + assembly bind the embedded signed content. +func TestAssembleAndVerify_EmbeddedContent_SurvivesStoredSpecDrift(t *testing.T) { + vendor, operator := genKey(t), genKey(t) + base := efrag(t, "redis", "base", baseSpec(), vendor) + adm := efrag(t, "redis-ingress-client", "admission", admissionSpec(), operator) + policy := redisPolicy(signerIDOf(t, base), signerIDOf(t, adm)) + + // simulate server-side deflate: stored spec drifts AND is even maliciously + // altered — both are irrelevant, the embedded content is the truth + base.Spec.Execs = []v1beta1.ExecCalls{{Path: "/bin/evil-not-signed"}} + adm.Spec.Ingress = nil + + composite, manifest, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, adm}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify must bind embedded content: %v", err) + } + if len(composite.Spec.Execs) != 1 || composite.Spec.Execs[0].Path != "/opt/bitnami/redis/bin/redis-server" { + t.Errorf("composite must contain the SIGNED exec, got %+v", composite.Spec.Execs) + } + if len(composite.Spec.Ingress) != 1 || composite.Spec.Ingress[0].Identifier != "allowed-client" { + t.Errorf("composite must contain the SIGNED ingress, got %+v", composite.Spec.Ingress) + } + if err := VerifyManifestRoot(manifest); err != nil { + t.Error(err) + } +} + +// TestAssembleAndVerify_EmbeddedContent_StoredLabelFlipIgnored: an attacker +// flipping the STORED class label cannot escalate — class comes from the +// signed embedded labels. +func TestAssembleAndVerify_EmbeddedContent_StoredLabelFlipIgnored(t *testing.T) { + vendor, operator := genKey(t), genKey(t) + base := efrag(t, "redis", "base", baseSpec(), vendor) + // admission fragment signed with class=admission — attacker flips the + // STORED label to base (whose policy would allow execs) + adm := efrag(t, "redis-ingress-client", "admission", admissionSpec(), operator) + adm.Labels[LabelFragmentClass] = "base" + policy := redisPolicy(signerIDOf(t, base), signerIDOf(t, adm)) + + _, m, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, adm}, policy) + if err != nil { + t.Fatalf("assembly: %v", err) + } + for _, l := range m.Leaves { + if l.Name == "redis-ingress-client" && l.Class != ClassAdmission { + t.Errorf("class must come from the signed content, got %q", l.Class) + } + } +} + +// TestAssembleAndVerify_EmbeddedContent_ForeignBundleRejected: the signed +// bundle label is authoritative — a fragment signed for another bundle cannot +// be pulled in by relabeling the stored object. +func TestAssembleAndVerify_EmbeddedContent_ForeignBundleRejected(t *testing.T) { + vendor, operator := genKey(t), genKey(t) + base := efrag(t, "redis", "base", baseSpec(), vendor) + foreign := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-frag", + Namespace: "redis", + Labels: map[string]string{ + LabelBundle: "other", + LabelFragmentClass: "admission", + }, + }, + Spec: admissionSpec(), + } + if err := signature.SignObject(profiles.NewContainerProfileAdapter(foreign), signature.WithPrivateKey(operator), signature.WithEmbedContent(true)); err != nil { + t.Fatal(err) + } + // attacker relabels the STORED object into the redis bundle + foreign.Labels[LabelBundle] = "redis" + policy := redisPolicy(signerIDOf(t, base), signerIDOf(t, foreign)) + + _, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, foreign}, policy) + if err == nil { + t.Fatal("a fragment signed for another bundle must be rejected") + } +} + +// TestSignerIdentity_IgnoresSpoofableOIDCAnnotations pins that the signer +// identity is the public-key fingerprint, NOT the attacker-controlled +// identity/issuer annotations — else a trust-policy signer bypass (V1/C3). +func TestSignerIdentity_IgnoresSpoofableOIDCAnnotations(t *testing.T) { + attacker := genKey(t) + cp := fragment(t, "redis", "base", baseSpec(), attacker) + // attacker stamps a trusted vendor's OIDC identity on the annotations + cp.Annotations[signature.AnnotationIssuer] = "https://accounts.google.com" + cp.Annotations[signature.AnnotationIdentity] = "trusted-vendor@example.com" + + id, err := SignerID(cp) + if err != nil { + t.Fatal(err) + } + if id == "oidc:https://accounts.google.com#trusted-vendor@example.com" { + t.Fatal("signer identity trusted spoofable OIDC annotations — trust bypass") + } + if id[:4] != "key:" { + t.Errorf("signer identity must be a public-key fingerprint, got %q", id) + } +} + +// TestSeccompConfinement_EmptyDefaultAction: a seccomp payload with empty +// DefaultAction must still confine to its class (V3). +func TestSeccompConfinement_EmptyDefaultAction(t *testing.T) { + vendor, operator := genKey(t), genKey(t) + base := fragment(t, "redis", "base", baseSpec(), vendor) + // admission fragment that smuggles seccomp content with empty DefaultAction + badSpec := admissionSpec() + badSpec.SeccompProfile.Spec.Architectures = []v1beta1.Arch{"amd64"} + adm := fragment(t, "redis-ingress-client", "admission", badSpec, operator) + policy := redisPolicy(signerIDOf(t, base), signerIDOf(t, adm)) + + _, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, adm}, policy) + if !errors.Is(err, ErrPathNotAllowed) { + t.Errorf("seccomp with empty DefaultAction must still be class-confined; got %v", err) + } +} diff --git a/pkg/signature/bundle/config.go b/pkg/signature/bundle/config.go new file mode 100644 index 0000000000..daf086c61c --- /dev/null +++ b/pkg/signature/bundle/config.go @@ -0,0 +1,207 @@ +//go:build experimental_signing + +package bundle + +import ( + "bytes" + "crypto/ecdsa" + "encoding/json" + "errors" + "fmt" + "io" + "os" + + "github.com/kubescape/node-agent/pkg/signature" +) + +const FixedRootKeyPath = "/etc/bundle/root.pub" + +const signedConfigObjectName = "config" + +// ConfigPurpose binds a signed payload to the consumer allowed to use it. +type ConfigPurpose string + +const ( + NodeAgentConfigPurpose ConfigPurpose = "node-agent-config" + ClusterDataPurpose ConfigPurpose = "cluster-data" +) + +func (p ConfigPurpose) validate() error { + switch p { + case NodeAgentConfigPurpose, ClusterDataPurpose: + return nil + default: + return fmt.Errorf("invalid signed config purpose %q", p) + } +} + +type signedConfigArtifact struct { + Purpose ConfigPurpose `json:"purpose"` + Config json.RawMessage `json:"config"` + Annotations map[string]string `json:"annotations"` +} + +type configSignable struct { + purpose ConfigPurpose + raw json.RawMessage + content interface{} + annotations map[string]string +} + +func canonicalConfigContent(raw []byte) (interface{}, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return nil, errors.New("config payload is empty") + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var content interface{} + if err := dec.Decode(&content); err != nil { + return nil, fmt.Errorf("parse config payload: %w", err) + } + var extra json.RawMessage + if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { + return nil, errors.New("config payload has trailing data after the JSON object") + } + if _, ok := content.(map[string]interface{}); !ok { + return nil, errors.New("config payload is not a JSON object") + } + return content, nil +} + +func newConfigSignable(raw json.RawMessage, purpose ConfigPurpose, annotations map[string]string) (*configSignable, error) { + if err := purpose.validate(); err != nil { + return nil, err + } + content, err := canonicalConfigContent(raw) + if err != nil { + return nil, err + } + if annotations == nil { + annotations = make(map[string]string) + } + return &configSignable{purpose: purpose, raw: raw, content: map[string]interface{}{"purpose": purpose, "config": content}, annotations: annotations}, nil +} + +func (c *configSignable) GetAnnotations() map[string]string { return c.annotations } + +func (c *configSignable) SetAnnotations(a map[string]string) { + if a == nil { + a = make(map[string]string) + } + c.annotations = a +} + +func (c *configSignable) GetUID() string { return "" } +func (c *configSignable) GetNamespace() string { return "" } +func (c *configSignable) GetName() string { return signedConfigObjectName } +func (c *configSignable) GetContent() interface{} { return c.content } +func (c *configSignable) GetUpdatedObject() interface{} { + return signedConfigArtifact{Purpose: c.purpose, Config: c.raw, Annotations: c.annotations} +} + +func SignConfig(raw []byte, purpose ConfigPurpose, rootKey *ecdsa.PrivateKey) ([]byte, error) { + if rootKey == nil { + return nil, errors.New("nil root key") + } + it, err := newConfigSignable(json.RawMessage(raw), purpose, nil) + if err != nil { + return nil, fmt.Errorf("sign config: %w", err) + } + if err := signature.SignObject(it, signature.WithPrivateKey(rootKey)); err != nil { + return nil, fmt.Errorf("sign config: %w", err) + } + artifact := signedConfigArtifact{Purpose: it.purpose, Config: it.raw, Annotations: it.GetAnnotations()} + out, err := json.MarshalIndent(artifact, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal signed config: %w", err) + } + return out, nil +} + +func verifyAndPinConfig(data []byte, expectedRootFp string, expectedPurpose ConfigPurpose) ([]byte, error) { + if err := expectedPurpose.validate(); err != nil { + return nil, err + } + var artifact signedConfigArtifact + if err := json.Unmarshal(data, &artifact); err != nil { + return nil, fmt.Errorf("parse signed config: %w", err) + } + if artifact.Purpose != expectedPurpose { + return nil, fmt.Errorf("signed config purpose %q does not match expected %q", artifact.Purpose, expectedPurpose) + } + payload := bytes.TrimSpace(artifact.Config) + if len(payload) == 0 || bytes.Equal(payload, []byte("null")) { + return nil, errors.New("signed config has no config payload") + } + if len(artifact.Annotations) == 0 { + return nil, errors.New("signed config has no signature annotations") + } + if _, embedded := artifact.Annotations[signature.AnnotationContent]; embedded { + return nil, fmt.Errorf("signed config carries an unexpected %s annotation", signature.AnnotationContent) + } + + it, err := newConfigSignable(artifact.Config, artifact.Purpose, artifact.Annotations) + if err != nil { + return nil, fmt.Errorf("signed config rejected: %w", err) + } + + if err := signature.VerifyObjectAllowUntrusted(it); err != nil { + return nil, fmt.Errorf("config signature verification failed: %w", err) + } + + sig, err := signature.GetObjectSignature(it) + if err != nil { + return nil, fmt.Errorf("config signature verification failed: %w", err) + } + signer, err := signerIdentity(sig) + if err != nil { + return nil, fmt.Errorf("config signature verification failed: %w", err) + } + if signer != expectedRootFp { + return nil, errors.New("config not signed by the pinned root key") + } + + return artifact.Config, nil +} + +func fixedRootFingerprint() (string, error) { + fp, _, err := ResolveTrustedRootFingerprint() + if err != nil { + return "", fmt.Errorf("resolve root fingerprint: %w", err) + } + return fp, nil +} + +func LoadSignedConfig(path string, expectedPurpose ConfigPurpose) ([]byte, error) { + rootFp, err := fixedRootFingerprint() + if err != nil { + return nil, err + } + return loadSignedConfigWithRoot(path, rootFp, expectedPurpose) +} + +func loadSignedConfigWithRoot(path, expectedRootFp string, expectedPurpose ConfigPurpose) ([]byte, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read signed config %q: %w", path, err) + } + verified, err := verifyAndPinConfig(b, expectedRootFp, expectedPurpose) + if err != nil { + return nil, fmt.Errorf("signed config %q: %w", path, err) + } + return verified, nil +} + +func LoadSignedConfigIfPresent(path string, expectedPurpose ConfigPurpose) ([]byte, bool, error) { + if _, err := os.Stat(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + return nil, true, fmt.Errorf("stat signed config %q: %w", path, err) + } + verified, err := LoadSignedConfig(path, expectedPurpose) + if err != nil { + return nil, true, err + } + return verified, true, nil +} diff --git a/pkg/signature/bundle/config_purpose_test.go b/pkg/signature/bundle/config_purpose_test.go new file mode 100644 index 0000000000..856d018528 --- /dev/null +++ b/pkg/signature/bundle/config_purpose_test.go @@ -0,0 +1,70 @@ +//go:build experimental_signing + +package bundle + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSignedConfigRejectsPurposeSwap(t *testing.T) { + key := genKey(t) + for _, tc := range []struct { + raw string + purpose, other ConfigPurpose + }{ + {nodeAgentConfigJSON, NodeAgentConfigPurpose, ClusterDataPurpose}, + {clusterDataJSON, ClusterDataPurpose, NodeAgentConfigPurpose}, + } { + t.Run(string(tc.purpose), func(t *testing.T) { + signed, fp := signConfigWithFingerprint(t, tc.raw, tc.purpose, key) + got, err := verifyAndPinConfig(signed, fp, tc.purpose) + require.NoError(t, err) + require.JSONEq(t, tc.raw, string(got)) + _, err = verifyAndPinConfig(signed, fp, tc.other) + require.ErrorContains(t, err, "does not match expected") + // Neither the mounted filename nor the same root signer grants permission + // to use a config intended for a different consumer. + path := filepath.Join(t.TempDir(), "config.signed.json") + require.NoError(t, os.WriteFile(path, signed, 0600)) + _, err = loadSignedConfigWithRoot(path, fp, tc.other) + require.ErrorContains(t, err, "does not match expected") + + var artifact signedConfigArtifact + require.NoError(t, json.Unmarshal(signed, &artifact)) + artifact.Purpose = tc.other + retagged, err := json.Marshal(artifact) + require.NoError(t, err) + _, err = verifyAndPinConfig(retagged, fp, tc.other) + require.ErrorContains(t, err, "signature verification failed") + + artifact.Purpose = "" + legacy, err := json.Marshal(artifact) + require.NoError(t, err) + _, err = verifyAndPinConfig(legacy, fp, tc.purpose) + require.ErrorContains(t, err, "does not match expected") + }) + } +} + +func TestSignedConfigRejectsMissingOrUnknownPurpose(t *testing.T) { + for _, purpose := range []ConfigPurpose{"", "trust-policy"} { + _, err := SignConfig([]byte(nodeAgentConfigJSON), purpose, genKey(t)) + require.ErrorContains(t, err, "invalid signed config purpose") + } +} + +func TestSignedConfigRejectsTrustPolicyArtifact(t *testing.T) { + key := genKey(t) + signed, err := SignTrustPolicy(&TrustPolicy{}, key) + require.NoError(t, err) + _, fp := signConfigWithFingerprint(t, nodeAgentConfigJSON, NodeAgentConfigPurpose, key) + for _, purpose := range []ConfigPurpose{NodeAgentConfigPurpose, ClusterDataPurpose} { + _, err := verifyAndPinConfig(signed, fp, purpose) + require.Error(t, err) + } +} diff --git a/pkg/signature/bundle/config_test.go b/pkg/signature/bundle/config_test.go new file mode 100644 index 0000000000..25eb1357c9 --- /dev/null +++ b/pkg/signature/bundle/config_test.go @@ -0,0 +1,327 @@ +//go:build experimental_signing + +package bundle + +import ( + "crypto/ecdsa" + "encoding/json" + "os" + "reflect" + "strings" + "testing" + + "github.com/kubescape/node-agent/pkg/signature" +) + +const nodeAgentConfigJSON = `{ + "applicationProfileServiceEnabled": true, + "runtimeDetectionEnabled": true, + "enableSignatureVerification": true, + "bundleTrustPolicyPath": "/etc/bundle/trust-policy.signed.json", + "bundleRootKeyPath": "/etc/bundle/root.pub", + "maxSniffingTimePerContainer": "24h", + "excludeNamespaces": ["kube-system", "kubescape"], + "eventDedup": {"enabled": true, "slotsExponent": 18}, + "maxTsProfileSize": 2097152 +}` + +const clusterDataJSON = `{ + "accountID": "11111111-2222-3333-4444-555555555555", + "clusterName": "demo-cluster", + "gatewayWebsocketURL": "gateway:8001", + "kubevulnURL": "kubevuln:8080", + "namespace": "kubescape", + "eventReceiverRestURL": "https://report.example.invalid" +}` + +func signConfigWithFingerprint(t *testing.T, raw string, purpose ConfigPurpose, key *ecdsa.PrivateKey) (signed []byte, fingerprint string) { + t.Helper() + signed, err := SignConfig([]byte(raw), purpose, key) + if err != nil { + t.Fatalf("SignConfig: %v", err) + } + var artifact signedConfigArtifact + if err := json.Unmarshal(signed, &artifact); err != nil { + t.Fatalf("unmarshal artifact: %v", err) + } + it, err := newConfigSignable(artifact.Config, artifact.Purpose, artifact.Annotations) + if err != nil { + t.Fatalf("newConfigSignable: %v", err) + } + sig, err := signature.GetObjectSignature(it) + if err != nil { + t.Fatalf("get signature: %v", err) + } + fp, err := signerIdentity(sig) + if err != nil { + t.Fatalf("signer identity: %v", err) + } + return signed, fp +} + +func decodeJSON(t *testing.T, b []byte) map[string]interface{} { + t.Helper() + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal json: %v", err) + } + return m +} + +func TestSignedConfig_RoundTripNodeAgentConfig(t *testing.T) { + signed, fp := signConfigWithFingerprint(t, nodeAgentConfigJSON, NodeAgentConfigPurpose, genKey(t)) + + got, err := verifyAndPinConfig(signed, fp, NodeAgentConfigPurpose) + if err != nil { + t.Fatalf("verifyAndPinConfig: %v", err) + } + if !reflect.DeepEqual(decodeJSON(t, got), decodeJSON(t, []byte(nodeAgentConfigJSON))) { + t.Fatalf("verified config differs from the signed input: %s", string(got)) + } +} + +func TestSignedConfig_RoundTripClusterData(t *testing.T) { + signed, fp := signConfigWithFingerprint(t, clusterDataJSON, ClusterDataPurpose, genKey(t)) + + got, err := verifyAndPinConfig(signed, fp, ClusterDataPurpose) + if err != nil { + t.Fatalf("verifyAndPinConfig: %v", err) + } + if !reflect.DeepEqual(decodeJSON(t, got), decodeJSON(t, []byte(clusterDataJSON))) { + t.Fatalf("verified clusterData differs from the signed input: %s", string(got)) + } +} + +func TestSignedConfig_WhitespaceAndKeyOrderInsensitive(t *testing.T) { + key := genKey(t) + signed, fp := signConfigWithFingerprint(t, nodeAgentConfigJSON, NodeAgentConfigPurpose, key) + + var artifact signedConfigArtifact + if err := json.Unmarshal(signed, &artifact); err != nil { + t.Fatalf("unmarshal artifact: %v", err) + } + + reordered := `{"eventDedup":{"slotsExponent":18,"enabled":true},"maxTsProfileSize":2097152,` + + `"excludeNamespaces":["kube-system","kubescape"],"bundleRootKeyPath":"/etc/bundle/root.pub",` + + `"maxSniffingTimePerContainer":"24h","bundleTrustPolicyPath":"/etc/bundle/trust-policy.signed.json",` + + `"enableSignatureVerification":true,"runtimeDetectionEnabled":true,"applicationProfileServiceEnabled":true}` + + if string(artifact.Config) == reordered { + t.Fatal("test is not exercising re-serialisation: payload bytes are identical") + } + + artifact.Config = json.RawMessage(reordered) + reserialised, err := json.Marshal(artifact) + if err != nil { + t.Fatalf("marshal artifact: %v", err) + } + + got, err := verifyAndPinConfig(reserialised, fp, NodeAgentConfigPurpose) + if err != nil { + t.Fatalf("re-serialised config must still verify: %v", err) + } + if !reflect.DeepEqual(decodeJSON(t, got), decodeJSON(t, []byte(nodeAgentConfigJSON))) { + t.Fatalf("verified config differs from the signed input: %s", string(got)) + } +} + +func TestSignedConfig_WrongSignerRejected(t *testing.T) { + signed, _ := signConfigWithFingerprint(t, nodeAgentConfigJSON, NodeAgentConfigPurpose, genKey(t)) + _, otherFp := signConfigWithFingerprint(t, nodeAgentConfigJSON, NodeAgentConfigPurpose, genKey(t)) + + _, err := verifyAndPinConfig(signed, otherFp, NodeAgentConfigPurpose) + if err == nil { + t.Fatal("config signed by a foreign key must be rejected") + } + if !strings.Contains(err.Error(), "not signed by the pinned root key") { + t.Fatalf("expected a pinning error, got: %v", err) + } +} + +func TestSignedConfig_TamperedPayloadRejected(t *testing.T) { + signed, fp := signConfigWithFingerprint(t, nodeAgentConfigJSON, NodeAgentConfigPurpose, genKey(t)) + + var artifact signedConfigArtifact + if err := json.Unmarshal(signed, &artifact); err != nil { + t.Fatalf("unmarshal artifact: %v", err) + } + payload := decodeJSON(t, artifact.Config) + payload["enableSignatureVerification"] = false + tampered, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal tampered payload: %v", err) + } + artifact.Config = json.RawMessage(tampered) + tamperedArtifact, err := json.Marshal(artifact) + if err != nil { + t.Fatalf("marshal artifact: %v", err) + } + + _, err = verifyAndPinConfig(tamperedArtifact, fp, NodeAgentConfigPurpose) + if err == nil { + t.Fatal("tampered config payload must be rejected") + } + if !strings.Contains(err.Error(), "signature verification failed") { + t.Fatalf("expected a signature verification error, got: %v", err) + } +} + +func TestSignedConfig_TamperedTrustPolicyPathRejected(t *testing.T) { + signed, fp := signConfigWithFingerprint(t, nodeAgentConfigJSON, NodeAgentConfigPurpose, genKey(t)) + redirected := strings.Replace(string(signed), "/etc/bundle/trust-policy.signed.json", "/tmp/attacker-policy.json", 1) + if redirected == string(signed) { + t.Fatal("test setup failed: trust policy path not present in artifact") + } + + _, err := verifyAndPinConfig([]byte(redirected), fp, NodeAgentConfigPurpose) + if err == nil { + t.Fatal("redirecting bundleTrustPolicyPath must be rejected") + } +} + +func TestSignedConfig_MissingAnnotationsRejected(t *testing.T) { + signed, fp := signConfigWithFingerprint(t, nodeAgentConfigJSON, NodeAgentConfigPurpose, genKey(t)) + + var artifact signedConfigArtifact + if err := json.Unmarshal(signed, &artifact); err != nil { + t.Fatalf("unmarshal artifact: %v", err) + } + artifact.Annotations = nil + stripped, err := json.Marshal(artifact) + if err != nil { + t.Fatalf("marshal artifact: %v", err) + } + + _, err = verifyAndPinConfig(stripped, fp, NodeAgentConfigPurpose) + if err == nil { + t.Fatal("config without signature annotations must be rejected") + } + if !strings.Contains(err.Error(), "no signature annotations") { + t.Fatalf("expected a missing-annotations error, got: %v", err) + } +} + +func TestSignedConfig_MissingPayloadRejected(t *testing.T) { + signed, fp := signConfigWithFingerprint(t, nodeAgentConfigJSON, NodeAgentConfigPurpose, genKey(t)) + + var artifact signedConfigArtifact + if err := json.Unmarshal(signed, &artifact); err != nil { + t.Fatalf("unmarshal artifact: %v", err) + } + artifact.Config = nil + stripped, err := json.Marshal(artifact) + if err != nil { + t.Fatalf("marshal artifact: %v", err) + } + + _, err = verifyAndPinConfig(stripped, fp, NodeAgentConfigPurpose) + if err == nil { + t.Fatal("config artifact without a payload must be rejected") + } + if !strings.Contains(err.Error(), "no config payload") { + t.Fatalf("expected a missing-payload error, got: %v", err) + } +} + +func TestSignedConfig_EmbeddedContentAnnotationRejected(t *testing.T) { + signed, fp := signConfigWithFingerprint(t, nodeAgentConfigJSON, NodeAgentConfigPurpose, genKey(t)) + + var artifact signedConfigArtifact + if err := json.Unmarshal(signed, &artifact); err != nil { + t.Fatalf("unmarshal artifact: %v", err) + } + artifact.Annotations[signature.AnnotationContent] = "c3RhcGxlZA==" + stapled, err := json.Marshal(artifact) + if err != nil { + t.Fatalf("marshal artifact: %v", err) + } + + _, err = verifyAndPinConfig(stapled, fp, NodeAgentConfigPurpose) + if err == nil { + t.Fatal("config artifact carrying embedded content must be rejected") + } + if !strings.Contains(err.Error(), signature.AnnotationContent) { + t.Fatalf("expected an embedded-content error, got: %v", err) + } +} + +func TestSignedConfig_NonObjectPayloadRejected(t *testing.T) { + key := genKey(t) + if _, err := SignConfig([]byte(`["not", "an", "object"]`), NodeAgentConfigPurpose, key); err == nil { + t.Fatal("a non-object config payload must not be signable") + } + if _, err := SignConfig([]byte(`{"a":1} {"b":2}`), NodeAgentConfigPurpose, key); err == nil { + t.Fatal("trailing data after the config object must not be signable") + } + if _, err := SignConfig([]byte(``), NodeAgentConfigPurpose, key); err == nil { + t.Fatal("an empty config payload must not be signable") + } +} + +func TestSignedConfig_LoadIfPresentAbsent(t *testing.T) { + got, present, err := LoadSignedConfigIfPresent(t.TempDir()+"/config.signed.json", NodeAgentConfigPurpose) + if err != nil { + t.Fatalf("absent signed config must not error: %v", err) + } + if present { + t.Fatal("absent signed config must report present=false") + } + if got != nil { + t.Fatalf("absent signed config must return no bytes, got %s", string(got)) + } +} + +func TestSignedConfig_LoadIfPresentRefusesForeignKey(t *testing.T) { + signed, _ := signConfigWithFingerprint(t, nodeAgentConfigJSON, NodeAgentConfigPurpose, genKey(t)) + path := t.TempDir() + "/config.signed.json" + if err := os.WriteFile(path, signed, 0644); err != nil { + t.Fatalf("write signed config: %v", err) + } + + _, present, err := LoadSignedConfigIfPresent(path, NodeAgentConfigPurpose) + if !present { + t.Fatal("an existing signed config must report present=true") + } + if err == nil { + t.Fatal("a signed config not signed by the pinned root key must be refused") + } + if !strings.Contains(err.Error(), path) { + t.Fatalf("refusal must name the file, got: %v", err) + } +} + +func TestSignedConfig_RoundTripFile(t *testing.T) { + signed, fp := signConfigWithFingerprint(t, clusterDataJSON, ClusterDataPurpose, genKey(t)) + path := t.TempDir() + "/clusterData.signed.json" + if err := os.WriteFile(path, signed, 0644); err != nil { + t.Fatalf("write signed config: %v", err) + } + + got, err := loadSignedConfigWithRoot(path, fp, ClusterDataPurpose) + if err != nil { + t.Fatalf("loadSignedConfigWithRoot: %v", err) + } + if !reflect.DeepEqual(decodeJSON(t, got), decodeJSON(t, []byte(clusterDataJSON))) { + t.Fatalf("verified clusterData differs from the signed input: %s", string(got)) + } +} + +func TestSignedConfig_FileTamperRejectedWithPath(t *testing.T) { + signed, fp := signConfigWithFingerprint(t, clusterDataJSON, ClusterDataPurpose, genKey(t)) + tampered := strings.Replace(string(signed), "demo-cluster", "attacker-cluster", 1) + if tampered == string(signed) { + t.Fatal("test setup failed: cluster name not present in artifact") + } + path := t.TempDir() + "/clusterData.signed.json" + if err := os.WriteFile(path, []byte(tampered), 0644); err != nil { + t.Fatalf("write signed config: %v", err) + } + + _, err := loadSignedConfigWithRoot(path, fp, ClusterDataPurpose) + if err == nil { + t.Fatal("a tampered clusterData file must be refused") + } + if !strings.Contains(err.Error(), path) { + t.Fatalf("refusal must name the file, got: %v", err) + } +} diff --git a/pkg/signature/bundle/divergence.go b/pkg/signature/bundle/divergence.go new file mode 100644 index 0000000000..af4659bc11 --- /dev/null +++ b/pkg/signature/bundle/divergence.go @@ -0,0 +1,171 @@ +//go:build experimental_signing + +package bundle + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "reflect" + "sort" + + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +// specPathFields names every top-level ContainerProfileSpec path for the +// divergence diff. setSpecPaths (class confinement, security-relevant) keeps +// its own checks; TestSpecPathTableMatchesSetSpecPaths pins the two lists to +// each other so a new spec field cannot silently join only one of them. +var specPathFields = []struct { + name string + get func(*v1beta1.ContainerProfileSpec) any +}{ + {"architectures", func(s *v1beta1.ContainerProfileSpec) any { return s.Architectures }}, + {"capabilities", func(s *v1beta1.ContainerProfileSpec) any { return s.Capabilities }}, + {"execs", func(s *v1beta1.ContainerProfileSpec) any { return s.Execs }}, + {"opens", func(s *v1beta1.ContainerProfileSpec) any { return s.Opens }}, + {"syscalls", func(s *v1beta1.ContainerProfileSpec) any { return s.Syscalls }}, + {"seccompProfile", func(s *v1beta1.ContainerProfileSpec) any { return s.SeccompProfile }}, + {"endpoints", func(s *v1beta1.ContainerProfileSpec) any { return s.Endpoints }}, + {"imageID", func(s *v1beta1.ContainerProfileSpec) any { return s.ImageID }}, + {"imageTag", func(s *v1beta1.ContainerProfileSpec) any { return s.ImageTag }}, + {"rulePolicies", func(s *v1beta1.ContainerProfileSpec) any { return s.PolicyByRuleId }}, + {"identifiedCallStacks", func(s *v1beta1.ContainerProfileSpec) any { return s.IdentifiedCallStacks }}, + {"labelSelector", func(s *v1beta1.ContainerProfileSpec) any { + return struct { + ML map[string]string + ME any + }{s.MatchLabels, s.MatchExpressions} + }}, + {"ingress", func(s *v1beta1.ContainerProfileSpec) any { return s.Ingress }}, + {"egress", func(s *v1beta1.ContainerProfileSpec) any { return s.Egress }}, +} + +// DiffSpecPaths reports the sorted top-level spec paths on which a and b +// differ. Path names only — values never leave this function, so nothing an +// attacker writes into a stored spec can reach a log pipeline through it. +// nil and empty collections compare equal at every level: the embed +// canonicalization materializes empty maps where the stored object has nil, +// and that must not read as divergence. +func DiffSpecPaths(a, b *v1beta1.ContainerProfileSpec) []string { + var out []string + for _, f := range specPathFields { + if !fieldsEqual(f.get(a), f.get(b)) { + out = append(out, f.name) + } + } + sort.Strings(out) + return out +} + +func fieldsEqual(a, b any) bool { + if reflect.DeepEqual(a, b) { + return true + } + aj, aerr := json.Marshal(a) + bj, berr := json.Marshal(b) + if aerr != nil || berr != nil { + return true + } + var av, bv any + if json.Unmarshal(aj, &av) != nil || json.Unmarshal(bj, &bv) != nil { + return true + } + return normEqual(av, bv) +} + +func emptyish(v any) bool { + switch t := v.(type) { + case nil: + return true + case map[string]any: + return len(t) == 0 + case []any: + return len(t) == 0 + } + return false +} + +func normEqual(a, b any) bool { + if emptyish(a) && emptyish(b) { + return true + } + switch av := a.(type) { + case map[string]any: + bv, ok := b.(map[string]any) + if !ok { + return false + } + for k := range av { + if !normEqual(av[k], bv[k]) { + return false + } + } + for k := range bv { + if _, seen := av[k]; !seen && !normEqual(nil, bv[k]) { + return false + } + } + return true + case []any: + bv, ok := b.([]any) + if !ok || len(av) != len(bv) { + return false + } + for i := range av { + if !normEqual(av[i], bv[i]) { + return false + } + } + return true + default: + return reflect.DeepEqual(a, b) + } +} + +// FragmentStoredDivergence compares a fragment's STORED spec against its +// embedded signed spec. paths is non-empty when the readable object shows +// content that is not what is enforced (or hides content that is). ok is false +// when there is nothing to compare (no embedded content) or the comparison +// itself failed — observability only, so a broken comparison never influences +// admission and reports nothing rather than something wrong. +func FragmentStoredDivergence(cp *v1beta1.ContainerProfile) (paths []string, storedHash string, ok bool) { + adapter := profiles.NewContainerProfileAdapter(cp) + embedded, present, err := signature.EmbeddedContent(adapter) + if !present || err != nil { + return nil, "", false + } + var view embeddedView + if err := json.Unmarshal(embedded, &view); err != nil { + return nil, "", false + } + return DiffSpecPaths(&cp.Spec, &view.Spec), hashSpec(cp.Spec), true +} + +// RulesStoredDivergence is the Rules analogue: the stored rules list vs the +// embedded signed one. Counts only, never rule content. +func RulesStoredDivergence(r *rulemanagertypesv1.Rules) (diverged bool, storedCount, signedCount int, storedHash string, ok bool) { + adapter := profiles.NewRulesAdapter(r) + embedded, present, err := signature.EmbeddedContent(adapter) + if !present || err != nil { + return false, 0, 0, "", false + } + var view rulesEmbeddedView + if err := json.Unmarshal(embedded, &view); err != nil { + return false, 0, 0, "", false + } + diverged = !fieldsEqual(r.Spec.Rules, view.Spec.Rules) + return diverged, len(r.Spec.Rules), len(view.Spec.Rules), hashSpec(r.Spec.Rules), true +} + +func hashSpec(v any) string { + b, err := json.Marshal(v) + if err != nil { + return "" + } + s := sha256.Sum256(b) + return hex.EncodeToString(s[:]) +} diff --git a/pkg/signature/bundle/divergence_test.go b/pkg/signature/bundle/divergence_test.go new file mode 100644 index 0000000000..5c0097a87c --- /dev/null +++ b/pkg/signature/bundle/divergence_test.go @@ -0,0 +1,137 @@ +//go:build experimental_signing + +package bundle + +import ( + "reflect" + "testing" + + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func fullSpec() v1beta1.ContainerProfileSpec { + port := int32(80) + return v1beta1.ContainerProfileSpec{ + Architectures: []string{"amd64"}, + Capabilities: []string{"CAP_NET_ADMIN"}, + Execs: []v1beta1.ExecCalls{{Path: "/bin/x"}}, + Opens: []v1beta1.OpenCalls{{Path: "/data/x", Flags: []string{"O_RDONLY"}}}, + Syscalls: []string{"open"}, + SeccompProfile: v1beta1.SingleSeccompProfile{Spec: v1beta1.SingleSeccompProfileSpec{BaseProfileName: "base"}}, + Endpoints: []v1beta1.HTTPEndpoint{{Endpoint: "/api"}}, + ImageID: "sha256:abc", + ImageTag: "v1", + PolicyByRuleId: map[string]v1beta1.RulePolicy{"R0001": {}}, + IdentifiedCallStacks: []v1beta1.IdentifiedCallStack{{CallID: "c1"}}, + LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "x"}}, + Ingress: []v1beta1.NetworkNeighbor{{Identifier: "n", Type: "internal", Ports: []v1beta1.NetworkPort{{Name: "TCP-80", Port: &port, Protocol: "TCP"}}}}, + Egress: []v1beta1.NetworkNeighbor{{Identifier: "m", Type: "internal"}}, + } +} + +// The divergence table and the class-confinement check must cover the same +// field list: a new spec field joining only one of them would either leak past +// confinement or hide from divergence reporting. +func TestSpecPathTableMatchesSetSpecPaths(t *testing.T) { + spec := fullSpec() + empty := v1beta1.ContainerProfileSpec{} + fromDiff := DiffSpecPaths(&spec, &empty) + fromSet := setSpecPaths(&spec) + require.ElementsMatch(t, fromSet, fromDiff, + "setSpecPaths and the divergence table disagree on the spec field list") +} + +func TestDiffSpecPaths(t *testing.T) { + a := fullSpec() + b := fullSpec() + require.Empty(t, DiffSpecPaths(&a, &b)) + + b.Execs = append(b.Execs, v1beta1.ExecCalls{Path: "/bin/backdoor"}) + b.ImageTag = "v2" + require.Equal(t, []string{"execs", "imageTag"}, DiffSpecPaths(&a, &b)) +} + +func embeddedFragment(t *testing.T, name string, spec v1beta1.ContainerProfileSpec) *v1beta1.ContainerProfile { + t.Helper() + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "redis", Labels: map[string]string{LabelBundle: "redis", LabelFragmentClass: "base"}}, + Spec: spec, + } + require.NoError(t, signature.SignObject(profiles.NewContainerProfileAdapter(cp), signature.WithPrivateKey(genKey(t)), signature.WithEmbedContent(true))) + return cp +} + +func TestFragmentStoredDivergence(t *testing.T) { + clean := embeddedFragment(t, "frag", cpSpecForPath("execs")) + paths, _, ok := FragmentStoredDivergence(clean) + require.True(t, ok) + require.Empty(t, paths, "an unedited fragment must report no divergence") + + edited := embeddedFragment(t, "frag", cpSpecForPath("execs")) + edited.Spec.Execs = append(edited.Spec.Execs, v1beta1.ExecCalls{Path: "/bin/backdoor"}) + paths, h1, ok := FragmentStoredDivergence(edited) + require.True(t, ok) + require.Equal(t, []string{"execs"}, paths) + require.NotEmpty(t, h1) + + noEmbed := buildFragment(t, "plain", "redis", "base", cpSpecForPath("execs"), genKey(t), true) + _, _, ok = FragmentStoredDivergence(noEmbed) + require.False(t, ok, "nothing to compare without embedded content") +} + +// AC #70-1/2: a stored-spec edit changes neither the composite nor the Merkle +// root — the injected content is not enforced — while the divergence reports. +func TestStoredSpecEdit_CompositeAndRootUnchanged(t *testing.T) { + key := genKey(t) + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "base-frag", Namespace: "redis", Labels: map[string]string{LabelBundle: "redis", LabelFragmentClass: "base"}}, + Spec: cpSpecForPath("execs"), + } + require.NoError(t, signature.SignObject(profiles.NewContainerProfileAdapter(cp), signature.WithPrivateKey(key), signature.WithEmbedContent(true))) + fp := signerIDOf(t, cp) + policy := TrustPolicy{Classes: map[FragmentClass]ClassPolicy{ + ClassBase: {Signers: []string{fp}, AllowedSpecPaths: []string{"execs"}}, + }} + + composite1, m1, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{cp}, policy) + require.NoError(t, err) + + cp.Spec.Execs = append(cp.Spec.Execs, v1beta1.ExecCalls{Path: "/bin/backdoor"}) + composite2, m2, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{cp}, policy) + require.NoError(t, err, "a stored-spec edit must not affect admission") + require.Equal(t, m1.Root, m2.Root, "Merkle root must bind the signed content, not the stored spec") + require.True(t, reflect.DeepEqual(composite1.Spec, composite2.Spec), "the injected stored content must not reach the composite") + for _, e := range composite2.Spec.Execs { + require.NotEqual(t, "/bin/backdoor", e.Path) + } + + paths, _, ok := FragmentStoredDivergence(cp) + require.True(t, ok) + require.Equal(t, []string{"execs"}, paths) +} + +func TestRulesStoredDivergence(t *testing.T) { + r := &rulemanagertypesv1.Rules{ + TypeMeta: metav1.TypeMeta{APIVersion: "kubescape.io/v1", Kind: "Rules"}, + ObjectMeta: metav1.ObjectMeta{Name: "baseline", Namespace: "kubescape", Labels: map[string]string{LabelFragmentClass: "base"}}, + Spec: rulemanagertypesv1.RulesSpec{Rules: []rulemanagertypesv1.Rule{{Enabled: true, ID: "R0001", Name: "r"}}}, + } + require.NoError(t, signature.SignObject(profiles.NewRulesAdapter(r), signature.WithPrivateKey(genKey(t)), signature.WithEmbedContent(true))) + + d, _, _, _, ok := RulesStoredDivergence(r) + require.True(t, ok) + require.False(t, d) + + r.Spec.Rules = append(r.Spec.Rules, rulemanagertypesv1.Rule{Enabled: true, ID: "R9999", Name: "rogue"}) + d, stored, signed, h, ok := RulesStoredDivergence(r) + require.True(t, ok) + require.True(t, d) + require.Equal(t, 2, stored) + require.Equal(t, 1, signed) + require.NotEmpty(t, h) +} diff --git a/pkg/signature/bundle/fixture_stacking_test.go b/pkg/signature/bundle/fixture_stacking_test.go new file mode 100644 index 0000000000..e934eb8a20 --- /dev/null +++ b/pkg/signature/bundle/fixture_stacking_test.go @@ -0,0 +1,172 @@ +//go:build experimental_signing + +// Guards the committed stacking fixtures: the v2 fragments must admit under +// the committed CT trust policy with a version strictly above their v1s, or +// the Test_56 replacement/rollback legs assert against dead fixtures. +package bundle + +import ( + "encoding/json" + "os" + "testing" + + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "sigs.k8s.io/yaml" +) + +func committedPolicy(t *testing.T) TrustPolicy { + t.Helper() + raw, err := os.ReadFile("../../../tests/resources/signed/trust-policy.json") + if err != nil { + t.Skipf("committed policy not present: %v", err) + } + var policy TrustPolicy + if err := json.Unmarshal(raw, &policy); err != nil { + t.Fatal(err) + } + return policy +} + +func TestStackingFixtures_OverlayV2AdmitsAboveV1(t *testing.T) { + policy := committedPolicy(t) + load := func(rel string) *v1beta1.ContainerProfile { + b, err := os.ReadFile("../../../tests/resources/signed/fragments/" + rel) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + var cp v1beta1.ContainerProfile + if err := yaml.Unmarshal(b, &cp); err != nil { + t.Fatalf("parse %s: %v", rel, err) + } + return &cp + } + v1f, err := admitFragment(load("bundle37-overlay-signed.yaml"), "bundle37", policy) + if err != nil { + t.Fatalf("overlay v1 must admit: %v", err) + } + v2f, err := admitFragment(load("bundle37-overlay-v2-signed.yaml"), "bundle37", policy) + if err != nil { + t.Fatalf("overlay v2 must admit: %v", err) + } + if v2f.version <= v1f.version { + t.Fatalf("v2 version (%d) must exceed v1 (%d) for the replacement/rollback legs", v2f.version, v1f.version) + } +} + +func TestStackingFixtures_RulesOverrideV2Admits(t *testing.T) { + policy := committedPolicy(t) + b, err := os.ReadFile("../../../tests/resources/signed/rules/namespace-override-v2-signed.yaml") + if err != nil { + t.Fatalf("read rules v2: %v", err) + } + var rules rulemanagertypesv1.Rules + if err := yaml.Unmarshal(b, &rules); err != nil { + t.Fatalf("parse rules v2: %v", err) + } + verified, err := AdmitRulesFragment(&rules, policy) + if err != nil { + t.Fatalf("rules override v2 must admit under the committed policy: %v", err) + } + if verified.Bundle != "bundle37" { + t.Fatalf("rules override v2 bundle = %q, want bundle37", verified.Bundle) + } + if len(verified.Rules) != 1 || verified.Rules[0].ID != "R0001" || verified.Rules[0].Severity != 8 { + t.Fatalf("rules override v2 must carry exactly R0001 severity 8: %+v", verified.Rules) + } +} + +// W6b: a multi-rule fragment carrying ONE rule ID outside its class allowlist +// is rejected WHOLLY — no partial admission of the allowed subset. +func TestRulesAdmission_DisallowedIDRejectsWholeFragment(t *testing.T) { + key := genKey(t) + mixed := signedRules(t, "mixed-rules", "ns", "bundle37", "overlay", []rulemanagertypesv1.Rule{ + {ID: "R0001", Name: "ok", Enabled: true}, + {ID: "R0011", Name: "not-allowed", Enabled: true}, + }, key) + policy := TrustPolicy{RuleClasses: map[RuleClass]RuleClassPolicy{ + RuleClassOverlay: {Signers: []string{rulesSignerIDOf(t, mixed)}, AllowedRuleIDs: []string{"R0001"}}, + }} + if _, aerr := AdmitRulesFragment(mixed, policy); aerr == nil { + t.Fatal("a fragment with any disallowed rule ID must be rejected wholly") + } + + pure := signedRules(t, "pure-rules", "ns", "bundle37", "overlay", []rulemanagertypesv1.Rule{ + {ID: "R0001", Name: "ok", Enabled: true}, + }, key) + if v, aerr := AdmitRulesFragment(pure, policy); aerr != nil { + t.Fatalf("the compliant single-ID fragment must admit: %v", aerr) + } else if len(v.Rules) != 1 { + t.Fatalf("admitted view must carry exactly the one rule: %+v", v.Rules) + } +} + +func TestStackingFixtures_OverlayV3AdmitsAboveV2(t *testing.T) { + policy := committedPolicy(t) + b, err := os.ReadFile("../../../tests/resources/signed/fragments/bundle37-overlay-v3-signed.yaml") + if err != nil { + t.Fatalf("read v3: %v", err) + } + var cp v1beta1.ContainerProfile + if err := yaml.Unmarshal(b, &cp); err != nil { + t.Fatal(err) + } + vf, err := admitFragment(&cp, "bundle37", policy) + if err != nil { + t.Fatalf("overlay v3 must admit: %v", err) + } + if vf.version != 3 { + t.Fatalf("v3 version = %d, want 3", vf.version) + } +} + +func TestStackingFixtures_MixedRulesFragmentRejectedByCommittedPolicy(t *testing.T) { + policy := committedPolicy(t) + b, err := os.ReadFile("../../../tests/resources/signed/rules/mixed-overlay-rejected-signed.yaml") + if err != nil { + t.Fatalf("read mixed: %v", err) + } + var rules rulemanagertypesv1.Rules + if err := yaml.Unmarshal(b, &rules); err != nil { + t.Fatal(err) + } + if _, aerr := AdmitRulesFragment(&rules, policy); aerr == nil { + t.Fatal("the mixed R0001+R0011 overlay fragment must be rejected wholly (R0011 outside the overlay allowlist)") + } +} + +// bundle56 fixtures (debian coreutils, distinct real binaries) admit under the +// committed CT policy with monotonic overlay versions — the exec-grant Test_56 +// legs depend on real binaries so R0001 can actually discriminate grants +// (busybox applets all collapse to /bin/busybox and cannot). +func TestStackingFixtures_Bundle56AdmitsWithVersions(t *testing.T) { + policy := committedPolicy(t) + load := func(rel string) *v1beta1.ContainerProfile { + b, err := os.ReadFile("../../../tests/resources/signed/fragments/" + rel) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + var cp v1beta1.ContainerProfile + if err := yaml.Unmarshal(b, &cp); err != nil { + t.Fatalf("parse %s: %v", rel, err) + } + return &cp + } + if _, err := admitFragment(load("bundle56-base-signed.yaml"), "bundle56", policy); err != nil { + t.Fatalf("bundle56 base must admit: %v", err) + } + var prev int64 + for i, rel := range []string{"bundle56-overlay-v1-signed.yaml", "bundle56-overlay-v2-signed.yaml", "bundle56-overlay-v3-signed.yaml"} { + vf, err := admitFragment(load(rel), "bundle56", policy) + if err != nil { + t.Fatalf("%s must admit: %v", rel, err) + } + if int64(i+1) != vf.version { + t.Fatalf("%s version = %d, want %d", rel, vf.version, i+1) + } + if vf.version <= prev { + t.Fatalf("overlay versions must be strictly monotonic: %d after %d", vf.version, prev) + } + prev = vf.version + } +} diff --git a/pkg/signature/bundle/grouped_fragment_test.go b/pkg/signature/bundle/grouped_fragment_test.go new file mode 100644 index 0000000000..fdafe58cbb --- /dev/null +++ b/pkg/signature/bundle/grouped_fragment_test.go @@ -0,0 +1,53 @@ +//go:build experimental_signing + +package bundle + +import ( + "encoding/json" + "errors" + "fmt" + "testing" + + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +func TestGroupedFragmentsRejected(t *testing.T) { + for _, field := range []string{"containers", "initContainers", "ephemeralContainers"} { + for _, embedded := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/embedded=%t", field, embedded), func(t *testing.T) { + var spec v1beta1.ContainerProfileSpec + // Include a flat grant too: mixed fragments must not silently lose + // the grouped grant while admitting the flat portion. + if err := json.Unmarshal([]byte(fmt.Sprintf(`{"execs":[{"path":"/bin/flat"}],%q:[{"name":"app","execs":[{"path":"/bin/grouped"}]}]}`, field)), &spec); err != nil { + t.Fatal(err) + } + key := genKey(t) + cp := buildFragment(t, "grouped", "bundle", "base", spec, key, false) + if err := signature.SignObject(profiles.NewContainerProfileAdapter(cp), signature.WithPrivateKey(key), signature.WithEmbedContent(embedded)); err != nil { + t.Fatal(err) + } + policy := TrustPolicy{Classes: map[FragmentClass]ClassPolicy{ + ClassBase: {Signers: []string{signerIDOf(t, cp)}, AllowedSpecPaths: []string{"*"}}, + }} + if embedded { + // Admission must inspect signed content, even if storage has + // removed all grouped fields from the carrier. + cp.Spec = baseSpec() + } + composite, manifest, err := AssembleAndVerify("bundle", "any", []*v1beta1.ContainerProfile{cp}, policy) + if !errors.Is(err, ErrGroupedFragment) || composite != nil || manifest != nil { + t.Fatalf("grouped fragment admitted: composite=%v manifest=%v err=%v", composite, manifest, err) + } + composite, manifest, dropped, err := AssembleAndVerifyPartial("bundle", "any", []*v1beta1.ContainerProfile{cp}, policy) + if !errors.Is(err, ErrEmptyBundle) || composite != nil || manifest != nil || len(dropped) != 1 { + t.Fatalf("partial assembly should reject grouped fragment: composite=%v manifest=%v dropped=%v err=%v", composite, manifest, dropped, err) + } + if !errors.Is(dropped[0].Reason, ErrGroupedFragment) { + t.Fatalf("unexpected drop reason: %v", dropped[0].Reason) + } + }) + } + } +} diff --git a/pkg/signature/bundle/loaders.go b/pkg/signature/bundle/loaders.go new file mode 100644 index 0000000000..8bd9296da0 --- /dev/null +++ b/pkg/signature/bundle/loaders.go @@ -0,0 +1,97 @@ +//go:build experimental_signing + +package bundle + +import ( + "encoding/json" + "errors" + "fmt" + "os" +) + +func LoadTrustPolicy(path string) (*TrustPolicy, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read trust policy %q: %w", path, err) + } + var p TrustPolicy + if err := json.Unmarshal(b, &p); err != nil { + return nil, fmt.Errorf("parse trust policy %q: %w", path, err) + } + if len(p.Classes) == 0 { + return nil, fmt.Errorf("trust policy %q has no classes", path) + } + return &p, nil +} + +func LoadSignedTrustPolicy(policyPath, rootKeyPath string) (*TrustPolicy, error) { + rootFp, _, err := ResolveRootFingerprint(rootKeyPath) + if err != nil { + return nil, fmt.Errorf("resolve root fingerprint: %w", err) + } + return loadSignedTrustPolicyWithRoot(policyPath, rootFp) +} + +// ResolveTrustedRootFingerprint resolves the root anchor from the fixed mount +// (FixedRootKeyPath) or the compiled-in default ONLY. Unlike a config-supplied +// path, this anchor cannot be redirected by editing a mutable ConfigMap. +func ResolveTrustedRootFingerprint() (fingerprint string, mounted bool, err error) { + return resolveTrustedRootFingerprintAt(FixedRootKeyPath) +} + +func resolveTrustedRootFingerprintAt(path string) (fingerprint string, mounted bool, err error) { + mountedKeyPath := "" + if _, statErr := os.Stat(path); statErr == nil { + mountedKeyPath = path + } else if !errors.Is(statErr, os.ErrNotExist) { + return "", false, fmt.Errorf("probe trusted root %q: %w", path, statErr) + } + return ResolveRootFingerprint(mountedKeyPath) +} + +// GuardRootAnchor rejects an unsafe trust anchor. In enforce mode a policy +// anchored to the published demo root with no mounted override authenticates +// nothing (anyone holds the private half), so it is refused. nil == acceptable. +func GuardRootAnchor(p *TrustPolicy, rootFp string, mounted bool) error { + if p.Enforcing() && !mounted && IsDemoRoot(rootFp) { + return fmt.Errorf("enforce mode refuses the published demo root key: mount a real root at %s", FixedRootKeyPath) + } + return nil +} + +// RuleAdmissionUnauthenticated reports whether the policy turns rule signing on +// while the trust anchor is the published demo root: every rule-admission +// decision then rests on a key whose private half is public. +func RuleAdmissionUnauthenticated(p *TrustPolicy, rootFp string) bool { + return p != nil && p.RuleSigningEnabled() && IsDemoRoot(rootFp) +} + +// LoadSignedTrustPolicyTrusted loads and verifies the policy against the trusted +// root anchor (fixed mount or compiled default), never a config-supplied path, +// then guards the anchor for the policy's mode. This is the boot entry point. +func LoadSignedTrustPolicyTrusted(policyPath string) (p *TrustPolicy, rootFp string, mounted bool, err error) { + rootFp, mounted, err = ResolveTrustedRootFingerprint() + if err != nil { + return nil, "", false, fmt.Errorf("resolve trusted root: %w", err) + } + p, err = loadSignedTrustPolicyWithRoot(policyPath, rootFp) + if err != nil { + return nil, rootFp, mounted, err + } + if gerr := GuardRootAnchor(p, rootFp, mounted); gerr != nil { + return nil, rootFp, mounted, gerr + } + return p, rootFp, mounted, nil +} + +func loadSignedTrustPolicyWithRoot(path, expectedRootFp string) (*TrustPolicy, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read signed trust policy %q: %w", path, err) + } + p, err := verifyAndPinPolicy(b, expectedRootFp) + if err != nil { + return nil, fmt.Errorf("signed trust policy %q: %w", path, err) + } + return p, nil +} diff --git a/pkg/signature/bundle/manifest.go b/pkg/signature/bundle/manifest.go new file mode 100644 index 0000000000..5bf31d1686 --- /dev/null +++ b/pkg/signature/bundle/manifest.go @@ -0,0 +1,269 @@ +//go:build experimental_signing + +// Package bundle implements signed ContainerProfile fragment bundles: multiple +// independently signed partial ContainerProfiles ("fragments"), authored by +// different parties, verified per-leaf against a trust policy, deterministically +// assembled into one effective ContainerProfile, and bound to the exact set of +// admissible leaves via a Merkle leaf-tree. node-agent then re-signs the +// composite internally so the R1016 tamper path protects it. +// +// This is the multi-file overlay layer on top of the flat single-signed-CP +// support in pkg/signature + containerprofilecache/tamper_alert.go. +package bundle + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "reflect" + + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +// FragmentClass declares what a fragment is allowed to contribute and who may +// sign it. Precedence (base < admission < overlay) also fixes assembly order so +// higher-precedence fragments win scalar conflicts deterministically. +type FragmentClass string + +const ( + ClassBase FragmentClass = "base" + ClassAdmission FragmentClass = "admission" + ClassOverlay FragmentClass = "overlay" +) + +// classPrecedence orders fragments in the composite. Unknown classes sort last. +func classPrecedence(c FragmentClass) int { + switch c { + case ClassBase: + return 0 + case ClassAdmission: + return 1 + case ClassOverlay: + return 2 + default: + return 3 + } +} + +// Labels a fragment ContainerProfile carries. +const ( + // LabelBundle groups fragments that assemble into one effective profile. + LabelBundle = "signature.kubescape.io/bundle" + // LabelFragmentClass declares the fragment's class (base|admission|overlay). + LabelFragmentClass = "signature.kubescape.io/fragment-class" + // LabelVersion is the fragment's monotonic version within its + // (bundle, class, name) slot. It is part of the signed labels, so it cannot + // be forged. A vendor increments it when shipping a replacement; node-agent + // refuses a fragment whose version is below the highest already accepted for + // that slot, which blocks replay of an older validly-signed fragment. Absent + // is treated as version 0 (back-compatible with unversioned fragments). + LabelVersion = "signature.kubescape.io/version" +) + +// ManifestAnnotation carries the serialized BundleManifest on the composite. +const ManifestAnnotation = "signature.kubescape.io/bundle-manifest" + +// ClassPolicy is the admissibility rule for one fragment class: which signer +// identities may author it and which spec paths it may set. +type ClassPolicy struct { + Signers []string `json:"signers"` + AllowedSpecPaths []string `json:"allowedSpecPaths"` +} + +func (p ClassPolicy) allowsSigner(id string) bool { + for _, s := range p.Signers { + if s == id { + return true + } + } + return false +} + +func (p ClassPolicy) allowsPath(path string) bool { + for _, s := range p.AllowedSpecPaths { + if s == path || s == "*" { + return true + } + } + return false +} + +// TrustPolicy maps each fragment class to its admissibility rule. +// +// Classes governs signed ContainerProfile fragments; RuleClasses governs signed +// Rules fragments (see rules.go). The two are independent: a policy with no +// ruleClasses leaves rule signing DISABLED, which is the back-compatible +// default for deployments that only use profile bundles. +type TrustPolicy struct { + Mode SigningMode `json:"mode,omitempty"` + Classes map[FragmentClass]ClassPolicy `json:"classes"` + RuleClasses map[RuleClass]RuleClassPolicy `json:"ruleClasses,omitempty"` + BindingClasses map[FragmentClass]BindingClassPolicy `json:"bindingClasses,omitempty"` + // PolicyVersion is a monotonic counter inside the signed content: a reload + // carrying a lower version than the policy in force is a rollback replay + // and is refused. Absent means 0 (back-compatible). + PolicyVersion int64 `json:"policyVersion,omitempty"` +} + +// SigningMode is the single global signing state. OFF is the absence of a +// mounted policy; a mounted policy is ON and is at least alerting. enforce +// refuses unsigned/unverifiable artifacts; alert loads them but logs loudly. +type SigningMode string + +const ( + ModeAlert SigningMode = "alert" + ModeEnforce SigningMode = "enforce" +) + +// EffectiveMode resolves the mode a mounted policy runs in. A mounted policy +// with no explicit mode defaults to alert, never to silent — enforce must be +// asked for, so an operator cannot brick a cluster by omission. +func (p TrustPolicy) EffectiveMode() SigningMode { + if p.Mode == ModeEnforce { + return ModeEnforce + } + return ModeAlert +} + +func (p TrustPolicy) Enforcing() bool { return p.EffectiveMode() == ModeEnforce } + +// RuleSigningEnabled reports whether the policy governs signed Rules fragments. +// Absent ruleClasses means rule signing is off and the rules watcher keeps its +// pre-signing behaviour. +func (p TrustPolicy) RuleSigningEnabled() bool { return len(p.RuleClasses) > 0 } + +func (p TrustPolicy) BindingSigningEnabled() bool { return len(p.BindingClasses) > 0 } + +// LeafRef is one verified fragment's entry in the bundle manifest. +type LeafRef struct { + Class FragmentClass `json:"class"` + Signer string `json:"signer"` + Name string `json:"name"` + ContentDigest string `json:"contentDigest"` // hex sha256 of the signed content + Version int64 `json:"version"` // monotonic version from the signed labels +} + +// BundleManifest is committed to by the composite: it lists the admissible +// leaves and the Merkle root binding them, so any verifier can independently +// re-verify each leaf and confirm the composite derives from exactly this set. +type BundleManifest struct { + Root string `json:"root"` // hex Merkle root over the leaves in manifest order + Leaves []LeafRef `json:"leaves"` +} + +// signerIdentity returns a stable identity for whoever signed the fragment: a +// SHA-256 fingerprint of the signing public key extracted from the embedded +// certificate. +// +// SECURITY: the identity is derived ONLY from the public key that the signature +// was actually verified against. The OIDC identity/issuer annotations are NOT +// used — verification runs allow-untrusted (no Fulcio chain / SAN attestation), +// so those annotations are attacker-controlled plaintext and trusting them would +// let anyone spoof a trusted signer by stamping two strings. Binding trust to the +// verified public key is the only sound choice in this trust model; keyless/OIDC +// signers would require strict (Fulcio-attested) verification, which this path +// deliberately does not do. +func signerIdentity(sig *signature.Signature) (string, error) { + if sig == nil { + return "", fmt.Errorf("nil signature") + } + if len(sig.Certificate) == 0 { + return "", fmt.Errorf("signature has no certificate to fingerprint") + } + block, _ := pem.Decode(sig.Certificate) + if block == nil { + return "", fmt.Errorf("failed to PEM-decode certificate") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse certificate: %w", err) + } + pub, err := x509.MarshalPKIXPublicKey(cert.PublicKey) + if err != nil { + return "", fmt.Errorf("failed to marshal public key: %w", err) + } + sum := sha256.Sum256(pub) + return "key:" + hex.EncodeToString(sum[:]), nil +} + +// SignerID returns the trust-policy signer identity of a signed fragment — the +// signing public-key fingerprint (key:). Exposed for +// authoring trust policies (which list allowed signer IDs per class) and tests. +func SignerID(cp *v1beta1.ContainerProfile) (string, error) { + sig, err := signature.GetObjectSignature(profiles.NewContainerProfileAdapter(cp)) + if err != nil { + return "", err + } + return signerIdentity(sig) +} + +// contentDigest returns the hex SHA-256 of the fragment's canonical signed +// content — the same content the signature covers (metadata{name,namespace, +// labels} + spec, annotations excluded), so a leaf's digest is exactly what its +// author signed. +func contentDigest(cp *v1beta1.ContainerProfile) (string, error) { + adapter := profiles.NewContainerProfileAdapter(cp) + h, err := signature.HashSignableContent(adapter) + if err != nil { + return "", err + } + return h, nil +} + +// setSpecPaths returns the sorted set of spec paths a fragment actually sets +// (non-zero fields), used to enforce class confinement (an admission fragment +// may only set ingress/egress, etc.). +func setSpecPaths(spec *v1beta1.ContainerProfileSpec) []string { + var paths []string + if len(spec.Architectures) > 0 { + paths = append(paths, "architectures") + } + if len(spec.Capabilities) > 0 { + paths = append(paths, "capabilities") + } + if len(spec.Execs) > 0 { + paths = append(paths, "execs") + } + if len(spec.Opens) > 0 { + paths = append(paths, "opens") + } + if len(spec.Syscalls) > 0 { + paths = append(paths, "syscalls") + } + if !reflect.DeepEqual(spec.SeccompProfile, v1beta1.SingleSeccompProfile{}) { + // Any non-zero seccomp content confines to the class — not just + // DefaultAction (Syscalls/Architectures/ListenerPath/etc. also count), + // else an admission fragment could smuggle seccomp settings past the + // class check by leaving DefaultAction empty. + paths = append(paths, "seccompProfile") + } + if len(spec.Endpoints) > 0 { + paths = append(paths, "endpoints") + } + if spec.ImageID != "" { + paths = append(paths, "imageID") + } + if spec.ImageTag != "" { + paths = append(paths, "imageTag") + } + if len(spec.PolicyByRuleId) > 0 { + paths = append(paths, "rulePolicies") + } + if len(spec.IdentifiedCallStacks) > 0 { + paths = append(paths, "identifiedCallStacks") + } + if len(spec.MatchLabels) > 0 || len(spec.MatchExpressions) > 0 { + paths = append(paths, "labelSelector") + } + if len(spec.Ingress) > 0 { + paths = append(paths, "ingress") + } + if len(spec.Egress) > 0 { + paths = append(paths, "egress") + } + return paths +} diff --git a/pkg/signature/bundle/merkle.go b/pkg/signature/bundle/merkle.go new file mode 100644 index 0000000000..bfac523652 --- /dev/null +++ b/pkg/signature/bundle/merkle.go @@ -0,0 +1,63 @@ +//go:build experimental_signing + +package bundle + +import ( + "crypto/sha256" + "encoding/hex" +) + +// leafHash binds class‖signer‖contentDigest into a domain-separated leaf +// pre-image so a leaf commits to WHO signed WHAT for WHICH class — swapping any +// of the three changes the leaf and therefore the root. +func leafHash(l LeafRef) [32]byte { + h := sha256.New() + h.Write([]byte("kubescape-bundle-leaf\x00")) + h.Write([]byte(l.Class)) + h.Write([]byte{0}) + h.Write([]byte(l.Signer)) + h.Write([]byte{0}) + h.Write([]byte(l.ContentDigest)) + var out [32]byte + copy(out[:], h.Sum(nil)) + return out +} + +// merkleRoot computes a binary Merkle root over the leaf hashes. Internal nodes +// are domain-separated (0x01 prefix) to prevent second-preimage attacks; an odd +// node at any level is promoted unchanged. Order matters: the caller passes +// leaves in canonical (sorted) fragment order. +func merkleRoot(leaves [][32]byte) [32]byte { + if len(leaves) == 0 { + return sha256.Sum256([]byte("kubescape-bundle-empty")) + } + level := leaves + for len(level) > 1 { + next := make([][32]byte, 0, (len(level)+1)/2) + for i := 0; i < len(level); i += 2 { + if i+1 == len(level) { + next = append(next, level[i]) // promote odd node + continue + } + h := sha256.New() + h.Write([]byte{0x01}) + h.Write(level[i][:]) + h.Write(level[i+1][:]) + var n [32]byte + copy(n[:], h.Sum(nil)) + next = append(next, n) + } + level = next + } + return level[0] +} + +// rootFromLeaves computes the hex Merkle root over LeafRefs in the given order. +func rootFromLeaves(leaves []LeafRef) string { + hashes := make([][32]byte, len(leaves)) + for i, l := range leaves { + hashes[i] = leafHash(l) + } + root := merkleRoot(hashes) + return hex.EncodeToString(root[:]) +} diff --git a/pkg/signature/bundle/merkle_differential_test.go b/pkg/signature/bundle/merkle_differential_test.go new file mode 100644 index 0000000000..7a31711ac4 --- /dev/null +++ b/pkg/signature/bundle/merkle_differential_test.go @@ -0,0 +1,196 @@ +//go:build experimental_signing + +package bundle + +import ( + "crypto/sha256" + "encoding/hex" + mathrand "math/rand" + "reflect" + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +func refLeafHash(l LeafRef) [32]byte { + h := sha256.New() + h.Write([]byte("kubescape-bundle-leaf\x00")) + h.Write([]byte(l.Class)) + h.Write([]byte{0}) + h.Write([]byte(l.Signer)) + h.Write([]byte{0}) + h.Write([]byte(l.ContentDigest)) + var out [32]byte + copy(out[:], h.Sum(nil)) + return out +} + +func refMerkleRoot(leaves [][32]byte) [32]byte { + if len(leaves) == 0 { + return sha256.Sum256([]byte("kubescape-bundle-empty")) + } + level := leaves + for len(level) > 1 { + var next [][32]byte + for i := 0; i < len(level); i += 2 { + if i+1 == len(level) { + next = append(next, level[i]) + continue + } + h := sha256.New() + h.Write([]byte{0x01}) + h.Write(level[i][:]) + h.Write(level[i+1][:]) + var n [32]byte + copy(n[:], h.Sum(nil)) + next = append(next, n) + } + level = next + } + return level[0] +} + +func refRoot(leaves []LeafRef) string { + hashes := make([][32]byte, len(leaves)) + for i, l := range leaves { + hashes[i] = refLeafHash(l) + } + root := refMerkleRoot(hashes) + return hex.EncodeToString(root[:]) +} + +func randomLeaf(rng *mathrand.Rand) LeafRef { + classes := []FragmentClass{ClassBase, ClassAdmission, ClassOverlay} + digest := make([]byte, 32) + rng.Read(digest) + return LeafRef{ + Class: classes[rng.Intn(len(classes))], + Signer: "key:" + hex.EncodeToString([]byte{byte(rng.Intn(256)), byte(rng.Intn(256))}), + Name: "frag-" + hex.EncodeToString([]byte{byte(rng.Intn(256))}), + ContentDigest: hex.EncodeToString(digest), + } +} + +func randomLeaves(rng *mathrand.Rand, n int) []LeafRef { + out := make([]LeafRef, n) + for i := range out { + out[i] = randomLeaf(rng) + } + return out +} + +func TestMerkleDifferential_RefRootEqualsProduction(t *testing.T) { + rng := mathrand.New(mathrand.NewSource(0x0FFEE)) + const iterations = 400 + for i := 0; i < iterations; i++ { + n := rng.Intn(9) + leaves := randomLeaves(rng, n) + got := rootFromLeaves(leaves) + want := refRoot(leaves) + if got != want { + t.Fatalf("iteration %d (n=%d): production rootFromLeaves=%s, independent refRoot=%s", i, n, got, want) + } + } + if refRoot(nil) != rootFromLeaves(nil) { + t.Fatalf("empty-leaf-set root diverged: ref=%s prod=%s", refRoot(nil), rootFromLeaves(nil)) + } + t.Logf("independent sha256 domain-separated reference matched production rootFromLeaves over %d random leaf sets (0..8 leaves) plus the empty set", iterations) +} + +func TestMerkleDifferential_LeafDigestChangeChangesRoot(t *testing.T) { + rng := mathrand.New(mathrand.NewSource(0x1234)) + const iterations = 200 + for i := 0; i < iterations; i++ { + leaves := randomLeaves(rng, rng.Intn(6)+1) + before := rootFromLeaves(leaves) + idx := rng.Intn(len(leaves)) + mutated := make([]LeafRef, len(leaves)) + copy(mutated, leaves) + orig := mutated[idx].ContentDigest + mutated[idx].ContentDigest = orig + "0" + after := rootFromLeaves(mutated) + if before == after { + t.Fatalf("iteration %d: changing leaf %d content digest did not change the root (%s)", i, idx, before) + } + if after != refRoot(mutated) { + t.Fatalf("iteration %d: production and reference disagree after mutation", i) + } + } + t.Logf("changing any single leaf content digest changed the Merkle root across %d random sets", iterations) +} + +func TestMerkleDifferential_AddOrRemoveLeafChangesRoot(t *testing.T) { + rng := mathrand.New(mathrand.NewSource(0x9E3779B9)) + const iterations = 200 + for i := 0; i < iterations; i++ { + leaves := randomLeaves(rng, rng.Intn(5)+1) + before := rootFromLeaves(leaves) + + added := append(append([]LeafRef{}, leaves...), randomLeaf(rng)) + if rootFromLeaves(added) == before { + t.Fatalf("iteration %d: adding a leaf did not change the root", i) + } + + idx := rng.Intn(len(leaves)) + removed := append(append([]LeafRef{}, leaves[:idx]...), leaves[idx+1:]...) + if rootFromLeaves(removed) == before { + t.Fatalf("iteration %d: removing leaf %d did not change the root", i, idx) + } + } + t.Logf("adding or removing a leaf changed the Merkle root across %d random sets", iterations) +} + +func randomFragmentSpec(rng *mathrand.Rand, class FragmentClass) v1beta1.ContainerProfileSpec { + switch class { + case ClassAdmission: + port := int32(6379) + return v1beta1.ContainerProfileSpec{Ingress: []v1beta1.NetworkNeighbor{{ + Identifier: "n-" + hex.EncodeToString([]byte{byte(rng.Intn(256))}), + Type: "internal", + Ports: []v1beta1.NetworkPort{{Name: "TCP-6379", Port: &port, Protocol: "TCP"}}, + }}} + default: + return v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/tool-" + hex.EncodeToString([]byte{byte(rng.Intn(256))})}}, + } + } +} + +func TestMerkleDifferential_AssemblyOrderIndependent(t *testing.T) { + rng := mathrand.New(mathrand.NewSource(0xABCDEF)) + vendor, operator := genKey(t), genKey(t) + + const iterations = 20 + for it := 0; it < iterations; it++ { + base := fragment(t, "redis", "base", randomFragmentSpec(rng, ClassBase), vendor) + adm := fragment(t, "redis-ingress", "admission", randomFragmentSpec(rng, ClassAdmission), operator) + ovl := fragment(t, "redis-overlay", "base", randomFragmentSpec(rng, ClassBase), vendor) + + policy := TrustPolicy{Classes: map[FragmentClass]ClassPolicy{ + ClassBase: {Signers: []string{signerIDOf(t, base)}, AllowedSpecPaths: []string{"execs", "opens", "architectures"}}, + ClassAdmission: {Signers: []string{signerIDOf(t, adm)}, AllowedSpecPaths: []string{"ingress", "egress"}}, + }} + + frags := []*v1beta1.ContainerProfile{base, adm, ovl} + refComposite, refManifest, err := AssembleAndVerify("redis", "redis", frags, policy) + if err != nil { + t.Fatalf("iteration %d: baseline assembly: %v", it, err) + } + + perms := [][]int{{0, 1, 2}, {2, 1, 0}, {1, 2, 0}, {2, 0, 1}, {0, 2, 1}, {1, 0, 2}} + for _, p := range perms { + shuffled := []*v1beta1.ContainerProfile{frags[p[0]], frags[p[1]], frags[p[2]]} + composite, manifest, err := AssembleAndVerify("redis", "redis", shuffled, policy) + if err != nil { + t.Fatalf("iteration %d perm %v: %v", it, p, err) + } + if manifest.Root != refManifest.Root { + t.Fatalf("iteration %d perm %v: Merkle root depends on input order: %s vs %s", it, p, manifest.Root, refManifest.Root) + } + if !reflect.DeepEqual(composite.Spec, refComposite.Spec) { + t.Fatalf("iteration %d perm %v: composite spec depends on input order", it, p) + } + } + } + t.Logf("across %d random fragment sets, every input permutation produced an identical composite spec AND Merkle root", iterations) +} diff --git a/pkg/signature/bundle/mode_test.go b/pkg/signature/bundle/mode_test.go new file mode 100644 index 0000000000..fde5c96f54 --- /dev/null +++ b/pkg/signature/bundle/mode_test.go @@ -0,0 +1,83 @@ +//go:build experimental_signing + +package bundle + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// The demo root fingerprint constant must equal the fingerprint of the key +// DefaultRootPublicKeyPEM actually embeds, or enforce-mode's demo-key refusal +// silently protects the wrong key. +func TestDemoRootFingerprintMatchesDefault(t *testing.T) { + fp, err := rootFingerprint() + require.NoError(t, err) + require.Equal(t, DemoRootFingerprint, fp, + "DemoRootFingerprint is out of sync with DefaultRootPublicKeyPEM") + require.True(t, IsDemoRoot(fp)) + require.False(t, IsDemoRoot("key:0000000000000000000000000000000000000000000000000000000000000000")) +} + +func TestEffectiveMode(t *testing.T) { + require.Equal(t, ModeEnforce, TrustPolicy{Mode: ModeEnforce}.EffectiveMode()) + require.True(t, TrustPolicy{Mode: ModeEnforce}.Enforcing()) + // A mounted policy is never silent: absent or unknown mode -> alert, not off. + require.Equal(t, ModeAlert, TrustPolicy{}.EffectiveMode()) + require.Equal(t, ModeAlert, TrustPolicy{Mode: "wat"}.EffectiveMode()) + require.False(t, TrustPolicy{}.Enforcing()) +} + +func TestGuardRootAnchor(t *testing.T) { + demo := DemoRootFingerprint + real := "key:1111111111111111111111111111111111111111111111111111111111111111" + + // enforce + demo root + not mounted => refused. + require.Error(t, GuardRootAnchor(&TrustPolicy{Mode: ModeEnforce}, demo, false)) + // enforce + demo root + mounted override => allowed (operator vouched for it). + require.NoError(t, GuardRootAnchor(&TrustPolicy{Mode: ModeEnforce}, demo, true)) + // enforce + real root => allowed. + require.NoError(t, GuardRootAnchor(&TrustPolicy{Mode: ModeEnforce}, real, false)) + // alert mode never refuses on the anchor (it only warns at the call site). + require.NoError(t, GuardRootAnchor(&TrustPolicy{Mode: ModeAlert}, demo, false)) + require.NoError(t, GuardRootAnchor(&TrustPolicy{}, demo, false)) +} + +func TestResolveTrustedRootIgnoresConfigPath(t *testing.T) { + // With no fixed mount present, the anchor is the compiled demo root — and it + // takes no argument, so a config value can never influence it. + fp, mounted, err := ResolveTrustedRootFingerprint() + require.NoError(t, err) + require.False(t, mounted) + require.Equal(t, DemoRootFingerprint, fp) +} + +func TestModeRoundTripsJSON(t *testing.T) { + in := TrustPolicy{Mode: ModeEnforce, Classes: map[FragmentClass]ClassPolicy{}} + b, err := json.Marshal(in) + require.NoError(t, err) + require.Contains(t, string(b), `"mode":"enforce"`) + var out TrustPolicy + require.NoError(t, json.Unmarshal(b, &out)) + require.Equal(t, ModeEnforce, out.Mode) + + // A policy without a mode omits the field and reads back as alert. + b2, _ := json.Marshal(TrustPolicy{Classes: map[FragmentClass]ClassPolicy{}}) + require.NotContains(t, string(b2), `"mode"`) +} + +func TestRuleAdmissionUnauthenticated(t *testing.T) { + demo := DemoRootFingerprint + real := "key:1111111111111111111111111111111111111111111111111111111111111111" + withRules := &TrustPolicy{RuleClasses: map[RuleClass]RuleClassPolicy{ + RuleClassBase: {Signers: []string{real}, AllowedRuleIDs: []string{"*"}}, + }} + withoutRules := &TrustPolicy{} + + require.True(t, RuleAdmissionUnauthenticated(withRules, demo)) + require.False(t, RuleAdmissionUnauthenticated(withRules, real)) + require.False(t, RuleAdmissionUnauthenticated(withoutRules, demo)) + require.False(t, RuleAdmissionUnauthenticated(nil, demo)) +} diff --git a/pkg/signature/bundle/pair_fixture_test.go b/pkg/signature/bundle/pair_fixture_test.go new file mode 100644 index 0000000000..c35b908775 --- /dev/null +++ b/pkg/signature/bundle/pair_fixture_test.go @@ -0,0 +1,47 @@ +//go:build experimental_signing + +package bundle + +import ( + "encoding/json" + "os" + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +func TestBundle37FragmentsGrantExecPairs(t *testing.T) { + root := "../../../tests/resources/signed/" + var frags []*v1beta1.ContainerProfile + for _, f := range []string{"fragments/bundle37-base-signed.yaml", "fragments/bundle37-admission-signed.yaml", "fragments/bundle37-overlay-signed.yaml"} { + b, err := os.ReadFile(root + f) + require.NoError(t, err) + var cp v1beta1.ContainerProfile + require.NoError(t, yaml.Unmarshal(b, &cp)) + frags = append(frags, &cp) + } + pb, err := os.ReadFile(root + "trust-policy.profiles-only.signed.json") + require.NoError(t, err) + var wrapped struct { + Policy TrustPolicy `json:"policy"` + } + require.NoError(t, json.Unmarshal(pb, &wrapped)) + + composite, manifest, dropped, err := AssembleAndVerifyPartial("bundle37", "", frags, wrapped.Policy) + require.NoError(t, err) + require.NotNil(t, composite) + require.Empty(t, dropped, "every committed fragment must verify") + require.NotNil(t, manifest) + + granted := map[string][]string{} + for _, e := range composite.Spec.Execs { + granted[e.Path] = append(granted[e.Path], e.Args...) + } + assert.Contains(t, granted, "/bin/busybox", "the multi-call binary is granted") + assert.Contains(t, granted["/bin/busybox"], "/usr/bin/id", "granted under the overlay's name") + assert.Contains(t, granted["/bin/busybox"], "/bin/sleep", "and the base's own command") + assert.NotContains(t, granted["/bin/busybox"], "/bin/ls", "the sentinel applet is granted by no fragment") +} diff --git a/pkg/signature/bundle/policy.go b/pkg/signature/bundle/policy.go new file mode 100644 index 0000000000..8b5f22aaac --- /dev/null +++ b/pkg/signature/bundle/policy.go @@ -0,0 +1,151 @@ +//go:build experimental_signing + +package bundle + +import ( + "crypto/ecdsa" + "encoding/json" + "errors" + "fmt" + + "github.com/kubescape/node-agent/pkg/signature" +) + +// signedTrustPolicyArtifact is the on-disk shape of a root-signed trust policy +// (typically a mounted ConfigMap value). The signature in Annotations is +// computed over the CANONICAL Policy content — not the raw file bytes — so it +// survives ConfigMap string round-trips and JSON re-serialisation. +// +// {"policy": {"classes": {...}}, +// "annotations": {"signature.kubescape.io/signature": "...", +// "signature.kubescape.io/certificate": "...", ...}} +type signedTrustPolicyArtifact struct { + Policy *TrustPolicy `json:"policy"` + Annotations map[string]string `json:"annotations"` +} + +// policySignable adapts a TrustPolicy to signature.SignableObject so the exact +// same crypto envelope (SignObject / VerifyObject) that protects CP fragments +// also protects the trust policy. +// +// GetContent returns the *TrustPolicy value (NOT raw file bytes): Sign/Verify +// hash json.Marshal(GetContent()) through utils.CanonicalHash, which is +// order-independent. Because both signing and reloading marshal the SAME +// *TrustPolicy struct type, the canonical hash is identical across a +// sign → serialise → ConfigMap round-trip → reload cycle. Signing raw bytes +// would break the moment whitespace or key order changed. +type policySignable struct { + policy *TrustPolicy + annotations map[string]string +} + +func newPolicySignable(policy *TrustPolicy, annotations map[string]string) *policySignable { + if annotations == nil { + annotations = make(map[string]string) + } + return &policySignable{policy: policy, annotations: annotations} +} + +func (p *policySignable) GetAnnotations() map[string]string { return p.annotations } + +func (p *policySignable) SetAnnotations(a map[string]string) { + if a == nil { + a = make(map[string]string) + } + p.annotations = a +} + +func (p *policySignable) GetUID() string { return "" } +func (p *policySignable) GetNamespace() string { return "" } +func (p *policySignable) GetName() string { return "trust-policy" } +func (p *policySignable) GetContent() interface{} { return p.policy } +func (p *policySignable) GetUpdatedObject() interface{} { + return signedTrustPolicyArtifact{Policy: p.policy, Annotations: p.annotations} +} + +// SignTrustPolicy signs a TrustPolicy with the offline ROOT private key and +// returns the serialised {policy, annotations} artifact. The signature covers +// the canonical policy content; the resulting artifact is what node-agent loads +// via LoadSignedTrustPolicy and pins to the embedded root fingerprint. +func SignTrustPolicy(policy *TrustPolicy, rootKey *ecdsa.PrivateKey) ([]byte, error) { + if policy == nil { + return nil, fmt.Errorf("nil trust policy") + } + if rootKey == nil { + return nil, fmt.Errorf("nil root key") + } + it := newPolicySignable(policy, nil) + if err := signature.SignObject(it, signature.WithPrivateKey(rootKey)); err != nil { + return nil, fmt.Errorf("sign trust policy: %w", err) + } + artifact := signedTrustPolicyArtifact{Policy: policy, Annotations: it.GetAnnotations()} + out, err := json.MarshalIndent(artifact, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal signed trust policy: %w", err) + } + return out, nil +} + +// parseSignedTrustPolicy verifies and returns a root-signed trust policy from +// its serialised {policy, annotations} bytes. It fails closed on every failure: +// +// 1. The signature must verify over the canonical policy content against the +// certificate embedded in the annotations (VerifyObjectAllowUntrusted). +// 2. The signer's public-key fingerprint MUST equal the embedded root +// fingerprint — the pin that stops an attacker re-signing an edited policy +// with their own key. +// 3. The policy must declare at least one class. +func parseSignedTrustPolicy(data []byte) (*TrustPolicy, error) { + rootFp, err := rootFingerprint() + if err != nil { + return nil, fmt.Errorf("compute embedded root fingerprint: %w", err) + } + return verifyAndPinPolicy(data, rootFp) +} + +// verifyAndPinPolicy is the crypto core: it verifies the artifact's signature +// over the canonical policy content and pins the signer to expectedRootFp. The +// production path (parseSignedTrustPolicy) always passes the embedded root +// fingerprint; the split lets tests exercise the exact same logic with their own +// generated keypair, so NO private key needs to exist on disk. Fails closed on +// every path. +func verifyAndPinPolicy(data []byte, expectedRootFp string) (*TrustPolicy, error) { + var artifact signedTrustPolicyArtifact + if err := json.Unmarshal(data, &artifact); err != nil { + return nil, fmt.Errorf("parse signed trust policy: %w", err) + } + if artifact.Policy == nil { + return nil, fmt.Errorf("signed trust policy has no policy") + } + if len(artifact.Annotations) == 0 { + return nil, fmt.Errorf("signed trust policy has no signature annotations") + } + + it := newPolicySignable(artifact.Policy, artifact.Annotations) + + // (1) The signature must verify over the canonical policy content. Any + // mismatch (including a tampered policy object) fails here. + if err := signature.VerifyObjectAllowUntrusted(it); err != nil { + return nil, fmt.Errorf("trust policy signature verification failed: %w", err) + } + + // (2) Pin the signer to the (embedded) root key. + sig, err := signature.GetObjectSignature(it) + if err != nil { + return nil, fmt.Errorf("trust policy signature verification failed: %w", err) + } + signer, err := signerIdentity(sig) + if err != nil { + return nil, fmt.Errorf("trust policy signature verification failed: %w", err) + } + if signer != expectedRootFp { + return nil, errors.New("trust policy not signed by the embedded root key") + } + + // (3) Same minimum-content check as LoadTrustPolicy. + if len(artifact.Policy.Classes) == 0 { + return nil, fmt.Errorf("trust policy has no classes") + } + + return artifact.Policy, nil +} diff --git a/pkg/signature/bundle/policy_signing_test.go b/pkg/signature/bundle/policy_signing_test.go new file mode 100644 index 0000000000..634bc2fe83 --- /dev/null +++ b/pkg/signature/bundle/policy_signing_test.go @@ -0,0 +1,267 @@ +//go:build experimental_signing + +package bundle + +import ( + "crypto/ecdsa" + "encoding/json" + mathrand "math/rand" + "os" + "reflect" + "strings" + "testing" + + "github.com/kubescape/node-agent/pkg/signature" +) + +func demoPolicy() *TrustPolicy { + return &TrustPolicy{ + Classes: map[FragmentClass]ClassPolicy{ + ClassBase: { + Signers: []string{"key:vendorfingerprint"}, + AllowedSpecPaths: []string{"execs", "opens"}, + }, + ClassAdmission: { + Signers: []string{"key:operatorfingerprint"}, + AllowedSpecPaths: []string{"ingress", "egress"}, + }, + }, + } +} + +// signPolicyWithFingerprint signs a policy with key and returns the artifact +// bytes plus the signer's fingerprint. Tests pin against this fingerprint, so +// they never need the embedded root's private key on disk — the verify+pin logic +// (verifyAndPinPolicy) is identical to the production path, only the trusted +// fingerprint is supplied by the test instead of rootFingerprint(). +func signPolicyWithFingerprint(t *testing.T, policy *TrustPolicy, key *ecdsa.PrivateKey) (signed []byte, fingerprint string) { + t.Helper() + signed, err := SignTrustPolicy(policy, key) + if err != nil { + t.Fatalf("SignTrustPolicy: %v", err) + } + var artifact signedTrustPolicyArtifact + if err := json.Unmarshal(signed, &artifact); err != nil { + t.Fatalf("unmarshal artifact: %v", err) + } + it := newPolicySignable(artifact.Policy, artifact.Annotations) + sig, err := signature.GetObjectSignature(it) + if err != nil { + t.Fatalf("get signature: %v", err) + } + fp, err := signerIdentity(sig) + if err != nil { + t.Fatalf("signer identity: %v", err) + } + return signed, fp +} + +// A policy signed by a key and pinned to that key's fingerprint round-trips. +func TestSignedPolicy_RoundTrip(t *testing.T) { + signed, fp := signPolicyWithFingerprint(t, demoPolicy(), genKey(t)) + + got, err := verifyAndPinPolicy(signed, fp) + if err != nil { + t.Fatalf("verifyAndPinPolicy: %v", err) + } + if len(got.Classes) != 2 { + t.Fatalf("expected 2 classes, got %d", len(got.Classes)) + } + if _, ok := got.Classes[ClassBase]; !ok { + t.Fatal("missing base class") + } + if _, ok := got.Classes[ClassAdmission]; !ok { + t.Fatal("missing admission class") + } +} + +// The file-path loader (used by main.go) round-trips too. +func TestSignedPolicy_RoundTripFile(t *testing.T) { + signed, fp := signPolicyWithFingerprint(t, demoPolicy(), genKey(t)) + path := t.TempDir() + "/trust-policy.signed.json" + if err := os.WriteFile(path, signed, 0644); err != nil { + t.Fatalf("write: %v", err) + } + got, err := loadSignedTrustPolicyWithRoot(path, fp) + if err != nil { + t.Fatalf("loadSignedTrustPolicyWithRoot: %v", err) + } + if len(got.Classes) != 2 { + t.Fatalf("expected 2 classes, got %d", len(got.Classes)) + } +} + +// A policy whose signature is valid but was made by a key OTHER than the pinned +// root is rejected — the pin, not just signature validity, is what protects the +// policy from being re-signed by an attacker. +func TestSignedPolicy_WrongSignerRejected(t *testing.T) { + signed, _ := signPolicyWithFingerprint(t, demoPolicy(), genKey(t)) + // Pin to a DIFFERENT key's fingerprint. + _, otherFp := signPolicyWithFingerprint(t, demoPolicy(), genKey(t)) + + _, err := verifyAndPinPolicy(signed, otherFp) + if err == nil { + t.Fatal("expected rejection: policy not signed by the pinned root") + } + if !strings.Contains(err.Error(), "not signed by the embedded root key") { + t.Fatalf("expected root-pin error, got: %v", err) + } +} + +// Mutating the policy object in the artifact after signing (adding a rogue +// class) is rejected: the signature no longer matches the canonical content. +func TestSignedPolicy_TamperedRejected(t *testing.T) { + signed, fp := signPolicyWithFingerprint(t, demoPolicy(), genKey(t)) + + var artifact signedTrustPolicyArtifact + if err := json.Unmarshal(signed, &artifact); err != nil { + t.Fatalf("unmarshal: %v", err) + } + artifact.Policy.Classes[ClassAdmission] = ClassPolicy{ + Signers: []string{"key:attackerfingerprint"}, + AllowedSpecPaths: []string{"execs", "opens", "ingress", "egress"}, + } + tampered, err := json.Marshal(artifact) + if err != nil { + t.Fatalf("marshal tampered: %v", err) + } + + _, err = verifyAndPinPolicy(tampered, fp) + if err == nil { + t.Fatal("expected rejection for tampered policy") + } + if !strings.Contains(err.Error(), "signature verification failed") { + t.Fatalf("expected signature-verification error, got: %v", err) + } +} + +// An unsigned / annotation-less artifact is rejected (fail closed). +func TestSignedPolicy_MissingSignatureRejected(t *testing.T) { + artifact := signedTrustPolicyArtifact{Policy: demoPolicy(), Annotations: nil} + data, err := json.Marshal(artifact) + if err != nil { + t.Fatalf("marshal: %v", err) + } + _, err = verifyAndPinPolicy(data, "key:whatever") + if err == nil { + t.Fatal("expected rejection for missing signature annotations") + } +} + +func randomSigners(rng *mathrand.Rand) []string { + n := rng.Intn(3) + 1 + out := make([]string, n) + for i := range out { + out[i] = "key:" + string(rune('a'+rng.Intn(26))) + string(rune('0'+rng.Intn(10))) + } + return out +} + +func randomSubset(rng *mathrand.Rand, pool []string) []string { + var out []string + for _, p := range pool { + if rng.Intn(2) == 0 { + out = append(out, p) + } + } + if len(out) == 0 { + out = append(out, pool[rng.Intn(len(pool))]) + } + return out +} + +func randomTrustPolicy(rng *mathrand.Rand) *TrustPolicy { + specPaths := []string{"execs", "opens", "ingress", "egress", "capabilities", "syscalls", "architectures"} + ruleIDs := []string{"R0001", "R0002", "R1000", "*"} + + classPool := []FragmentClass{ClassBase, ClassAdmission, ClassOverlay} + classes := map[FragmentClass]ClassPolicy{} + for _, c := range classPool { + if rng.Intn(2) == 0 { + classes[c] = ClassPolicy{Signers: randomSigners(rng), AllowedSpecPaths: randomSubset(rng, specPaths)} + } + } + if len(classes) == 0 { + classes[ClassBase] = ClassPolicy{Signers: randomSigners(rng), AllowedSpecPaths: randomSubset(rng, specPaths)} + } + + policy := &TrustPolicy{Classes: classes} + + if rng.Intn(2) == 0 { + ruleClasses := map[RuleClass]RuleClassPolicy{} + for _, c := range []RuleClass{RuleClassBase, RuleClassOverlay} { + if rng.Intn(2) == 0 { + ruleClasses[c] = RuleClassPolicy{Signers: randomSigners(rng), AllowedRuleIDs: randomSubset(rng, ruleIDs)} + } + } + if len(ruleClasses) > 0 { + policy.RuleClasses = ruleClasses + } + } + return policy +} + +func TestSignedPolicy_RandomRoundTripOracle(t *testing.T) { + rng := mathrand.New(mathrand.NewSource(0x50101C9)) + const iterations = 300 + for i := 0; i < iterations; i++ { + policy := randomTrustPolicy(rng) + signed, fp := signPolicyWithFingerprint(t, policy, genKey(t)) + + got, err := verifyAndPinPolicy(signed, fp) + if err != nil { + t.Fatalf("iteration %d: verifyAndPinPolicy of a freshly signed policy: %v", i, err) + } + if !reflect.DeepEqual(policy, got) { + t.Fatalf("iteration %d: parsed policy differs from the signed input\n want %+v\n got %+v", i, policy, got) + } + } + t.Logf("SignTrustPolicy → verifyAndPinPolicy round-tripped %d random trust policies with deep equality", iterations) +} + +func TestSignedPolicy_SingleByteMutationOracle(t *testing.T) { + rng := mathrand.New(mathrand.NewSource(0xB17F11E)) + const policies = 5 + totalRejected := 0 + totalAccepted := 0 + for i := 0; i < policies; i++ { + policy := randomTrustPolicy(rng) + signed, fp := signPolicyWithFingerprint(t, policy, genKey(t)) + + if _, err := verifyAndPinPolicy(signed, fp); err != nil { + t.Fatalf("policy %d: precondition, pristine artifact must verify: %v", i, err) + } + + for pos := 0; pos < len(signed); pos++ { + mutated := make([]byte, len(signed)) + copy(mutated, signed) + mutated[pos] ^= 0xFF + + got, err := verifyAndPinPolicy(mutated, fp) + if err != nil { + totalRejected++ + continue + } + if !reflect.DeepEqual(policy, got) { + t.Fatalf("policy %d byte %d: a single-byte mutation was ACCEPTED and yielded a DIFFERENT policy\n orig %+v\n got %+v", i, pos, policy, got) + } + totalAccepted++ + } + } + if totalRejected == 0 { + t.Fatal("expected many single-byte mutations to be rejected; none were") + } + t.Logf("single-byte mutation oracle: %d mutations rejected, %d accepted (each accepted one re-parsed to the IDENTICAL policy — no forgeable variant)", totalRejected, totalAccepted) +} + +// The embedded root public key parses and yields a stable key: fingerprint. +// This exercises the production anchor without needing any private key. +func TestEmbeddedRootFingerprint_Parses(t *testing.T) { + fp, err := rootFingerprint() + if err != nil { + t.Fatalf("rootFingerprint: %v", err) + } + if !strings.HasPrefix(fp, "key:") || len(fp) != len("key:")+64 { + t.Fatalf("embedded root fingerprint malformed: %q", fp) + } +} diff --git a/pkg/signature/bundle/reload.go b/pkg/signature/bundle/reload.go new file mode 100644 index 0000000000..83a082104a --- /dev/null +++ b/pkg/signature/bundle/reload.go @@ -0,0 +1,181 @@ +//go:build experimental_signing + +package bundle + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "strings" + "time" +) + +// DefaultPolicyReloadInterval is how often the mounted trust policy is re-read. +// A ConfigMap update reaches the pod within about a minute, so polling is enough +// and avoids an inotify watch on a kubelet-managed symlink farm. +const DefaultPolicyReloadInterval = 30 * time.Second + +// PolicyReloadEvent reports the outcome of one reload attempt. +type PolicyReloadEvent struct { + Applied *TrustPolicy // non-nil when a new policy verified and should be applied + Err error // non-nil when the changed artifact was refused + Mounted bool + RootFP string + // ArtifactDigest is the sha256 (hex) of the candidate artifact bytes; + // InForceDigest identifies the policy still (or now) in force, so a + // refusal names both and `sha256sum` on the mounted file can be matched. + ArtifactDigest string + InForceDigest string + Unchanged bool +} + +// PolicyReloader re-reads a mounted trust policy and reports verified changes. +// A changed artifact that does NOT verify is refused and the caller keeps the +// policy it already has: a swapped-in bad policy must never downgrade or +// disable enforcement. +type PolicyReloader struct { + path string + // lastSeenHash dedups reporting (set even for refused content); + // inForceHash identifies the applied policy and only moves on apply. + lastSeenHash string + inForceHash string + // resolveRoot is the trusted-anchor resolver; production uses the fixed + // mount or the compiled root, tests substitute a fingerprint. + resolveRoot func() (fingerprint string, mounted bool, err error) + inForceVersion int64 + inForce *TrustPolicy // D16 guards compare candidates against this +} + +// ErrPolicyRollback is a replay of an older, still-validly-signed trust policy: +// hash dedup only detects change, so the monotonic policyVersion inside the +// signed content is what refuses a downgrade to a previously valid artifact. +var ErrPolicyRollback = errors.New("policy version is below the version in force (rollback)") + +// D16 one-way door: enforcement, once in force, cannot be reloaded away for the +// life of the process — uninstall to turn signing off. +var ErrEnforceDowngrade = errors.New("mode downgrade refused: enforcement is a one-way door") + +// D16 append-only signers: a reload may add signers, never remove one that is +// in force (revocation semantics arrive as an explicit revoked list, not +// silent removal). +var ErrSignerRemoved = errors.New("signer removal refused: signers are append-only") + +func NewPolicyReloader(path string, initial []byte, inForceVersion int64) *PolicyReloader { + r := &PolicyReloader{path: path, resolveRoot: ResolveTrustedRootFingerprint, inForceVersion: inForceVersion} + if len(initial) > 0 { + h := hashBytes(initial) + r.lastSeenHash = h + r.inForceHash = h + } + return r +} + +func hashBytes(b []byte) string { + s := sha256.Sum256(b) + return hex.EncodeToString(s[:]) +} + +// Poll reads the policy once and reports what changed. +func (r *PolicyReloader) Poll() PolicyReloadEvent { + b, err := os.ReadFile(r.path) + if err != nil { + return PolicyReloadEvent{Err: err} + } + h := hashBytes(b) + if h == r.lastSeenHash { + return PolicyReloadEvent{Unchanged: true} + } + // Record the hash even on failure so a persistently bad artifact is reported + // once per distinct content rather than every tick. + r.lastSeenHash = h + + rootFP, mounted, rerr := r.resolveRoot() + if rerr != nil { + return PolicyReloadEvent{Err: rerr, Mounted: mounted, ArtifactDigest: h, InForceDigest: r.inForceHash} + } + p, verr := verifyAndPinPolicy(b, rootFP) + if verr != nil { + return PolicyReloadEvent{Err: verr, Mounted: mounted, RootFP: rootFP, ArtifactDigest: h, InForceDigest: r.inForceHash} + } + if gerr := GuardRootAnchor(p, rootFP, mounted); gerr != nil { + return PolicyReloadEvent{Err: gerr, Mounted: mounted, RootFP: rootFP, ArtifactDigest: h, InForceDigest: r.inForceHash} + } + if r.inForce != nil { + if r.inForce.Enforcing() && !p.Enforcing() { + return PolicyReloadEvent{Err: fmt.Errorf("%w: refused mode %q, in force %q", ErrEnforceDowngrade, p.EffectiveMode(), r.inForce.EffectiveMode()), Mounted: mounted, RootFP: rootFP, ArtifactDigest: h, InForceDigest: r.inForceHash} + } + if removed := removedSigners(r.inForce, p); len(removed) > 0 { + return PolicyReloadEvent{Err: fmt.Errorf("%w: %s", ErrSignerRemoved, strings.Join(removed, ", ")), Mounted: mounted, RootFP: rootFP, ArtifactDigest: h, InForceDigest: r.inForceHash} + } + } + if p.PolicyVersion < r.inForceVersion { + return PolicyReloadEvent{Err: fmt.Errorf("%w: refused version %d, in force %d", ErrPolicyRollback, p.PolicyVersion, r.inForceVersion), Mounted: mounted, RootFP: rootFP, ArtifactDigest: h, InForceDigest: r.inForceHash} + } + r.inForceVersion = p.PolicyVersion + r.inForceHash = h + r.inForce = p + return PolicyReloadEvent{Applied: p, Mounted: mounted, RootFP: rootFP, ArtifactDigest: h, InForceDigest: h} +} + +// NoteInForce seeds the D16 guards with the policy applied at startup. +func (r *PolicyReloader) NoteInForce(p *TrustPolicy) { r.inForce = p } + +// removedSigners lists signers present in a class of the in-force policy that +// the candidate no longer carries for that class. +func removedSigners(inForce, candidate *TrustPolicy) []string { + var removed []string + for class, cp := range inForce.Classes { + next := candidate.Classes[class] + for _, s := range cp.Signers { + if !next.allowsSigner(s) { + removed = append(removed, string(class)+":"+s) + } + } + } + for class, cp := range inForce.RuleClasses { + next := candidate.RuleClasses[class] + for _, s := range cp.Signers { + if !next.allowsSigner(s) { + removed = append(removed, "rule/"+string(class)+":"+s) + } + } + } + for class, cp := range inForce.BindingClasses { + next := candidate.BindingClasses[class] + for _, s := range cp.Signers { + if !next.allowsSigner(s) { + removed = append(removed, "binding/"+string(class)+":"+s) + } + } + } + return removed +} + +// Watch polls until ctx is done, handing each event to onEvent. +func (r *PolicyReloader) Watch(ctx context.Context, every time.Duration, onEvent func(PolicyReloadEvent)) { + if every <= 0 { + every = DefaultPolicyReloadInterval + } + t := time.NewTicker(every) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + ev := r.Poll() + if ev.Unchanged { + continue + } + onEvent(ev) + } + } +} + +// HashArtifact is the digest served by /policyz and logged on reload events: +// plain sha256 hex of the artifact bytes, so `sha256sum` on the mounted file +// matches it directly. +func HashArtifact(b []byte) string { return hashBytes(b) } diff --git a/pkg/signature/bundle/reload_d16_poll_test.go b/pkg/signature/bundle/reload_d16_poll_test.go new file mode 100644 index 0000000000..a1c61720f8 --- /dev/null +++ b/pkg/signature/bundle/reload_d16_poll_test.go @@ -0,0 +1,144 @@ +//go:build experimental_signing + +package bundle + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func enforcePolicyV(t *testing.T, version int64, signers ...string) *TrustPolicy { + t.Helper() + p := demoPolicy() + p.Mode = ModeEnforce + p.PolicyVersion = version + for class, cp := range p.Classes { + cp.Signers = append([]string{}, signers...) + p.Classes[class] = cp + } + return p +} + +func TestPolicyReloader_RefusesModeDowngradeWhileRunning(t *testing.T) { + dir := t.TempDir() + key := genKey(t) + enforce := enforcePolicyV(t, 2, "key:aaa") + signed, fp := signPolicyWithFingerprint(t, enforce, key) + path := writePolicyFile(t, dir, signed) + r := newTestReloader(t, path, fp, nil) + ev := r.Poll() + require.NoError(t, ev.Err) + require.NotNil(t, ev.Applied) + require.True(t, ev.Applied.Enforcing()) + + alert := enforcePolicyV(t, 3, "key:aaa") + alert.Mode = ModeAlert + signedAlert, _ := signPolicyWithFingerprint(t, alert, key) + require.NoError(t, os.WriteFile(path, signedAlert, 0o600)) + ev = r.Poll() + require.ErrorIs(t, ev.Err, ErrEnforceDowngrade) + require.Nil(t, ev.Applied) + + unversioned := enforcePolicyV(t, 0, "key:aaa") + unversioned.Mode = "" + signedUnversioned, _ := signPolicyWithFingerprint(t, unversioned, key) + require.NoError(t, os.WriteFile(path, signedUnversioned, 0o600)) + ev = r.Poll() + require.ErrorIs(t, ev.Err, ErrEnforceDowngrade, "the one-way door is named before the version check") + require.Nil(t, ev.Applied) + + still := enforcePolicyV(t, 3, "key:aaa", "key:bbb") + signedStill, _ := signPolicyWithFingerprint(t, still, key) + require.NoError(t, os.WriteFile(path, signedStill, 0o600)) + ev = r.Poll() + require.NoError(t, ev.Err) + require.NotNil(t, ev.Applied, "an enforce policy that only adds signers applies after the refusals") +} + +func TestPolicyReloader_SignersAreAppendOnly(t *testing.T) { + dir := t.TempDir() + key := genKey(t) + two := enforcePolicyV(t, 2, "key:aaa", "key:bbb") + signed, fp := signPolicyWithFingerprint(t, two, key) + path := writePolicyFile(t, dir, signed) + r := newTestReloader(t, path, fp, nil) + require.NotNil(t, r.Poll().Applied) + + one := enforcePolicyV(t, 3, "key:aaa") + signedOne, _ := signPolicyWithFingerprint(t, one, key) + require.NoError(t, os.WriteFile(path, signedOne, 0o600)) + ev := r.Poll() + require.ErrorIs(t, ev.Err, ErrSignerRemoved) + require.Contains(t, ev.Err.Error(), "key:bbb") + require.Nil(t, ev.Applied) + + three := enforcePolicyV(t, 3, "key:aaa", "key:bbb", "key:ccc") + signedThree, _ := signPolicyWithFingerprint(t, three, key) + require.NoError(t, os.WriteFile(path, signedThree, 0o600)) + ev = r.Poll() + require.NoError(t, ev.Err) + require.NotNil(t, ev.Applied) + + two4 := enforcePolicyV(t, 4, "key:aaa", "key:bbb") + signedTwo4, _ := signPolicyWithFingerprint(t, two4, key) + require.NoError(t, os.WriteFile(path, signedTwo4, 0o600)) + ev = r.Poll() + require.ErrorIs(t, ev.Err, ErrSignerRemoved, "the added signer is in force and cannot be taken back") + require.Contains(t, ev.Err.Error(), "key:ccc") +} + +func TestPolicyReloader_BootPolicyIsTheFirstInForce(t *testing.T) { + dir := t.TempDir() + key := genKey(t) + boot := enforcePolicyV(t, 2, "key:aaa", "key:bbb") + signed, fp := signPolicyWithFingerprint(t, boot, key) + path := writePolicyFile(t, dir, signed) + r := newTestReloader(t, path, fp, signed) + r.NoteInForce(boot) + + one := enforcePolicyV(t, 3, "key:aaa") + signedOne, _ := signPolicyWithFingerprint(t, one, key) + require.NoError(t, os.WriteFile(path, signedOne, 0o600)) + require.ErrorIs(t, r.Poll().Err, ErrSignerRemoved) +} + +func TestPolicyReloader_BindingSignersAreAppendOnly(t *testing.T) { + for _, tc := range []struct { + name string + classes map[FragmentClass]BindingClassPolicy + refused bool + }{ + {"remove signer", map[FragmentClass]BindingClassPolicy{"base": {Signers: []string{"key:aaa"}}}, true}, + {"remove class", nil, true}, + {"add signer", map[FragmentClass]BindingClassPolicy{"base": {Signers: []string{"key:aaa", "key:bbb", "key:ccc"}}}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + key := genKey(t) + boot := enforcePolicyV(t, 2, "key:profile") + boot.BindingClasses = map[FragmentClass]BindingClassPolicy{"base": {Signers: []string{"key:aaa", "key:bbb"}}} + signed, fp := signPolicyWithFingerprint(t, boot, key) + path := writePolicyFile(t, t.TempDir(), signed) + r := newTestReloader(t, path, fp, nil) + require.NotNil(t, r.Poll().Applied) + + candidate := enforcePolicyV(t, 3, "key:profile") + candidate.BindingClasses = tc.classes + updated, _ := signPolicyWithFingerprint(t, candidate, key) + require.NoError(t, os.WriteFile(path, updated, 0o600)) + ev := r.Poll() + if tc.refused { + require.ErrorIs(t, ev.Err, ErrSignerRemoved) + require.Contains(t, ev.Err.Error(), "binding/base:key:bbb") + require.Nil(t, ev.Applied) + require.True(t, r.inForce.BindingSigningEnabled()) + require.Equal(t, int64(2), r.inForceVersion) + } else { + require.NoError(t, ev.Err) + require.NotNil(t, ev.Applied) + require.True(t, ev.Applied.BindingClasses["base"].allowsSigner("key:ccc")) + } + }) + } +} diff --git a/pkg/signature/bundle/reload_d16_test.go b/pkg/signature/bundle/reload_d16_test.go new file mode 100644 index 0000000000..5884e53651 --- /dev/null +++ b/pkg/signature/bundle/reload_d16_test.go @@ -0,0 +1,44 @@ +//go:build experimental_signing + +package bundle + +import ( + "errors" + "testing" +) + +// D16: enforcement is a one-way door; signers are append-only. +func TestD16_Guards(t *testing.T) { + enforce := &TrustPolicy{Mode: ModeEnforce, Classes: map[FragmentClass]ClassPolicy{ + "base": {Signers: []string{"key:aaa", "key:bbb"}}, + }} + alertFewer := &TrustPolicy{Mode: ModeAlert, Classes: map[FragmentClass]ClassPolicy{ + "base": {Signers: []string{"key:aaa", "key:bbb"}}, + }} + enforceRemoved := &TrustPolicy{Mode: ModeEnforce, Classes: map[FragmentClass]ClassPolicy{ + "base": {Signers: []string{"key:aaa"}}, + }} + enforceAdded := &TrustPolicy{Mode: ModeEnforce, Classes: map[FragmentClass]ClassPolicy{ + "base": {Signers: []string{"key:aaa", "key:bbb", "key:ccc"}}, + }} + + r := &PolicyReloader{} + r.NoteInForce(enforce) + + if r.inForce.Mode == ModeEnforce && alertFewer.Mode != ModeEnforce { + // mirrors Poll's guard; assert the sentinel wiring + err := errors.Join(ErrEnforceDowngrade) + if !errors.Is(err, ErrEnforceDowngrade) { + t.Fatal("sentinel broken") + } + } + if got := removedSigners(enforce, enforceRemoved); len(got) != 1 || got[0] != "base:key:bbb" { + t.Fatalf("signer removal must be detected, got %v", got) + } + if got := removedSigners(enforce, enforceAdded); len(got) != 0 { + t.Fatalf("adding signers must pass, got %v", got) + } + if got := removedSigners(enforce, alertFewer); len(got) != 0 { + t.Fatalf("same signers different mode: no removal, got %v", got) + } +} diff --git a/pkg/signature/bundle/reload_test.go b/pkg/signature/bundle/reload_test.go new file mode 100644 index 0000000000..ca14aec2f2 --- /dev/null +++ b/pkg/signature/bundle/reload_test.go @@ -0,0 +1,185 @@ +//go:build experimental_signing + +package bundle + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func writePolicyFile(t *testing.T, dir string, b []byte) string { + t.Helper() + p := filepath.Join(dir, "trust-policy.json") + require.NoError(t, os.WriteFile(p, b, 0o600)) + return p +} + +func newTestReloader(t *testing.T, path, fp string, initial []byte) *PolicyReloader { + t.Helper() + r := NewPolicyReloader(path, initial, 0) + r.resolveRoot = func() (string, bool, error) { return fp, true, nil } + return r +} + +// A policy that changes and verifies is applied. +func TestPolicyReloader_AppliesVerifiedChange(t *testing.T) { + dir := t.TempDir() + key := genKey(t) + first, fp := signPolicyWithFingerprint(t, demoPolicy(), key) + path := writePolicyFile(t, dir, first) + + r := newTestReloader(t, path, fp, first) + require.True(t, r.Poll().Unchanged, "identical content must be a no-op") + + // Ship a new policy (same root key) that adds a rule class. + p2 := demoPolicy() + p2.RuleClasses = map[RuleClass]RuleClassPolicy{ + RuleClassBase: {Signers: []string{"key:whoever"}, AllowedRuleIDs: []string{"*"}}, + } + second, _ := signPolicyWithFingerprint(t, p2, key) + require.NoError(t, os.WriteFile(path, second, 0o600)) + + ev := r.Poll() + require.NoError(t, ev.Err) + require.NotNil(t, ev.Applied, "a verified change must be applied") + require.True(t, ev.Applied.RuleSigningEnabled(), "the new policy's ruleClasses must take effect without a restart") +} + +// A changed policy signed by the WRONG key is refused; the caller keeps its own. +func TestPolicyReloader_RefusesUnverifiedChange(t *testing.T) { + dir := t.TempDir() + good, fp := signPolicyWithFingerprint(t, demoPolicy(), genKey(t)) + path := writePolicyFile(t, dir, good) + r := newTestReloader(t, path, fp, good) + + rogue, _ := signPolicyWithFingerprint(t, demoPolicy(), genKey(t)) // different key + require.NoError(t, os.WriteFile(path, rogue, 0o600)) + + ev := r.Poll() + require.Error(t, ev.Err, "a policy not signed by the trusted root must be refused") + require.Nil(t, ev.Applied, "nothing may be applied from an unverifiable artifact") + + // Reported once per distinct content, not every tick. + require.True(t, r.Poll().Unchanged) +} + +// enforce + demo root + no mount is refused on reload too, not just at boot. +func TestPolicyReloader_RefusesDemoRootUnderEnforce(t *testing.T) { + dir := t.TempDir() + key := genKey(t) + p := demoPolicy() + p.Mode = ModeEnforce + signed, _ := signPolicyWithFingerprint(t, p, key) + path := writePolicyFile(t, dir, signed) + + r := NewPolicyReloader(path, nil, 0) + r.resolveRoot = func() (string, bool, error) { return DemoRootFingerprint, false, nil } + ev := r.Poll() + require.Error(t, ev.Err) + require.Nil(t, ev.Applied) +} + +// A reloader constructed after a boot-load failure (no initial artifact) +// recovers as soon as a verifiable policy is mounted: scream-and-poll, never +// a restart requirement. +func TestPolicyReloader_RecoversFromInvalidBoot(t *testing.T) { + dir := t.TempDir() + key := genKey(t) + _, fp := signPolicyWithFingerprint(t, demoPolicy(), key) + path := writePolicyFile(t, dir, []byte("{not a signed policy}")) + + r := newTestReloader(t, path, fp, nil) + ev := r.Poll() + require.Error(t, ev.Err, "the invalid boot artifact must be refused") + require.Nil(t, ev.Applied) + require.True(t, r.Poll().Unchanged, "the same invalid content must not re-report every tick") + + good, _ := signPolicyWithFingerprint(t, demoPolicy(), key) + require.NoError(t, os.WriteFile(path, good, 0o600)) + ev = r.Poll() + require.NoError(t, ev.Err) + require.NotNil(t, ev.Applied, "a valid policy mounted after a bad boot must enable signing without a restart") +} + +// An older, still-validly-signed policy is a rollback replay and is refused; +// hash dedup alone is not a rollback defense. +func TestPolicyReloader_RefusesVersionRollback(t *testing.T) { + dir := t.TempDir() + key := genKey(t) + + v2 := demoPolicy() + v2.PolicyVersion = 2 + signedV2, fp := signPolicyWithFingerprint(t, v2, key) + path := writePolicyFile(t, dir, signedV2) + r := newTestReloader(t, path, fp, nil) + + ev := r.Poll() + require.NoError(t, ev.Err) + require.NotNil(t, ev.Applied) + require.Equal(t, int64(2), ev.Applied.PolicyVersion) + + v1 := demoPolicy() + v1.PolicyVersion = 1 + signedV1, _ := signPolicyWithFingerprint(t, v1, key) + require.NoError(t, os.WriteFile(path, signedV1, 0o600)) + ev = r.Poll() + require.ErrorIs(t, ev.Err, ErrPolicyRollback) + require.Nil(t, ev.Applied, "an older validly-signed policy must not be applied") + + v3 := demoPolicy() + v3.PolicyVersion = 3 + signedV3, _ := signPolicyWithFingerprint(t, v3, key) + require.NoError(t, os.WriteFile(path, signedV3, 0o600)) + ev = r.Poll() + require.NoError(t, ev.Err) + require.NotNil(t, ev.Applied, "a higher version must apply after a refused rollback") +} + +// Unversioned policies (absent = 0) keep the pre-version behavior: any +// verified change applies, so existing artifacts are unaffected. +func TestPolicyReloader_UnversionedPoliciesStillReload(t *testing.T) { + dir := t.TempDir() + key := genKey(t) + first, fp := signPolicyWithFingerprint(t, demoPolicy(), key) + path := writePolicyFile(t, dir, first) + r := newTestReloader(t, path, fp, first) + + p2 := demoPolicy() + p2.Mode = ModeAlert + second, _ := signPolicyWithFingerprint(t, p2, key) + require.NoError(t, os.WriteFile(path, second, 0o600)) + ev := r.Poll() + require.NoError(t, ev.Err) + require.NotNil(t, ev.Applied) +} + +// Refusal events name BOTH digests so the enforced policy can be identified +// from the log alone; apply events move the in-force digest. +func TestPolicyReloader_EventsCarryDigests(t *testing.T) { + dir := t.TempDir() + key := genKey(t) + first, fp := signPolicyWithFingerprint(t, demoPolicy(), key) + path := writePolicyFile(t, dir, first) + r := newTestReloader(t, path, fp, first) + bootDigest := HashArtifact(first) + + rogue, _ := signPolicyWithFingerprint(t, demoPolicy(), genKey(t)) + require.NoError(t, os.WriteFile(path, rogue, 0o600)) + ev := r.Poll() + require.Error(t, ev.Err) + require.Equal(t, HashArtifact(rogue), ev.ArtifactDigest, "refusal must name the refused artifact") + require.Equal(t, bootDigest, ev.InForceDigest, "refusal must keep naming the boot policy as in force") + + p2 := demoPolicy() + p2.Mode = ModeAlert + second, _ := signPolicyWithFingerprint(t, p2, key) + require.NoError(t, os.WriteFile(path, second, 0o600)) + ev = r.Poll() + require.NoError(t, ev.Err) + require.NotNil(t, ev.Applied) + require.Equal(t, HashArtifact(second), ev.ArtifactDigest) + require.Equal(t, HashArtifact(second), ev.InForceDigest, "apply must move the in-force digest") +} diff --git a/pkg/signature/bundle/root.go b/pkg/signature/bundle/root.go new file mode 100644 index 0000000000..ef8192e90c --- /dev/null +++ b/pkg/signature/bundle/root.go @@ -0,0 +1,69 @@ +//go:build experimental_signing + +package bundle + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "os" +) + +const DefaultRootPublicKeyPEM = `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwIIkPTD5rGPP/bYmjRHxefAoCI1m +wIiQMRA78xdS5rTT3zl06ygpY9D4A1zRrQjq2CkF5ciwO2kSJGe3h64jEw== +-----END PUBLIC KEY----- +` + +func fingerprintFromPublicKeyPEM(pemBytes []byte) (string, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return "", fmt.Errorf("failed to PEM-decode root public key") + } + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse root public key: %w", err) + } + pkix, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return "", fmt.Errorf("failed to marshal root public key: %w", err) + } + sum := sha256.Sum256(pkix) + return "key:" + hex.EncodeToString(sum[:]), nil +} + +// DemoRootFingerprint is the fingerprint of the published demo root key that +// DefaultRootPublicKeyPEM embeds. A published key authenticates nothing, so +// enforce mode refuses it unless a real root is mounted at FixedRootKeyPath. +// TestDemoRootFingerprintMatchesDefault pins this to the embedded key. +const DemoRootFingerprint = "key:d0cc7f2e82d99699d7a5f7078a9f30ec4847d13b6e41e91e224e0a47b32b9f9e" + +// IsDemoRoot reports whether a resolved root fingerprint is the published demo +// key that authenticates nothing. +func IsDemoRoot(fingerprint string) bool { return fingerprint == DemoRootFingerprint } + +func rootFingerprint() (string, error) { + return fingerprintFromPublicKeyPEM([]byte(DefaultRootPublicKeyPEM)) +} + +func rootFingerprintFromKeyFile(path string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read root public key %q: %w", path, err) + } + return fingerprintFromPublicKeyPEM(b) +} + +func ResolveRootFingerprint(mountedKeyPath string) (fingerprint string, mounted bool, err error) { + if mountedKeyPath != "" { + fp, ferr := rootFingerprintFromKeyFile(mountedKeyPath) + if ferr != nil { + return "", true, ferr + } + return fp, true, nil + } + fp, ferr := rootFingerprint() + return fp, false, ferr +} diff --git a/pkg/signature/bundle/root_mounted_test.go b/pkg/signature/bundle/root_mounted_test.go new file mode 100644 index 0000000000..416a3cb70d --- /dev/null +++ b/pkg/signature/bundle/root_mounted_test.go @@ -0,0 +1,120 @@ +//go:build experimental_signing + +package bundle + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "testing" +) + +func writePubKeyPEM(t *testing.T, dir string, key *ecdsa.PrivateKey) string { + t.Helper() + der, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, "root.pub") + if err := os.WriteFile(p, pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}), 0644); err != nil { + t.Fatal(err) + } + return p +} + +func TestResolveRootFingerprint_EmbeddedByDefault(t *testing.T) { + fp, mounted, err := ResolveRootFingerprint("") + if err != nil { + t.Fatalf("resolve embedded: %v", err) + } + if mounted { + t.Fatal("empty path must resolve to the embedded key, not mounted") + } + embedded, _ := rootFingerprint() + if fp != embedded { + t.Fatalf("embedded fingerprint mismatch: %q vs %q", fp, embedded) + } +} + +func TestResolveRootFingerprint_MountedOverridesEmbedded(t *testing.T) { + key := genKey(t) + path := writePubKeyPEM(t, t.TempDir(), key) + fp, mounted, err := ResolveRootFingerprint(path) + if err != nil { + t.Fatalf("resolve mounted: %v", err) + } + if !mounted { + t.Fatal("a supplied path must resolve as mounted") + } + embedded, _ := rootFingerprint() + if fp == embedded { + t.Fatal("a distinct mounted key must yield a distinct fingerprint") + } +} + +func TestLoadSignedTrustPolicy_AcceptsPolicySignedByMountedRoot(t *testing.T) { + root := genKey(t) + path := writePubKeyPEM(t, t.TempDir(), root) + signed, err := SignTrustPolicy(demoPolicy(), root) + if err != nil { + t.Fatalf("sign policy with mounted root: %v", err) + } + artifact := filepath.Join(t.TempDir(), "trust-policy.signed.json") + if err := os.WriteFile(artifact, signed, 0644); err != nil { + t.Fatal(err) + } + pol, err := LoadSignedTrustPolicy(artifact, path) + if err != nil { + t.Fatalf("mounted-root policy must load: %v", err) + } + if len(pol.Classes) != 2 { + t.Fatalf("expected 2 classes, got %d", len(pol.Classes)) + } +} + +func TestLoadSignedTrustPolicy_RejectsPolicyNotSignedByMountedRoot(t *testing.T) { + root, rogue := genKey(t), genKey(t) + path := writePubKeyPEM(t, t.TempDir(), root) + signed, err := SignTrustPolicy(demoPolicy(), rogue) + if err != nil { + t.Fatal(err) + } + artifact := filepath.Join(t.TempDir(), "trust-policy.signed.json") + if err := os.WriteFile(artifact, signed, 0644); err != nil { + t.Fatal(err) + } + if _, err := LoadSignedTrustPolicy(artifact, path); err == nil { + t.Fatal("a policy signed by a key other than the mounted root must be rejected") + } +} + +func TestResolveRootFingerprint_UnreadableMountFails(t *testing.T) { + if _, _, err := ResolveRootFingerprint(filepath.Join(t.TempDir(), "nope.pub")); err == nil { + t.Fatal("an unreadable mounted key path must fail closed") + } +} + +func TestTrustedRootDoesNotFallbackOnStatFailure(t *testing.T) { + // Descending through a regular file fails with ENOTDIR even as root. + blocker := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blocker, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + if fp, _, err := resolveTrustedRootFingerprintAt(filepath.Join(blocker, "root.pub")); err == nil || fp != "" { + t.Fatalf("stat errors must not select compiled root: fp=%q err=%v", fp, err) + } +} + +func TestTrustedRootFallbackOnlyWhenMissing(t *testing.T) { + fp, mounted, err := resolveTrustedRootFingerprintAt(filepath.Join(t.TempDir(), "absent.pub")) + if err != nil || mounted || fp != DemoRootFingerprint { + t.Fatalf("missing mount must use compiled root: fp=%q mounted=%v err=%v", fp, mounted, err) + } + keyPath := writePubKeyPEM(t, t.TempDir(), genKey(t)) + fp, mounted, err = resolveTrustedRootFingerprintAt(keyPath) + if err != nil || !mounted || fp == DemoRootFingerprint { + t.Fatalf("existing mount must override compiled root: fp=%q mounted=%v err=%v", fp, mounted, err) + } +} diff --git a/pkg/signature/bundle/rules.go b/pkg/signature/bundle/rules.go new file mode 100644 index 0000000000..d027e3bc23 --- /dev/null +++ b/pkg/signature/bundle/rules.go @@ -0,0 +1,199 @@ +//go:build experimental_signing + +package bundle + +import ( + "encoding/json" + "errors" + "fmt" + + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" +) + +// RuleClass declares the blast radius of a signed Rules fragment: a base +// fragment contributes detection rules to every workload, an overlay fragment +// only to the workloads bound to its BUNDLE. +// +// Rule fragments are labelled exactly like ContainerProfile fragments — +// LabelBundle + LabelFragmentClass — because the two are the halves of one +// vendor-shipped bundle: the base-class ContainerProfile fragments describe the +// expected behaviour, the overlay-class Rules fragment ships the detections that +// go with them. A workload opts into the bundle with the pod label +// kubescape.io/user-defined-profile, and BOTH halves follow that opt-in. +type RuleClass string + +const ( + // RuleClassBase is the baseline ruleset (default-rules), applying + // cluster-wide. It belongs to no bundle. + RuleClassBase RuleClass = "base" + // RuleClassOverlay is a vendor-shipped rule overlay. It applies to exactly + // the workloads bound to its bundle — in ANY namespace — overriding the base + // rule with the same ID for them. Bundle membership is SIGNED (it lives in + // metadata.labels), so an installer cannot re-target an overlay at another + // bundle; metadata.namespace is not signed and plays no part in scoping, + // because a vendor cannot know the customer's namespaces. + RuleClassOverlay RuleClass = "overlay" +) + +// Rule-fragment admissibility failure sentinels. They complement the +// ContainerProfile fragment sentinels in verify.go and deliberately reuse +// ErrFragmentUnsigned / ErrFragmentTampered / ErrSignerNotTrusted / ErrNoClass +// so callers can treat both fragment kinds uniformly. +var ( + ErrRuleClassNotAllowed = errors.New("rule class not present in trust policy") + ErrRuleIDNotAllowed = errors.New("rule ID not permitted for this rule class") + ErrRuleBundleRequired = errors.New("overlay rule fragment has no bundle label") +) + +// RuleClassPolicy is the admissibility rule for one rule class: which signer +// identities may author it and which rule IDs it may carry. +type RuleClassPolicy struct { + Signers []string `json:"signers"` + AllowedRuleIDs []string `json:"allowedRuleIDs"` +} + +func (p RuleClassPolicy) allowsSigner(id string) bool { + for _, s := range p.Signers { + if s == id { + return true + } + } + return false +} + +// allowsRuleID reports whether the class may carry the given rule ID. The +// wildcard entry "*" admits any ID. +func (p RuleClassPolicy) allowsRuleID(id string) bool { + for _, s := range p.AllowedRuleIDs { + if s == id || s == "*" { + return true + } + } + return false +} + +// VerifiedRules is the admitted view of a signed Rules object. Every field is +// derived from the VERIFIED content, never from the stored carrier object. +type VerifiedRules struct { + Rules []rulemanagertypesv1.Rule + Class RuleClass + Signer string + // Bundle is the VERIFIED bundle this fragment belongs to, read from the + // signed metadata.labels. Empty for a base fragment (the cluster baseline + // belongs to no bundle), mandatory for an overlay. + Bundle string + ClusterWide bool // true iff Class == RuleClassBase +} + +// rulesEmbeddedView is the canonical signed content layout produced by the +// Rules adapter's GetContent (profiles.RulesAdapter). +type rulesEmbeddedView struct { + Metadata struct { + Name string `json:"name"` + Labels map[string]string `json:"labels"` + } `json:"metadata"` + Spec rulemanagertypesv1.RulesSpec `json:"spec"` +} + +// AdmitRulesFragment runs the full admissibility check on one Rules object: it +// must be signed, verify cleanly, declare a class the trust policy knows, be +// signed by a signer that class trusts, and carry only rule IDs that class is +// allowed to ship. +// +// When the object carries embedded signed content (signed with --embed-content), +// the EMBEDDED bytes are the verified source of truth: name, labels (i.e. the +// class AND the bundle) and the rule list all come from them. This is what makes +// bundle scoping sound — an attacker who relabels a validly signed overlay +// cannot re-target it at another bundle, because the bundle the agent acts on is +// the signed one. +func AdmitRulesFragment(r *rulemanagertypesv1.Rules, policy TrustPolicy) (VerifiedRules, error) { + if r == nil { + return VerifiedRules{}, fmt.Errorf("nil Rules object") + } + adapter := profiles.NewRulesAdapter(r) + if !signature.IsSigned(adapter) { + return VerifiedRules{}, fmt.Errorf("%w: %q", ErrFragmentUnsigned, r.Name) + } + if err := signature.VerifyObjectAllowUntrusted(adapter); err != nil { + if errors.Is(err, signature.ErrSignatureMismatch) { + return VerifiedRules{}, fmt.Errorf("%w: %q: %v", ErrFragmentTampered, r.Name, err) + } + return VerifiedRules{}, fmt.Errorf("verify rules fragment %q: %w", r.Name, err) + } + + // Establish the verified view: embedded content when present, else the + // stored object. + name := r.Name + labels := r.Labels + rules := r.Spec.Rules + if embedded, present, embErr := signature.EmbeddedContent(adapter); present { + if embErr != nil { + return VerifiedRules{}, fmt.Errorf("embedded content of %q: %w", r.Name, embErr) + } + var view rulesEmbeddedView + if err := json.Unmarshal(embedded, &view); err != nil { + return VerifiedRules{}, fmt.Errorf("parse embedded content of %q: %w", r.Name, err) + } + name = view.Metadata.Name + labels = view.Metadata.Labels + rules = view.Spec.Rules + // metadata.namespace is deliberately absent from the verified view: it is + // not signed and it no longer scopes anything — bundle membership does. + } + + class := RuleClass(labels[LabelFragmentClass]) + if class == "" { + return VerifiedRules{}, fmt.Errorf("%w: %q", ErrNoClass, name) + } + rpol, ok := policy.RuleClasses[class] + if !ok { + return VerifiedRules{}, fmt.Errorf("%w: class %q (fragment %q)", ErrRuleClassNotAllowed, class, name) + } + + sig, err := signature.GetObjectSignature(adapter) + if err != nil { + return VerifiedRules{}, fmt.Errorf("read signature of %q: %w", name, err) + } + signer, err := signerIdentity(sig) + if err != nil { + return VerifiedRules{}, fmt.Errorf("signer identity of %q: %w", name, err) + } + if !rpol.allowsSigner(signer) { + return VerifiedRules{}, fmt.Errorf("%w: signer %q, class %q (fragment %q)", ErrSignerNotTrusted, signer, class, name) + } + + for _, rule := range rules { + if !rpol.allowsRuleID(rule.ID) { + return VerifiedRules{}, fmt.Errorf("%w: rule %q, class %q (fragment %q)", ErrRuleIDNotAllowed, rule.ID, class, name) + } + } + + // The bundle comes from the VERIFIED labels, never from the carrier object. + bundleName := labels[LabelBundle] + if class == RuleClassOverlay && bundleName == "" { + // An overlay with no bundle would have no way to be scoped and would + // either apply nowhere or everywhere. Reject it rather than guess. + return VerifiedRules{}, fmt.Errorf("%w: fragment %q", ErrRuleBundleRequired, name) + } + + return VerifiedRules{ + Rules: rules, + Class: class, + Signer: signer, + Bundle: bundleName, + ClusterWide: class == RuleClassBase, + }, nil +} + +// RulesSignerID returns the trust-policy signer identity of a signed Rules +// object — the signing public-key fingerprint (key:). +// Exposed for authoring trust policies and tests. +func RulesSignerID(r *rulemanagertypesv1.Rules) (string, error) { + sig, err := signature.GetObjectSignature(profiles.NewRulesAdapter(r)) + if err != nil { + return "", err + } + return signerIdentity(sig) +} diff --git a/pkg/signature/bundle/rules_test.go b/pkg/signature/bundle/rules_test.go new file mode 100644 index 0000000000..ab9590a117 --- /dev/null +++ b/pkg/signature/bundle/rules_test.go @@ -0,0 +1,385 @@ +//go:build experimental_signing + +package bundle + +import ( + "crypto/ecdsa" + "errors" + "testing" + + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// rule is a minimal enabled Rule with the given ID. +func rule(id string, enabled bool) rulemanagertypesv1.Rule { + return rulemanagertypesv1.Rule{ + Enabled: enabled, + ID: id, + Name: "rule-" + id, + } +} + +// rulesObject builds a Rules fragment labelled exactly like a ContainerProfile +// fragment: LabelBundle + LabelFragmentClass. Both labels are set BEFORE signing +// because metadata.labels is inside the signed content, so the signature binds +// the class AND the bundle. The namespace is NOT signed and is only where the +// object happens to be installed. +func rulesObject(name, namespace, bundleName, class string, rules []rulemanagertypesv1.Rule) *rulemanagertypesv1.Rules { + labels := map[string]string{} + if class != "" { + labels[LabelFragmentClass] = class + } + if bundleName != "" { + labels[LabelBundle] = bundleName + } + return &rulemanagertypesv1.Rules{ + TypeMeta: metav1.TypeMeta{APIVersion: "kubescape.io/v1", Kind: "Rules"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + }, + Spec: rulemanagertypesv1.RulesSpec{Rules: rules}, + } +} + +// signedRules builds a Rules object and signs it with key. +func signedRules(t *testing.T, name, namespace, bundleName, class string, rules []rulemanagertypesv1.Rule, key *ecdsa.PrivateKey) *rulemanagertypesv1.Rules { + t.Helper() + r := rulesObject(name, namespace, bundleName, class, rules) + if err := signature.SignObject(profiles.NewRulesAdapter(r), signature.WithPrivateKey(key)); err != nil { + t.Fatalf("sign %s: %v", name, err) + } + return r +} + +func rulesSignerIDOf(t *testing.T, r *rulemanagertypesv1.Rules) string { + t.Helper() + id, err := RulesSignerID(r) + if err != nil { + t.Fatalf("signer identity: %v", err) + } + return id +} + +func rulePolicy(baseSigner, overlaySigner string, baseIDs, overlayIDs []string) TrustPolicy { + return TrustPolicy{RuleClasses: map[RuleClass]RuleClassPolicy{ + RuleClassBase: {Signers: []string{baseSigner}, AllowedRuleIDs: baseIDs}, + RuleClassOverlay: {Signers: []string{overlaySigner}, AllowedRuleIDs: overlayIDs}, + }} +} + +func TestRuleSigningEnabled(t *testing.T) { + if (TrustPolicy{}).RuleSigningEnabled() { + t.Error("empty policy must report rule signing disabled") + } + // A profile-only policy (the pre-existing shape) must NOT turn rule signing on. + profileOnly := TrustPolicy{Classes: map[FragmentClass]ClassPolicy{ClassBase: {}}} + if profileOnly.RuleSigningEnabled() { + t.Error("policy without ruleClasses must report rule signing disabled") + } + withRules := TrustPolicy{RuleClasses: map[RuleClass]RuleClassPolicy{RuleClassBase: {}}} + if !withRules.RuleSigningEnabled() { + t.Error("policy with ruleClasses must report rule signing enabled") + } +} + +func TestAdmitRulesFragment_BaseHappyPath(t *testing.T) { + vendor, operator := genKey(t), genKey(t) + frag := signedRules(t, "baseline", "kubescape", "", string(RuleClassBase), []rulemanagertypesv1.Rule{rule("R0001", true), rule("R0002", true)}, vendor) + ovlFrag := signedRules(t, "redis-tuning", "redis", "redis", string(RuleClassOverlay), []rulemanagertypesv1.Rule{rule("R0001", true)}, operator) + policy := rulePolicy(rulesSignerIDOf(t, frag), rulesSignerIDOf(t, ovlFrag), []string{"R0001", "R0002"}, []string{"R0001"}) + + got, err := AdmitRulesFragment(frag, policy) + if err != nil { + t.Fatalf("AdmitRulesFragment: %v", err) + } + if got.Class != RuleClassBase { + t.Errorf("class = %q, want %q", got.Class, RuleClassBase) + } + if !got.ClusterWide { + t.Error("base class fragment must be ClusterWide") + } + // The cluster baseline belongs to no bundle: it applies to every workload. + if got.Bundle != "" { + t.Errorf("bundle = %q, want empty for a base fragment", got.Bundle) + } + if len(got.Rules) != 2 { + t.Fatalf("got %d rules, want 2", len(got.Rules)) + } + if got.Signer != rulesSignerIDOf(t, frag) { + t.Errorf("signer = %q", got.Signer) + } +} + +func TestAdmitRulesFragment_OverlayHappyPath(t *testing.T) { + vendor, operator := genKey(t), genKey(t) + baseFrag := signedRules(t, "baseline", "kubescape", "", string(RuleClassBase), []rulemanagertypesv1.Rule{rule("R0001", true)}, vendor) + frag := signedRules(t, "redis-tuning", "redis", "redis", string(RuleClassOverlay), []rulemanagertypesv1.Rule{rule("R0001", false)}, operator) + policy := rulePolicy(rulesSignerIDOf(t, baseFrag), rulesSignerIDOf(t, frag), []string{"R0001"}, []string{"R0001"}) + + got, err := AdmitRulesFragment(frag, policy) + if err != nil { + t.Fatalf("AdmitRulesFragment: %v", err) + } + if got.Class != RuleClassOverlay { + t.Errorf("class = %q, want %q", got.Class, RuleClassOverlay) + } + if got.ClusterWide { + t.Error("overlay class fragment must not be ClusterWide") + } + if got.Bundle != "redis" { + t.Errorf("bundle = %q, want %q", got.Bundle, "redis") + } +} + +// The bundle the agent scopes by must come from the SIGNED labels, never from +// the stored carrier object: relabelling a validly signed overlay must not +// re-target it at another bundle. +func TestAdmitRulesFragment_BundleComesFromSignedLabels(t *testing.T) { + key := genKey(t) + frag := signedRules(t, "redis-tuning", "redis", "redis", string(RuleClassOverlay), []rulemanagertypesv1.Rule{rule("R0001", true)}, key) + policy := rulePolicy("key:none", rulesSignerIDOf(t, frag), []string{"*"}, []string{"*"}) + + // Precondition: the fragment ships with embedded signed content, which is + // what makes the carrier's labels irrelevant. + if _, present, _ := signature.EmbeddedContent(profiles.NewRulesAdapter(frag)); !present { + t.Skip("fragment carries no embedded content; relabelling is caught by the signature instead") + } + + frag.Labels[LabelBundle] = "payments" + + got, err := AdmitRulesFragment(frag, policy) + if err != nil { + t.Fatalf("AdmitRulesFragment: %v", err) + } + if got.Bundle != "redis" { + t.Fatalf("bundle = %q, want the SIGNED bundle %q", got.Bundle, "redis") + } +} + +func TestAdmitRulesFragment_Unsigned(t *testing.T) { + policy := rulePolicy("key:whatever", "key:whatever", []string{"*"}, []string{"*"}) + frag := rulesObject("unsigned", "redis", "redis", string(RuleClassOverlay), []rulemanagertypesv1.Rule{rule("R0001", true)}) + + _, err := AdmitRulesFragment(frag, policy) + if !errors.Is(err, ErrFragmentUnsigned) { + t.Fatalf("want ErrFragmentUnsigned, got %v", err) + } +} + +func TestAdmitRulesFragment_Tampered(t *testing.T) { + // The canonical attack: flip a rule to disabled after the vendor signed it. + key := genKey(t) + frag := signedRules(t, "baseline", "kubescape", "", string(RuleClassBase), []rulemanagertypesv1.Rule{rule("R0001", true)}, key) + policy := rulePolicy(rulesSignerIDOf(t, frag), "key:none", []string{"*"}, []string{"*"}) + if _, err := AdmitRulesFragment(frag, policy); err != nil { + t.Fatalf("precondition: fragment must admit before tampering: %v", err) + } + + frag.Spec.Rules[0].Enabled = false + + _, err := AdmitRulesFragment(frag, policy) + if !errors.Is(err, ErrFragmentTampered) { + t.Fatalf("want ErrFragmentTampered, got %v", err) + } +} + +func TestAdmitRulesFragment_NamespaceIsNotSigned(t *testing.T) { + // A vendor cannot know which namespace a customer installs into, so + // metadata.namespace is NOT part of the signed content: the same signed + // artifact must admit wherever it is placed, and it stays scoped to its + // BUNDLE regardless of the namespace it landed in. + key := genKey(t) + frag := signedRules(t, "redis-tuning", "redis", "redis", string(RuleClassOverlay), []rulemanagertypesv1.Rule{rule("R0001", true)}, key) + policy := rulePolicy("key:none", rulesSignerIDOf(t, frag), []string{"*"}, []string{"*"}) + + for _, ns := range []string{"payments", "team-a", ""} { + frag.Namespace = ns + + v, err := AdmitRulesFragment(frag, policy) + if err != nil { + t.Fatalf("a signed fragment must admit in any namespace (%q), got %v", ns, err) + } + if v.Bundle != "redis" { + t.Fatalf("installed in %q: bundle = %q, want %q — the namespace must not affect scoping", ns, v.Bundle, "redis") + } + } +} + +func TestAdmitRulesFragment_UntrustedSigner(t *testing.T) { + vendor, attacker := genKey(t), genKey(t) + trusted := signedRules(t, "baseline", "kubescape", "", string(RuleClassBase), []rulemanagertypesv1.Rule{rule("R0001", true)}, vendor) + frag := signedRules(t, "rogue", "kubescape", "", string(RuleClassBase), []rulemanagertypesv1.Rule{rule("R0001", false)}, attacker) + policy := rulePolicy(rulesSignerIDOf(t, trusted), "key:none", []string{"*"}, []string{"*"}) + + _, err := AdmitRulesFragment(frag, policy) + if !errors.Is(err, ErrSignerNotTrusted) { + t.Fatalf("want ErrSignerNotTrusted, got %v", err) + } +} + +func TestAdmitRulesFragment_RuleIDNotAllowed(t *testing.T) { + key := genKey(t) + frag := signedRules(t, "redis-tuning", "redis", "redis", string(RuleClassOverlay), []rulemanagertypesv1.Rule{rule("R0001", true), rule("R9999", true)}, key) + policy := rulePolicy("key:none", rulesSignerIDOf(t, frag), []string{"*"}, []string{"R0001"}) + + _, err := AdmitRulesFragment(frag, policy) + if !errors.Is(err, ErrRuleIDNotAllowed) { + t.Fatalf("want ErrRuleIDNotAllowed, got %v", err) + } + if err == nil || !contains(err.Error(), "R9999") { + t.Errorf("error must name the offending rule ID, got %v", err) + } +} + +func TestAdmitRulesFragment_ClassNotAllowed(t *testing.T) { + key := genKey(t) + frag := signedRules(t, "redis-tuning", "redis", "redis", string(RuleClassOverlay), []rulemanagertypesv1.Rule{rule("R0001", true)}, key) + // Policy only knows the base class. + policy := TrustPolicy{RuleClasses: map[RuleClass]RuleClassPolicy{ + RuleClassBase: {Signers: []string{rulesSignerIDOf(t, frag)}, AllowedRuleIDs: []string{"*"}}, + }} + + _, err := AdmitRulesFragment(frag, policy) + if !errors.Is(err, ErrRuleClassNotAllowed) { + t.Fatalf("want ErrRuleClassNotAllowed, got %v", err) + } +} + +func TestAdmitRulesFragment_NoClassLabel(t *testing.T) { + key := genKey(t) + frag := signedRules(t, "unclassed", "redis", "redis", "", []rulemanagertypesv1.Rule{rule("R0001", true)}, key) + policy := rulePolicy(rulesSignerIDOf(t, frag), rulesSignerIDOf(t, frag), []string{"*"}, []string{"*"}) + + _, err := AdmitRulesFragment(frag, policy) + if !errors.Is(err, ErrNoClass) { + t.Fatalf("want ErrNoClass, got %v", err) + } +} + +func TestAdmitRulesFragment_WildcardRuleIDs(t *testing.T) { + key := genKey(t) + frag := signedRules(t, "baseline", "kubescape", "", string(RuleClassBase), []rulemanagertypesv1.Rule{rule("R0001", true), rule("R4242", true), rule("anything", true)}, key) + policy := TrustPolicy{RuleClasses: map[RuleClass]RuleClassPolicy{ + RuleClassBase: {Signers: []string{rulesSignerIDOf(t, frag)}, AllowedRuleIDs: []string{"*"}}, + }} + + got, err := AdmitRulesFragment(frag, policy) + if err != nil { + t.Fatalf("wildcard AllowedRuleIDs must admit any ID: %v", err) + } + if len(got.Rules) != 3 { + t.Errorf("got %d rules, want 3", len(got.Rules)) + } +} + +// An overlay with no bundle label could not be scoped to anything, so it is +// rejected rather than silently applied everywhere or nowhere. +func TestAdmitRulesFragment_OverlayWithoutBundle(t *testing.T) { + key := genKey(t) + frag := signedRules(t, "bundleless", "redis", "", string(RuleClassOverlay), []rulemanagertypesv1.Rule{rule("R0001", true)}, key) + policy := TrustPolicy{RuleClasses: map[RuleClass]RuleClassPolicy{ + RuleClassOverlay: {Signers: []string{rulesSignerIDOf(t, frag)}, AllowedRuleIDs: []string{"*"}}, + }} + + _, err := AdmitRulesFragment(frag, policy) + if !errors.Is(err, ErrRuleBundleRequired) { + t.Fatalf("want ErrRuleBundleRequired, got %v", err) + } +} + +// A base fragment belongs to no bundle, so an empty bundle label is fine. +func TestAdmitRulesFragment_BaseWithoutBundleAdmits(t *testing.T) { + key := genKey(t) + frag := signedRules(t, "baseline", "kubescape", "", string(RuleClassBase), []rulemanagertypesv1.Rule{rule("R0001", true)}, key) + policy := TrustPolicy{RuleClasses: map[RuleClass]RuleClassPolicy{ + RuleClassBase: {Signers: []string{rulesSignerIDOf(t, frag)}, AllowedRuleIDs: []string{"*"}}, + }} + + got, err := AdmitRulesFragment(frag, policy) + if err != nil { + t.Fatalf("a base fragment without a bundle must admit, got %v", err) + } + if got.Bundle != "" || !got.ClusterWide { + t.Fatalf("base fragment shape: bundle=%q clusterWide=%v", got.Bundle, got.ClusterWide) + } +} + +// The vendor ships ONE bundle: the ContainerProfile half and the Rules half +// carry the SAME labels, only the fragment class differs. This pins the +// symmetry — the rule fragment is read with the very constants the profile +// fragments use. +func TestAdmitRulesFragment_SameLabelsAsProfileFragments(t *testing.T) { + key := genKey(t) + frag := signedRules(t, "redis-rules", "anywhere", "redis", string(ClassOverlay), []rulemanagertypesv1.Rule{rule("R0001", true)}, key) + policy := TrustPolicy{RuleClasses: map[RuleClass]RuleClassPolicy{ + RuleClassOverlay: {Signers: []string{rulesSignerIDOf(t, frag)}, AllowedRuleIDs: []string{"*"}}, + }} + + got, err := AdmitRulesFragment(frag, policy) + if err != nil { + t.Fatalf("AdmitRulesFragment: %v", err) + } + if string(got.Class) != string(ClassOverlay) || got.Bundle != "redis" { + t.Fatalf("class=%q bundle=%q, want overlay/redis", got.Class, got.Bundle) + } + // And the profile-fragment class constants are literally the same strings. + if string(RuleClassBase) != string(ClassBase) || string(RuleClassOverlay) != string(ClassOverlay) { + t.Fatal("rule and profile fragment class names must match") + } +} + +func TestRuleClassPolicy_Allows(t *testing.T) { + p := RuleClassPolicy{Signers: []string{"key:a"}, AllowedRuleIDs: []string{"R0001"}} + if !p.allowsSigner("key:a") || p.allowsSigner("key:b") { + t.Error("allowsSigner mismatch") + } + if !p.allowsRuleID("R0001") || p.allowsRuleID("R0002") { + t.Error("allowsRuleID mismatch") + } + // The wildcard is only honoured in AllowedRuleIDs, never in Signers. + w := RuleClassPolicy{Signers: []string{"*"}, AllowedRuleIDs: []string{"*"}} + if !w.allowsRuleID("whatever") { + t.Error("wildcard rule ID must match") + } + if !w.allowsSigner("*") || w.allowsSigner("key:a") { + t.Error("signers must be matched literally, no wildcard") + } +} + +func contains(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} + +// The literal #68 observation: a key that IS trusted — for the base class — +// signs an overlay fragment. Cross-class use of a legitimate key must be +// refused exactly like an unknown key. +func TestAdmitRulesFragment_TrustedBaseKeySigningOverlay(t *testing.T) { + vendor, operator := genKey(t), genKey(t) + baseFrag := signedRules(t, "baseline", "kubescape", "", string(RuleClassBase), []rulemanagertypesv1.Rule{rule("R0001", true)}, vendor) + ovlProbe := signedRules(t, "probe", "redis", "redis", string(RuleClassOverlay), []rulemanagertypesv1.Rule{rule("R0001", true)}, operator) + policy := rulePolicy(rulesSignerIDOf(t, baseFrag), rulesSignerIDOf(t, ovlProbe), []string{"*"}, []string{"R0001", "R0002"}) + + crossClass := signedRules(t, "rogue-overlay", "redis", "redis", string(RuleClassOverlay), []rulemanagertypesv1.Rule{rule("R0001", false)}, vendor) + got, err := AdmitRulesFragment(crossClass, policy) + if !errors.Is(err, ErrSignerNotTrusted) { + t.Fatalf("want ErrSignerNotTrusted for a base-trusted key signing an overlay, got %v", err) + } + if len(got.Rules) != 0 { + t.Fatalf("no rules may load from a cross-class fragment, got %d", len(got.Rules)) + } + + swapped := signedRules(t, "rogue-base", "kubescape", "", string(RuleClassBase), []rulemanagertypesv1.Rule{rule("R0001", false)}, operator) + if _, err := AdmitRulesFragment(swapped, policy); !errors.Is(err, ErrSignerNotTrusted) { + t.Fatalf("want ErrSignerNotTrusted for an overlay-trusted key signing base, got %v", err) + } +} diff --git a/pkg/signature/bundle/signer_identity_test.go b/pkg/signature/bundle/signer_identity_test.go new file mode 100644 index 0000000000..eadf022d84 --- /dev/null +++ b/pkg/signature/bundle/signer_identity_test.go @@ -0,0 +1,52 @@ +//go:build experimental_signing + +package bundle + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/kubescape/node-agent/pkg/signature" + "github.com/stretchr/testify/require" +) + +func selfSigned(t *testing.T, key *ecdsa.PrivateKey, cn string, notAfter time.Time) []byte { + t.Helper() + tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: cn}, Issuer: pkix.Name{CommonName: cn + "-issuer"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: notAfter} + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func TestSignerIdentity_IsThePublicKeyFingerprintAndNothingElse(t *testing.T) { + keyA, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + keyB, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + idA1, err := signerIdentity(&signature.Signature{Certificate: selfSigned(t, keyA, "vendor", time.Now().Add(time.Hour))}) + require.NoError(t, err) + idA2, err := signerIdentity(&signature.Signature{Certificate: selfSigned(t, keyA, "someone-else", time.Now().Add(-time.Minute))}) + require.NoError(t, err) + idB, err := signerIdentity(&signature.Signature{Certificate: selfSigned(t, keyB, "vendor", time.Now().Add(time.Hour))}) + require.NoError(t, err) + + require.Equal(t, idA1, idA2, "subject, issuer and expiry play no part: the same key is the same signer") + require.NotEqual(t, idA1, idB, "the same name with another key is another signer") + require.Regexp(t, `^key:[0-9a-f]{64}$`, idA1) + + _, err = signerIdentity(&signature.Signature{}) + require.Error(t, err, "a signature without a certificate has no identity") + _, err = signerIdentity(nil) + require.Error(t, err) + + require.True(t, ClassPolicy{Signers: []string{idA1}}.allowsSigner(idA2), "trust is pinned to the key fingerprint") + require.False(t, ClassPolicy{Signers: []string{idA1}}.allowsSigner(idB)) +} diff --git a/pkg/signature/bundle/stacking_test.go b/pkg/signature/bundle/stacking_test.go new file mode 100644 index 0000000000..19f2419fd9 --- /dev/null +++ b/pkg/signature/bundle/stacking_test.go @@ -0,0 +1,476 @@ +//go:build experimental_signing + +// Stacking truth table for bundle-fragment composition: which spec surfaces +// are ADDITIVE (union across fragments), which are REPLACEMENT (keyed, the +// higher-precedence class wins wholly), and that multi-stacked composites are +// deterministic regardless of input order (canonical order: class precedence +// base < admission < overlay, then signer, then digest). +package bundle + +import ( + "crypto/ecdsa" + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func stackPolicy(baseID, admissionID, overlayID string) TrustPolicy { + all := []string{"architectures", "capabilities", "execs", "opens", "syscalls", "rulePolicies", "endpoints", "identifiedCallStacks", "labelSelector", "ingress", "egress", "imageID", "imageTag", "seccompProfile"} + return TrustPolicy{Classes: map[FragmentClass]ClassPolicy{ + ClassBase: {Signers: []string{baseID}, AllowedSpecPaths: all}, + ClassAdmission: {Signers: []string{admissionID}, AllowedSpecPaths: all}, + ClassOverlay: {Signers: []string{overlayID}, AllowedSpecPaths: all}, + }} +} + +func egressEntry(id string, port int32) v1beta1.NetworkNeighbor { + return v1beta1.NetworkNeighbor{ + Identifier: id, Type: "internal", + PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "peer"}}, + Ports: []v1beta1.NetworkPort{{Name: "TCP", Port: &port, Protocol: "TCP"}}, + } +} + +// P-A1/P-A2: disjoint contributions from stacked fragments UNION into the +// composite — exec/open lists appended (deduped), string sets merged. +func TestStacking_AdditiveUnion(t *testing.T) { + vendor, op, ov := genKey(t), genKey(t), genKey(t) + base := fragment(t, "app", "base", v1beta1.ContainerProfileSpec{ + Architectures: []string{"amd64"}, + Capabilities: []string{"NET_BIND_SERVICE"}, + Execs: []v1beta1.ExecCalls{{Path: "/bin/server", Args: []string{"server"}}}, + Opens: []v1beta1.OpenCalls{{Path: "/etc/app.conf", Flags: []string{"O_RDONLY"}}}, + }, vendor) + overlay := fragment(t, "app-extra", "overlay", v1beta1.ContainerProfileSpec{ + Architectures: []string{"arm64"}, + Capabilities: []string{"CHOWN"}, + Execs: []v1beta1.ExecCalls{{Path: "/bin/backup", Args: []string{"backup"}}}, + }, ov) + policy := stackPolicy(signerIDOf(t, base), signerIDOf(t, base), signerIDOf(t, overlay)) + _ = op + + composite, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, overlay}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + if len(composite.Spec.Execs) != 2 { + t.Errorf("execs must union across fragments: got %+v", composite.Spec.Execs) + } + if len(composite.Spec.Architectures) != 2 || len(composite.Spec.Capabilities) != 2 { + t.Errorf("set fields must union: arch=%v caps=%v", composite.Spec.Architectures, composite.Spec.Capabilities) + } + if len(composite.Spec.Opens) != 1 { + t.Errorf("base opens must survive: %+v", composite.Spec.Opens) + } +} + +// P-R1: a network entry with the SAME Identifier is REPLACED WHOLLY by the +// higher-precedence class — ports are not merged across fragments. +func TestStacking_ReplacementByIdentifier(t *testing.T) { + vendor, ov := genKey(t), genKey(t) + base := fragment(t, "app", "base", v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{egressEntry("db", 5432)}, + }, vendor) + overlay := fragment(t, "app-egress", "overlay", v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{egressEntry("db", 5433)}, + }, ov) + policy := stackPolicy(signerIDOf(t, base), signerIDOf(t, base), signerIDOf(t, overlay)) + + composite, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, overlay}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + if len(composite.Spec.Egress) != 1 { + t.Fatalf("same-Identifier entries must collapse to one: %+v", composite.Spec.Egress) + } + ports := composite.Spec.Egress[0].Ports + if len(ports) != 1 || ports[0].Port == nil || *ports[0].Port != 5433 { + t.Errorf("overlay must replace the entry WHOLLY (no port merge): %+v", ports) + } +} + +// P-R2: PolicyByRuleId entries with the same rule ID are replaced wholly by +// the higher-precedence class. +func TestStacking_RulePolicyReplacement(t *testing.T) { + vendor, ov := genKey(t), genKey(t) + base := fragment(t, "app", "base", v1beta1.ContainerProfileSpec{ + PolicyByRuleId: map[string]v1beta1.RulePolicy{"R0001": {AllowedProcesses: []string{"/bin/a"}}}, + }, vendor) + overlay := fragment(t, "app-pol", "overlay", v1beta1.ContainerProfileSpec{ + PolicyByRuleId: map[string]v1beta1.RulePolicy{"R0001": {AllowedProcesses: []string{"/bin/b"}}}, + }, ov) + policy := stackPolicy(signerIDOf(t, base), signerIDOf(t, base), signerIDOf(t, overlay)) + + composite, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, overlay}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + got := composite.Spec.PolicyByRuleId["R0001"].AllowedProcesses + if len(got) != 1 || got[0] != "/bin/b" { + t.Errorf("overlay rule policy must replace base's wholly: %v", got) + } +} + +// P-M1: three stacked classes — additive fields are the 3-way union; +// replacement fields follow full precedence (overlay > admission > base). +func TestStacking_MultiStackThreeClasses(t *testing.T) { + vendor, op, ov := genKey(t), genKey(t), genKey(t) + base := fragment(t, "app", "base", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/server", Args: []string{"server"}}}, + Egress: []v1beta1.NetworkNeighbor{egressEntry("db", 1111)}, + }, vendor) + adm := fragment(t, "app-adm", "admission", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/probe", Args: []string{"probe"}}}, + Egress: []v1beta1.NetworkNeighbor{egressEntry("db", 2222)}, + }, op) + overlay := fragment(t, "app-ov", "overlay", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/backup", Args: []string{"backup"}}}, + Egress: []v1beta1.NetworkNeighbor{egressEntry("db", 3333)}, + }, ov) + policy := stackPolicy(signerIDOf(t, base), signerIDOf(t, adm), signerIDOf(t, overlay)) + + composite, manifest, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, adm, overlay}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + if len(composite.Spec.Execs) != 3 { + t.Errorf("3-way additive union of execs: %+v", composite.Spec.Execs) + } + if len(composite.Spec.Egress) != 1 || *composite.Spec.Egress[0].Ports[0].Port != 3333 { + t.Errorf("highest class (overlay) must win the keyed entry: %+v", composite.Spec.Egress) + } + if len(manifest.Leaves) != 3 { + t.Errorf("manifest must carry all 3 members: %d", len(manifest.Leaves)) + } +} + +// P-A4: the composite spec AND Merkle root are invariant under every input +// permutation of a 3-fragment stack. +func TestStacking_PermutationInvariance(t *testing.T) { + vendor, op, ov := genKey(t), genKey(t), genKey(t) + base := fragment(t, "app", "base", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/server", Args: []string{"server"}}}, + Egress: []v1beta1.NetworkNeighbor{egressEntry("db", 1111)}, + }, vendor) + adm := fragment(t, "app-adm", "admission", v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{egressEntry("db", 2222)}, + }, op) + overlay := fragment(t, "app-ov", "overlay", v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{egressEntry("cache", 6379)}, + }, ov) + policy := stackPolicy(signerIDOf(t, base), signerIDOf(t, adm), signerIDOf(t, overlay)) + + perms := [][]*v1beta1.ContainerProfile{ + {base, adm, overlay}, {base, overlay, adm}, {adm, base, overlay}, + {adm, overlay, base}, {overlay, base, adm}, {overlay, adm, base}, + } + var wantRoot, wantSpec string + for i, p := range perms { + composite, manifest, err := AssembleAndVerify("redis", "redis", p, policy) + if err != nil { + t.Fatalf("perm %d: %v", i, err) + } + spec := jsonKey(composite.Spec) + if i == 0 { + wantRoot, wantSpec = manifest.Root, spec + continue + } + if manifest.Root != wantRoot { + t.Errorf("perm %d: Merkle root depends on input order", i) + } + if spec != wantSpec { + t.Errorf("perm %d: composite spec depends on input order", i) + } + } + // Sanity on the fixed point: admission (class 1) beat base (class 0) on "db". + composite, _, _ := AssembleAndVerify("redis", "redis", perms[0], policy) + byID := map[string]int32{} + for _, e := range composite.Spec.Egress { + byID[e.Identifier] = *e.Ports[0].Port + } + if byID["db"] != 2222 || byID["cache"] != 6379 { + t.Errorf("expected db→2222 (admission wins), cache→6379: %v", byID) + } +} + +// P-M2: two fragments of the SAME class also stack deterministically — the +// canonical (signer, digest) tiebreak makes the same-Identifier winner stable +// under permutation. +func TestStacking_SameClassTieIsDeterministic(t *testing.T) { + vendor := genKey(t) + ovKeyA, ovKeyB := genKey(t), genKey(t) + base := fragment(t, "app", "base", baseSpec(), vendor) + ovA := fragment(t, "app-ov-a", "overlay", v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{egressEntry("db", 1000)}, + }, ovKeyA) + ovB := fragment(t, "app-ov-b", "overlay", v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{egressEntry("db", 2000)}, + }, ovKeyB) + policy := TrustPolicy{Classes: map[FragmentClass]ClassPolicy{ + ClassBase: {Signers: []string{signerIDOf(t, base)}, AllowedSpecPaths: []string{"architectures", "execs", "opens"}}, + ClassOverlay: {Signers: []string{signerIDOf(t, ovA), signerIDOf(t, ovB)}, AllowedSpecPaths: []string{"egress"}}, + }} + + c1, m1, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, ovA, ovB}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + c2, m2, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{ovB, base, ovA}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + if m1.Root != m2.Root || jsonKey(c1.Spec) != jsonKey(c2.Spec) { + t.Errorf("same-class stacking must be order-invariant (signer/digest tiebreak)") + } + if len(c1.Spec.Egress) != 1 { + t.Errorf("same-Identifier same-class entries must still collapse to one: %+v", c1.Spec.Egress) + } +} + +// P-A3: duplicated fragment content must not duplicate composite entries. +func TestStacking_DuplicateContentIsIdempotent(t *testing.T) { + vendor, ov := genKey(t), genKey(t) + base := fragment(t, "app", "base", baseSpec(), vendor) + dup := fragment(t, "app-dup", "overlay", baseSpec(), ov) // same content, different class/name + policy := stackPolicy(signerIDOf(t, base), signerIDOf(t, base), signerIDOf(t, dup)) + + composite, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, dup}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + if len(composite.Spec.Execs) != 1 || len(composite.Spec.Opens) != 1 { + t.Errorf("identical entries across fragments must dedup: execs=%+v opens=%+v", composite.Spec.Execs, composite.Spec.Opens) + } +} + +var _ = ecdsa.PrivateKey{} // keep ecdsa import when helpers move + +// ---- Wave 2: complex multi-entry overlays ---------------------------------- + +// W1: one overlay carrying MANY entries — some colliding, some new. Colliding +// Identifiers replace wholly; new ones add; untouched base keys survive. +func TestStacking_MultiKeyMixedAddAndReplace(t *testing.T) { + vendor, ov := genKey(t), genKey(t) + base := fragment(t, "app", "base", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/server", Args: []string{"server"}}}, + Egress: []v1beta1.NetworkNeighbor{egressEntry("db", 5432), egressEntry("cache", 6379)}, + }, vendor) + overlay := fragment(t, "app-multi", "overlay", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/backup", Args: []string{"backup"}}, {Path: "/bin/probe", Args: []string{"probe"}}}, + Egress: []v1beta1.NetworkNeighbor{ + egressEntry("db", 5433), // collides -> replaces + egressEntry("s3", 9000), // new + egressEntry("metrics", 9090), // new + }, + }, ov) + policy := stackPolicy(signerIDOf(t, base), signerIDOf(t, base), signerIDOf(t, overlay)) + + composite, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, overlay}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + byID := map[string]int32{} + for _, e := range composite.Spec.Egress { + byID[e.Identifier] = *e.Ports[0].Port + } + want := map[string]int32{"db": 5433, "cache": 6379, "s3": 9000, "metrics": 9090} + if len(byID) != 4 { + t.Fatalf("mixed add+replace must yield 4 keys: %v", byID) + } + for k, p := range want { + if byID[k] != p { + t.Errorf("key %s: port %d, want %d (collide→replace, new→add, untouched→survive)", k, byID[k], p) + } + } + if len(composite.Spec.Execs) != 3 { + t.Errorf("multi-entry exec union: got %d, want 3", len(composite.Spec.Execs)) + } +} + +// W2: key aliasing BYPASSES replacement — an overlay that replaces db's port +// can simultaneously re-grant the old port under a fresh Identifier for the +// SAME address; the effective allow-set is the union over entries, so the +// "replaced" port remains reachable. This pins the actual behavior: keyed +// replacement constrains the KEY, not the address, and reviewers of overlay +// fragments must treat new Identifiers on known addresses as grants. +func TestStacking_AliasedKeyBypassesReplacement(t *testing.T) { + mk := func(id, ip string, port int32) v1beta1.NetworkNeighbor { + return v1beta1.NetworkNeighbor{ + Identifier: id, Type: "external", IPAddress: ip, + Ports: []v1beta1.NetworkPort{{Name: "TCP", Port: &port, Protocol: "TCP"}}, + } + } + vendor, ov := genKey(t), genKey(t) + base := fragment(t, "app", "base", v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{mk("db", "10.0.0.5", 5432)}, + }, vendor) + overlay := fragment(t, "app-alias", "overlay", v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{ + mk("db", "10.0.0.5", 5433), // replaces the key: 5432 -> 5433 + mk("db-legacy", "10.0.0.5", 5432), // aliases the old grant back in + }, + }, ov) + policy := stackPolicy(signerIDOf(t, base), signerIDOf(t, base), signerIDOf(t, overlay)) + + composite, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, overlay}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + if len(composite.Spec.Egress) != 2 { + t.Fatalf("alias adds a second entry for the same address: %+v", composite.Spec.Egress) + } + allowed := map[int32]bool{} + for _, e := range composite.Spec.Egress { + if e.IPAddress == "10.0.0.5" { + for _, p := range e.Ports { + allowed[*p.Port] = true + } + } + } + if !allowed[5433] || !allowed[5432] { + t.Fatalf("effective allow-set for 10.0.0.5 is the union over entries: %v", allowed) + } +} + +// W3: near-duplicate additive entries BALLOON — jsonKey dedup is byte-exact, +// so arg variants of one path all survive, and same-path opens with different +// flags are kept as separate entries (flags are NOT merged during assembly, +// unlike storage's learned-profile merge which folds flags per path). +func TestStacking_NearDuplicatesBalloonNotMerge(t *testing.T) { + vendor, ov := genKey(t), genKey(t) + base := fragment(t, "app", "base", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/sh", Args: []string{"-c", "a"}}}, + Opens: []v1beta1.OpenCalls{{Path: "/etc/cfg", Flags: []string{"O_RDONLY"}}}, + }, vendor) + overlay := fragment(t, "app-near", "overlay", v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sh", Args: []string{"-c", "b"}}, // variant -> kept + {Path: "/bin/sh", Args: []string{"-c", "a"}}, // exact dup -> folded + }, + Opens: []v1beta1.OpenCalls{{Path: "/etc/cfg", Flags: []string{"O_RDWR"}}}, // same path, new flags -> kept + }, ov) + policy := stackPolicy(signerIDOf(t, base), signerIDOf(t, base), signerIDOf(t, overlay)) + + composite, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, overlay}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + if len(composite.Spec.Execs) != 2 { + t.Errorf("arg variants of one path are distinct entries (exact dup folds): got %d, want 2", len(composite.Spec.Execs)) + } + if len(composite.Spec.Opens) != 2 { + t.Errorf("same-path opens with different flags stay separate (no flag merge at assembly): got %d, want 2", len(composite.Spec.Opens)) + } +} + +// W4: rulePolicies maps with PARTIAL key overlap — overlapping IDs replaced, +// disjoint IDs from both sides survive. +func TestStacking_RulePoliciesPartialOverlap(t *testing.T) { + vendor, ov := genKey(t), genKey(t) + base := fragment(t, "app", "base", v1beta1.ContainerProfileSpec{ + PolicyByRuleId: map[string]v1beta1.RulePolicy{ + "R0001": {AllowedProcesses: []string{"/bin/a"}}, + "R0004": {AllowedProcesses: []string{"/bin/b"}}, + "R0011": {AllowedContainer: true}, + }, + }, vendor) + overlay := fragment(t, "app-pol2", "overlay", v1beta1.ContainerProfileSpec{ + PolicyByRuleId: map[string]v1beta1.RulePolicy{ + "R0004": {AllowedProcesses: []string{"/bin/x"}}, + "R0999": {AllowedContainer: true}, + }, + }, ov) + policy := stackPolicy(signerIDOf(t, base), signerIDOf(t, base), signerIDOf(t, overlay)) + + composite, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, overlay}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + got := composite.Spec.PolicyByRuleId + if len(got) != 4 { + t.Fatalf("partial overlap must keep 4 rule IDs: %v", got) + } + if got["R0004"].AllowedProcesses[0] != "/bin/x" { + t.Errorf("overlapping ID replaced wholly: %v", got["R0004"]) + } + if got["R0001"].AllowedProcesses[0] != "/bin/a" || !got["R0011"].AllowedContainer || !got["R0999"].AllowedContainer { + t.Errorf("disjoint IDs from both sides must survive: %v", got) + } +} + +// W5: two SAME-class overlays with disjoint AND colliding keys in one stack: +// disjoint keys union, the colliding key resolves deterministically across +// input orders. +func TestStacking_SameClassMultiFragmentMixedKeys(t *testing.T) { + vendor, ka, kb := genKey(t), genKey(t), genKey(t) + base := fragment(t, "app", "base", baseSpec(), vendor) + ovA := fragment(t, "app-ov-a", "overlay", v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{egressEntry("only-a", 1000), egressEntry("shared", 1111)}, + }, ka) + ovB := fragment(t, "app-ov-b", "overlay", v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{egressEntry("only-b", 2000), egressEntry("shared", 2222)}, + }, kb) + policy := TrustPolicy{Classes: map[FragmentClass]ClassPolicy{ + ClassBase: {Signers: []string{signerIDOf(t, base)}, AllowedSpecPaths: []string{"architectures", "execs", "opens"}}, + ClassOverlay: {Signers: []string{signerIDOf(t, ovA), signerIDOf(t, ovB)}, AllowedSpecPaths: []string{"egress"}}, + }} + + c1, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, ovA, ovB}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + c2, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{ovB, ovA, base}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + if len(c1.Spec.Egress) != 3 { + t.Fatalf("disjoint keys union, colliding key collapses: %+v", c1.Spec.Egress) + } + if jsonKey(c1.Spec) != jsonKey(c2.Spec) { + t.Errorf("same-class multi-fragment stack must be input-order invariant") + } +} + +// W7: selector and scalar precedence — matchLabels override-merge per key, +// matchExpressions APPEND (near-dups kept), scalars last-non-empty-writer +// (an empty higher-class value does NOT erase the base's). +func TestStacking_SelectorAndScalarPrecedence(t *testing.T) { + vendor, ov := genKey(t), genKey(t) + expr := metav1.LabelSelectorRequirement{Key: "tier", Operator: metav1.LabelSelectorOpIn, Values: []string{"db"}} + base := fragment(t, "app", "base", v1beta1.ContainerProfileSpec{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "x", "tier": "db"}, + MatchExpressions: []metav1.LabelSelectorRequirement{expr}, + }, + ImageID: "sha256:base-id", + ImageTag: "registry/app:1.0", + }, vendor) + overlay := fragment(t, "app-sel", "overlay", v1beta1.ContainerProfileSpec{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "cache", "extra": "1"}, + MatchExpressions: []metav1.LabelSelectorRequirement{expr}, // exact dup — appended anyway + }, + ImageTag: "registry/app:2.0", // overrides + // ImageID deliberately empty — must NOT erase the base's + }, ov) + policy := stackPolicy(signerIDOf(t, base), signerIDOf(t, base), signerIDOf(t, overlay)) + + composite, _, err := AssembleAndVerify("redis", "redis", []*v1beta1.ContainerProfile{base, overlay}, policy) + if err != nil { + t.Fatalf("AssembleAndVerify: %v", err) + } + ml := composite.Spec.MatchLabels + if ml["app"] != "x" || ml["tier"] != "cache" || ml["extra"] != "1" { + t.Errorf("matchLabels override-merge per key: %v", ml) + } + if len(composite.Spec.MatchExpressions) != 2 { + t.Errorf("matchExpressions APPEND without dedup (2 copies of the same expr): got %d", len(composite.Spec.MatchExpressions)) + } + if composite.Spec.ImageTag != "registry/app:2.0" { + t.Errorf("scalar last-writer: ImageTag = %q", composite.Spec.ImageTag) + } + if composite.Spec.ImageID != "sha256:base-id" { + t.Errorf("empty higher-class scalar must not erase the base's: ImageID = %q", composite.Spec.ImageID) + } +} diff --git a/pkg/signature/bundle/status.go b/pkg/signature/bundle/status.go new file mode 100644 index 0000000000..623aed49f7 --- /dev/null +++ b/pkg/signature/bundle/status.go @@ -0,0 +1,103 @@ +//go:build experimental_signing + +package bundle + +import ( + "sync" + "time" +) + +// PolicyStatus is the operator-queryable identity of the trust policy in +// force. It exists because after a refused reload the mounted ConfigMap +// contains the REFUSED artifact: the cluster is the wrong place to ask what is +// enforced, so the agent must answer. Exposes digests, mode and counts only — +// never the policy body and never per-class signer fingerprints, so an +// unauthenticated read path cannot bypass ConfigMap RBAC or enumerate trusted +// signers. +type PolicyStatus struct { + InForceDigest string `json:"inForceDigest"` + Mode string `json:"mode"` + RootFingerprint string `json:"rootFingerprint"` + RootAnchor string `json:"rootAnchor"` + PolicyVersion int64 `json:"policyVersion"` + RuleClassCount int `json:"ruleClassCount"` + BindingClassCount int `json:"bindingClassCount"` + AppliedAt time.Time `json:"appliedAt"` + LastRefusedDigest string `json:"lastRefusedDigest,omitempty"` + LastRefusedAt time.Time `json:"lastRefusedAt,omitempty"` + RulesAdmitted int `json:"rulesAdmitted"` + RulesRejected int `json:"rulesRejected"` + EffectiveRules int `json:"effectiveRules"` +} + +var ( + policyStatusMu sync.Mutex + policyStatus *PolicyStatus +) + +func SetInForcePolicy(p *TrustPolicy, digest, rootFp string, mounted bool) { + anchor := "compiled" + if mounted { + anchor = "mounted" + } else if IsDemoRoot(rootFp) { + anchor = "demo" + } + policyStatusMu.Lock() + defer policyStatusMu.Unlock() + next := &PolicyStatus{ + InForceDigest: digest, + Mode: string(p.EffectiveMode()), + RootFingerprint: rootFp, + RootAnchor: anchor, + PolicyVersion: p.PolicyVersion, + RuleClassCount: len(p.RuleClasses), + BindingClassCount: len(p.BindingClasses), + AppliedAt: time.Now().UTC(), + } + if policyStatus != nil { + next.LastRefusedDigest = policyStatus.LastRefusedDigest + next.LastRefusedAt = policyStatus.LastRefusedAt + next.RulesAdmitted = policyStatus.RulesAdmitted + next.RulesRejected = policyStatus.RulesRejected + next.EffectiveRules = policyStatus.EffectiveRules + } + policyStatus = next +} + +func RecordPolicyRefusal(refusedDigest string) { + policyStatusMu.Lock() + defer policyStatusMu.Unlock() + if policyStatus == nil { + policyStatus = &PolicyStatus{} + } + policyStatus.LastRefusedDigest = refusedDigest + policyStatus.LastRefusedAt = time.Now().UTC() +} + +func RecordRuleAdmission(admitted, rejected, effective int) { + policyStatusMu.Lock() + defer policyStatusMu.Unlock() + if policyStatus == nil { + return + } + policyStatus.RulesAdmitted = admitted + policyStatus.RulesRejected = rejected + policyStatus.EffectiveRules = effective +} + +// PolicyStatusSnapshot returns a copy, nil when no policy was ever in force. +func PolicyStatusSnapshot() *PolicyStatus { + policyStatusMu.Lock() + defer policyStatusMu.Unlock() + if policyStatus == nil || policyStatus.AppliedAt.IsZero() { + return nil + } + s := *policyStatus + return &s +} + +func resetPolicyStatusForTest() { + policyStatusMu.Lock() + defer policyStatusMu.Unlock() + policyStatus = nil +} diff --git a/pkg/signature/bundle/status_test.go b/pkg/signature/bundle/status_test.go new file mode 100644 index 0000000000..947700cc2a --- /dev/null +++ b/pkg/signature/bundle/status_test.go @@ -0,0 +1,59 @@ +//go:build experimental_signing + +package bundle + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPolicyStatus_LifecycleAndCounters(t *testing.T) { + resetPolicyStatusForTest() + require.Nil(t, PolicyStatusSnapshot(), "no status before any policy is in force") + + RecordRuleAdmission(1, 2, 3) + require.Nil(t, PolicyStatusSnapshot(), "counters alone must not fabricate an in-force policy") + + p := &TrustPolicy{Mode: ModeEnforce, RuleClasses: map[RuleClass]RuleClassPolicy{RuleClassBase: {}}, PolicyVersion: 7} + SetInForcePolicy(p, "d1", DemoRootFingerprint, false) + s := PolicyStatusSnapshot() + require.Equal(t, "d1", s.InForceDigest) + require.Equal(t, "enforce", s.Mode) + require.Equal(t, "demo", s.RootAnchor) + require.Equal(t, int64(7), s.PolicyVersion) + require.Equal(t, 1, s.RuleClassCount) + + RecordRuleAdmission(2, 1, 28) + RecordPolicyRefusal("bad1") + s = PolicyStatusSnapshot() + require.Equal(t, 2, s.RulesAdmitted) + require.Equal(t, 28, s.EffectiveRules) + require.Equal(t, "bad1", s.LastRefusedDigest) + require.Equal(t, "d1", s.InForceDigest, "a refusal must not move the in-force digest") + + SetInForcePolicy(&TrustPolicy{}, "d2", "key:real", true) + s = PolicyStatusSnapshot() + require.Equal(t, "d2", s.InForceDigest) + require.Equal(t, "mounted", s.RootAnchor) + require.Equal(t, "bad1", s.LastRefusedDigest, "refusal history survives a later apply") + require.Equal(t, 2, s.RulesAdmitted, "counters survive a policy apply") + + snap := PolicyStatusSnapshot() + snap.InForceDigest = "mutated" + require.Equal(t, "d2", PolicyStatusSnapshot().InForceDigest, "snapshot must be a copy") + resetPolicyStatusForTest() +} + +func TestPolicyStatus_RefusalBeforeFirstAppliedPolicy(t *testing.T) { + resetPolicyStatusForTest() + t.Cleanup(resetPolicyStatusForTest) + RecordPolicyRefusal("invalid-boot-policy") + require.Nil(t, PolicyStatusSnapshot(), "a refused policy must not fabricate in-force identity") + SetInForcePolicy(demoPolicy(), "verified-policy", "key:root", true) + status := PolicyStatusSnapshot() + require.NotNil(t, status) + require.Equal(t, "verified-policy", status.InForceDigest) + require.Equal(t, "invalid-boot-policy", status.LastRefusedDigest) + require.False(t, status.LastRefusedAt.IsZero(), "keep refusal history for the first real policy") +} diff --git a/pkg/signature/bundle/verify.go b/pkg/signature/bundle/verify.go new file mode 100644 index 0000000000..be19421cae --- /dev/null +++ b/pkg/signature/bundle/verify.go @@ -0,0 +1,309 @@ +//go:build experimental_signing + +package bundle + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Admissibility failure sentinels. ErrFragmentTampered is the one the caller +// should surface as an R1016 tamper alert; the rest are policy rejections. +var ( + ErrNoClass = errors.New("fragment has no fragment-class label") + ErrClassNotAllowed = errors.New("fragment class not present in trust policy") + ErrFragmentUnsigned = errors.New("fragment is not signed") + ErrFragmentTampered = errors.New("fragment signature does not verify (tampered)") + ErrSignerNotTrusted = errors.New("signer not permitted for this fragment class") + ErrPathNotAllowed = errors.New("fragment sets a spec path not permitted for its class") + ErrEmptyBundle = errors.New("bundle has no fragments") + ErrGroupedFragment = errors.New("grouped container profiles are not supported as bundle fragments") + // ErrFragmentRollback is a replay of an older, still-validly-signed fragment: + // its version is below the highest already accepted for its slot. It is NOT a + // tamper (the signature verifies), so it does not raise R1016; it is refused + // and the workload keeps its last verified composite. + ErrFragmentRollback = errors.New("fragment version is below the highest already accepted (rollback)") +) + +type verifiedFragment struct { + name string + spec v1beta1.ContainerProfileSpec + class FragmentClass + signer string + digest string + version int64 +} + +// fragmentVersion reads the monotonic version from the verified labels. Absent +// is version 0 (back-compatible); a present-but-unparseable value is an error, +// since it appears inside signed content and must not be silently ignored. +func fragmentVersion(labels map[string]string) (int64, error) { + raw, ok := labels[LabelVersion] + if !ok || raw == "" { + return 0, nil + } + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse %s=%q: %w", LabelVersion, raw, err) + } + if v < 0 { + return 0, fmt.Errorf("%s=%d must not be negative", LabelVersion, v) + } + return v, nil +} + +// embeddedView is the canonical signed content layout produced by the +// ContainerProfile adapter's GetContent. +type embeddedView struct { + Metadata struct { + Name string `json:"name"` + Labels map[string]string `json:"labels"` + } `json:"metadata"` + Spec v1beta1.ContainerProfileSpec `json:"spec"` +} + +// admitFragment runs the full admissibility check on one fragment: it must be +// class-labeled, signed, verify cleanly, be signed by a class-trusted signer, +// and set only spec paths its class is allowed to contribute. +// +// When the fragment carries embedded signed content (vendor-shipped artifacts, +// signed with --embed-content), the EMBEDDED bytes are the verified source of +// truth: labels (class + bundle membership), spec, name, and the leaf digest +// all come from them — the stored object is just a carrier whose spec the +// server may have normalised. Without embedded content the stored form is +// verified directly (legacy sign-after-roundtrip artifacts). +func admitFragment(cp *v1beta1.ContainerProfile, bundleName string, policy TrustPolicy) (verifiedFragment, error) { + adapter := profiles.NewContainerProfileAdapter(cp) + if !signature.IsSigned(adapter) { + return verifiedFragment{}, fmt.Errorf("%w: %q", ErrFragmentUnsigned, cp.Name) + } + if err := signature.VerifyObjectAllowUntrusted(adapter); err != nil { + if errors.Is(err, signature.ErrSignatureMismatch) { + return verifiedFragment{}, fmt.Errorf("%w: %q: %v", ErrFragmentTampered, cp.Name, err) + } + return verifiedFragment{}, fmt.Errorf("verify fragment %q: %w", cp.Name, err) + } + + // Establish the verified view: embedded content when present, else the + // stored object. + name := cp.Name + labels := cp.Labels + spec := cp.Spec + var digest string + if embedded, present, embErr := signature.EmbeddedContent(adapter); present { + if embErr != nil { + return verifiedFragment{}, fmt.Errorf("embedded content of %q: %w", cp.Name, embErr) + } + var view embeddedView + if err := json.Unmarshal(embedded, &view); err != nil { + return verifiedFragment{}, fmt.Errorf("parse embedded content of %q: %w", cp.Name, err) + } + name = view.Metadata.Name + labels = view.Metadata.Labels + spec = view.Spec + h, err := signature.HashBytes(embedded) + if err != nil { + return verifiedFragment{}, fmt.Errorf("digest of %q: %w", cp.Name, err) + } + digest = h + } else { + h, err := contentDigest(cp) + if err != nil { + return verifiedFragment{}, fmt.Errorf("digest of %q: %w", cp.Name, err) + } + digest = h + } + + // Bundle membership from the VERIFIED labels: a signed fragment cannot be + // re-labeled into another bundle, because the labels are inside the signed + // content. + if labels[LabelBundle] != bundleName { + return verifiedFragment{}, fmt.Errorf("%w: fragment %q belongs to bundle %q, not %q", ErrNoClass, name, labels[LabelBundle], bundleName) + } + class := FragmentClass(labels[LabelFragmentClass]) + if class == "" { + return verifiedFragment{}, fmt.Errorf("%w: %q", ErrNoClass, name) + } + cpol, ok := policy.Classes[class] + if !ok { + return verifiedFragment{}, fmt.Errorf("%w: class %q (fragment %q)", ErrClassNotAllowed, class, name) + } + + sig, err := signature.GetObjectSignature(adapter) + if err != nil { + return verifiedFragment{}, fmt.Errorf("read signature of %q: %w", name, err) + } + signer, err := signerIdentity(sig) + if err != nil { + return verifiedFragment{}, fmt.Errorf("signer identity of %q: %w", name, err) + } + if !cpol.allowsSigner(signer) { + return verifiedFragment{}, fmt.Errorf("%w: signer %q, class %q (fragment %q)", ErrSignerNotTrusted, signer, class, name) + } + + // Assembly and class-path restrictions currently support flat specs only. + // Reject grouped grants from the verified view rather than silently dropping + // them while producing a seemingly valid composite and manifest. + if len(spec.Containers) > 0 || len(spec.InitContainers) > 0 || len(spec.EphemeralContainers) > 0 { + return verifiedFragment{}, fmt.Errorf("%w: %q", ErrGroupedFragment, name) + } + + for _, p := range setSpecPaths(&spec) { + if !cpol.allowsPath(p) { + return verifiedFragment{}, fmt.Errorf("%w: class %q may not set spec.%s (fragment %q)", ErrPathNotAllowed, class, p, name) + } + } + + version, err := fragmentVersion(labels) + if err != nil { + return verifiedFragment{}, fmt.Errorf("version of %q: %w", name, err) + } + + return verifiedFragment{name: name, spec: spec, class: class, signer: signer, digest: digest, version: version}, nil +} + +// AssembleAndVerify verifies every fragment against the trust policy, +// deterministically assembles the admissible fragments into one composite +// ContainerProfile, and stamps the composite with a Merkle-bound bundle +// manifest. It fails closed: a single inadmissible or tampered fragment rejects +// the whole bundle (the returned error wraps the relevant sentinel). +// +// The composite is NOT signed on-cluster: node-agent only verifies fragment +// signatures. The composite is verified by construction from the just-verified +// fragments and is trusted in-process by the caller. +func AssembleAndVerify(name, namespace string, fragments []*v1beta1.ContainerProfile, policy TrustPolicy) (*v1beta1.ContainerProfile, *BundleManifest, error) { + if len(fragments) == 0 { + return nil, nil, ErrEmptyBundle + } + + verified := make([]verifiedFragment, 0, len(fragments)) + for _, f := range fragments { + vf, err := admitFragment(f, name, policy) + if err != nil { + return nil, nil, err + } + verified = append(verified, vf) + } + return assembleVerified(name, namespace, verified) +} + +// DroppedFragment is a fragment AssembleAndVerifyPartial refused as a member, +// with the error that excluded it. +type DroppedFragment struct { + Name string + Bundle string + Class FragmentClass + Reason error +} + +// AssembleAndVerifyPartial verifies each fragment, SKIPS every inadmissible one +// (returned in dropped), and assembles the admissible remainder. Membership is +// authenticated (only class-trusted signers count), so an injected non-member +// cannot deny the bundle. ErrEmptyBundle when nothing admissible remains; the +// caller uses its own known-good state to decide if a drop is a member tamper. +func AssembleAndVerifyPartial(name, namespace string, fragments []*v1beta1.ContainerProfile, policy TrustPolicy) (*v1beta1.ContainerProfile, *BundleManifest, []DroppedFragment, error) { + verified := make([]verifiedFragment, 0, len(fragments)) + var dropped []DroppedFragment + for _, f := range fragments { + vf, err := admitFragment(f, name, policy) + if err != nil { + dropped = append(dropped, DroppedFragment{ + Name: f.Name, + Bundle: name, + Class: FragmentClass(f.Labels[LabelFragmentClass]), + Reason: err, + }) + continue + } + verified = append(verified, vf) + } + if len(verified) == 0 { + return nil, nil, dropped, ErrEmptyBundle + } + composite, manifest, err := assembleVerified(name, namespace, verified) + return composite, manifest, dropped, err +} + +// assembleVerified deterministically assembles already-verified fragments into +// the composite + Merkle-bound manifest. Shared by AssembleAndVerify (fail +// closed) and AssembleAndVerifyPartial (skip non-members). +func assembleVerified(name, namespace string, verified []verifiedFragment) (*v1beta1.ContainerProfile, *BundleManifest, error) { + // Canonical order: class precedence, then signer, then content digest. This + // makes the composite (and its Merkle root) independent of input order. + sort.SliceStable(verified, func(i, j int) bool { + if pi, pj := classPrecedence(verified[i].class), classPrecedence(verified[j].class); pi != pj { + return pi < pj + } + if verified[i].signer != verified[j].signer { + return verified[i].signer < verified[j].signer + } + return verified[i].digest < verified[j].digest + }) + + spec := assembleSpec(verified) + + leaves := make([]LeafRef, len(verified)) + for i, vf := range verified { + leaves[i] = LeafRef{Class: vf.class, Signer: vf.signer, Name: vf.name, ContentDigest: vf.digest, Version: vf.version} + } + manifest := &BundleManifest{Root: rootFromLeaves(leaves), Leaves: leaves} + if verr := VerifyManifestRoot(manifest); verr != nil { + return nil, nil, fmt.Errorf("internal: %w", verr) + } + mb, err := json.Marshal(manifest) + if err != nil { + return nil, nil, fmt.Errorf("marshal manifest: %w", err) + } + + composite := &v1beta1.ContainerProfile{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "spdx.softwarecomposition.kubescape.io/v1beta1", + Kind: "ContainerProfile", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{LabelBundle: name}, + Annotations: map[string]string{ManifestAnnotation: string(mb)}, + }, + Spec: spec, + } + return composite, manifest, nil +} + +// VerifyManifestRoot recomputes the Merkle root from a manifest's own leaves and +// checks it against the committed Root. A mismatch means the manifest's leaf set +// was altered (a fragment added/dropped/reordered) — a composite-tamper event. +func VerifyManifestRoot(manifest *BundleManifest) error { + if manifest == nil { + return fmt.Errorf("nil manifest") + } + if got := rootFromLeaves(manifest.Leaves); got != manifest.Root { + return fmt.Errorf("bundle manifest root mismatch: committed %s, recomputed %s", manifest.Root, got) + } + return nil +} + +// ManifestFromComposite extracts and parses the bundle manifest annotation. +func ManifestFromComposite(composite *v1beta1.ContainerProfile) (*BundleManifest, error) { + if composite == nil || composite.Annotations == nil { + return nil, fmt.Errorf("composite has no annotations") + } + raw, ok := composite.Annotations[ManifestAnnotation] + if !ok { + return nil, fmt.Errorf("composite has no %s annotation", ManifestAnnotation) + } + var m BundleManifest + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return nil, fmt.Errorf("parse bundle manifest: %w", err) + } + return &m, nil +} diff --git a/pkg/signature/cluster_flow_test.go b/pkg/signature/cluster_flow_test.go new file mode 100644 index 0000000000..c64f118a1e --- /dev/null +++ b/pkg/signature/cluster_flow_test.go @@ -0,0 +1,152 @@ +//go:build experimental_signing + +package signature + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + + sigstore_signature "github.com/sigstore/sigstore/pkg/signature" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +func TestClusterProfileStructure(t *testing.T) { + // Simulate a cluster profile with empty TypeMeta (like from cluster) + profile := &v1beta1.ContainerProfile{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "", + Kind: "", + }, + } + profile.Name = "test-signed" + profile.Namespace = "default" + + adapter := profiles.NewContainerProfileAdapter(profile) + content := adapter.GetContent() + + if m, ok := content.(map[string]interface{}); ok { + t.Logf("apiVersion: %v (type: %T)", m["apiVersion"], m["apiVersion"]) + t.Logf("kind: %v (type: %T)", m["kind"], m["kind"]) + + // Verify fallback values are applied + if m["apiVersion"] != "spdx.softwarecomposition.kubescape.io/v1beta1" { + t.Errorf("Expected fallback apiVersion, got %s", m["apiVersion"]) + } + if m["kind"] != "ContainerProfile" { + t.Errorf("Expected fallback kind, got %s", m["kind"]) + } + } else { + t.Errorf("Expected map, got %T", content) + } +} + +func TestReproduceClusterVerificationFlow(t *testing.T) { + // Simulate the exact scenario from the cluster + profile := &v1beta1.ContainerProfile{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "", + Kind: "", + }, + } + profile.Name = "replicaset-nginx2-5bffdcc777-signed" + profile.Namespace = "default" + profile.Labels = map[string]string{ + "kubescape.io/instance-template-hash": "5bffdcc777", + "kubescape.io/workload-api-group": "apps", + "kubescape.io/workload-api-version": "v1", + "kubescape.io/workload-kind": "Deployment", + "kubescape.io/workload-name": "nginx2", + "kubescape.io/workload-namespace": "default", + "kubescape.io/workload-resource-version": "15471", + } + + adapter := profiles.NewContainerProfileAdapter(profile) + + // Calculate hash + cosignAdapter := &CosignAdapter{} + hash, err := cosignAdapter.GetContentHash(adapter.GetContent()) + if err != nil { + t.Fatalf("Failed to compute hash: %v", err) + } + + t.Logf("Computed hash: %s", hash) + + // Generate a key and sign + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + signer, err := sigstore_signature.LoadECDSASigner(privKey, crypto.SHA256) + if err != nil { + t.Fatalf("Failed to load signer: %v", err) + } + + sig, err := signer.SignMessage(bytes.NewReader([]byte(hash))) + if err != nil { + t.Fatalf("Failed to sign message: %v", err) + } + certBytes, err := generateTestCertificate(privKey) + if err != nil { + t.Fatalf("Failed to generate test certificate: %v", err) + } + + // Use the package-level annotation flow + sigObj := &Signature{ + Signature: sig, + Certificate: certBytes, + Timestamp: time.Now().Unix(), + } + annotations, err := cosignAdapter.EncodeSignatureToAnnotations(sigObj) + if err != nil { + t.Fatalf("Failed to encode signature to annotations: %v", err) + } + adapter.SetAnnotations(annotations) + + // Now verify using the higher-level flow + err = VerifyObjectAllowUntrusted(adapter) + if err != nil { + t.Fatalf("VerifyObjectAllowUntrusted failed: %v", err) + } +} + +func generateTestCertificate(privKey *ecdsa.PrivateKey) ([]byte, error) { + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: "test-signer", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) + if err != nil { + return nil, err + } + + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + }) + + return certPEM, nil +} diff --git a/pkg/signature/cluster_scenario_test.go b/pkg/signature/cluster_scenario_test.go new file mode 100644 index 0000000000..f8a50774c3 --- /dev/null +++ b/pkg/signature/cluster_scenario_test.go @@ -0,0 +1,90 @@ +//go:build experimental_signing + +package signature + +import ( + "testing" + + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// This test replicates the exact scenario from the production cluster where: +// 1. Profiles are loaded from the cluster with empty TypeMeta (APIVersion="", Kind="") +// 2. The adapter's GetContent() should fill in the correct fallback values +// 3. Signatures created and verified using these profiles should succeed + +func TestClusterScenarioIntegration(t *testing.T) { + // Simulate a profile as it comes from the cluster (empty TypeMeta) + clusterProfile := &v1beta1.ContainerProfile{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "", + Kind: "", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "replicaset-test-workload-123456789", + Namespace: "default", + Labels: map[string]string{ + "kubescape.io/instance-template-hash": "123456789", + "kubescape.io/workload-kind": "Deployment", + "kubescape.io/workload-name": "test-workload", + "kubescape.io/workload-namespace": "default", + }, + }, + } + + // Create adapter + adapter := profiles.NewContainerProfileAdapter(clusterProfile) + + // Verify GetContent() populates TypeMeta correctly + content := adapter.GetContent() + contentMap, ok := content.(map[string]interface{}) + if !ok { + t.Fatalf("GetContent() should return map[string]interface{}, got %T", content) + } + + // Check that fallback values are applied + if contentMap["apiVersion"] != "spdx.softwarecomposition.kubescape.io/v1beta1" { + t.Errorf("Expected apiVersion fallback to be applied, got: %v", contentMap["apiVersion"]) + } + if contentMap["kind"] != "ContainerProfile" { + t.Errorf("Expected kind fallback to be applied, got: %v", contentMap["kind"]) + } + + // Verify metadata is correctly structured + metadata, ok := contentMap["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("metadata should be a map[string]interface{}") + } + + if metadata["name"] != clusterProfile.Name { + t.Errorf("Expected metadata.name=%s, got %v", clusterProfile.Name, metadata["name"]) + } + if _, present := metadata["namespace"]; present { + t.Errorf("namespace must not be part of the signed content, got %v", metadata["namespace"]) + } + if metadata["labels"] == nil { + t.Error("metadata.labels should not be nil") + } + + // Now verify that signing and verification work end-to-end + if err := SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("Failed to sign object: %v", err) + } + + if clusterProfile.Annotations == nil { + t.Fatal("Annotations should be set after signing") + } + + if _, ok := clusterProfile.Annotations[AnnotationSignature]; !ok { + t.Error("Signature annotation should be set after signing") + } + + // Verify the signature + if err := VerifyObjectAllowUntrusted(adapter); err != nil { + t.Fatalf("Failed to verify object: %v", err) + } + + t.Log("✓ Cluster scenario integration test passed: profile with empty TypeMeta successfully signed and verified") +} diff --git a/pkg/signature/cosign_adapter.go b/pkg/signature/cosign_adapter.go new file mode 100644 index 0000000000..c382d3875f --- /dev/null +++ b/pkg/signature/cosign_adapter.go @@ -0,0 +1,570 @@ +//go:build experimental_signing + +package signature + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "net/url" + "os" + "strconv" + "time" + + "context" + "github.com/golang-jwt/jwt/v5" + "github.com/kubescape/storage/pkg/utils" + "github.com/sigstore/cosign/v3/pkg/providers" + _ "github.com/sigstore/cosign/v3/pkg/providers/all" + "github.com/sigstore/fulcio/pkg/api" + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/sigstore/sigstore/pkg/fulcioroots" + "github.com/sigstore/sigstore/pkg/oauthflow" + sigstore_signature "github.com/sigstore/sigstore/pkg/signature" +) + +const ( + sigstoreIssuer = "https://token.actions.githubusercontent.com" + sigstoreOIDC = "kubernetes.io" + fulcioURL = "https://fulcio.sigstore.dev" + rekorURL = "https://rekor.sigstore.dev" + // keylessSignTimeout bounds the whole keyless flow (token acquisition + + // Fulcio certificate) so it can never block indefinitely. + keylessSignTimeout = 2 * time.Minute +) + +type CosignAdapter struct { + privateKey *ecdsa.PrivateKey + signer sigstore_signature.Signer + verifier sigstore_signature.Verifier + useKeyless bool + tokenProvider func(ctx context.Context) (string, error) +} + +func NewCosignAdapter(useKeyless bool) (*CosignAdapter, error) { + if useKeyless { + return &CosignAdapter{ + useKeyless: true, + }, nil + } + + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate private key: %w", err) + } + + signer, err := sigstore_signature.LoadECDSASigner(privateKey, crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("failed to load ECDSA signer: %w", err) + } + + verifier, err := sigstore_signature.LoadECDSAVerifier(&privateKey.PublicKey, crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("failed to load ECDSA verifier: %w", err) + } + + return &CosignAdapter{ + privateKey: privateKey, + signer: signer, + verifier: verifier, + useKeyless: false, + }, nil +} + +func NewCosignAdapterWithPrivateKey(useKeyless bool, privateKey *ecdsa.PrivateKey) (*CosignAdapter, error) { + if privateKey == nil { + return nil, fmt.Errorf("private key cannot be nil") + } + + signer, err := sigstore_signature.LoadECDSASigner(privateKey, crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("failed to load ECDSA signer: %w", err) + } + + verifier, err := sigstore_signature.LoadECDSAVerifier(&privateKey.PublicKey, crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("failed to load ECDSA verifier: %w", err) + } + + return &CosignAdapter{ + privateKey: privateKey, + signer: signer, + verifier: verifier, + useKeyless: useKeyless, + }, nil +} + +func (c *CosignAdapter) SignData(data []byte) (*Signature, error) { + if c.useKeyless { + return c.signKeyless(data) + } + + return c.signWithKey(data) +} + +func (c *CosignAdapter) SetTokenProvider(provider func(context.Context) (string, error)) { + c.tokenProvider = provider +} + +func (c *CosignAdapter) signKeyless(data []byte) (*Signature, error) { + ctx, cancel := context.WithTimeout(context.Background(), keylessSignTimeout) + defer cancel() + + var tok string + var err error + var identity string + var issuer string + + // 1. Get OIDC Token + if c.tokenProvider != nil { + tok, err = c.tokenProvider(ctx) + if err != nil { + return nil, fmt.Errorf("failed to provide OIDC token from provider: %w", err) + } + } else if providers.Enabled(ctx) { + tok, err = providers.Provide(ctx, "sigstore") + if err != nil { + return nil, fmt.Errorf("failed to provide OIDC token: %w", err) + } + } + + if tok != "" { + // Extract "sub" and "iss" from the JWT token + parser := jwt.NewParser() + token, _, err := parser.ParseUnverified(tok, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("failed to parse OIDC token: %w", err) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("failed to get claims from OIDC token") + } + + sub, ok := claims["sub"].(string) + if !ok { + return nil, fmt.Errorf("failed to get 'sub' claim from OIDC token") + } + identity = sub + + iss, ok := claims["iss"].(string) + if !ok { + return nil, fmt.Errorf("failed to get 'iss' claim from OIDC token") + } + issuer = iss + } else { + // No ambient OIDC provider. The interactive browser flow is opt-in + // (COSIGN_ALLOW_INTERACTIVE) so CI, the node-agent, and tests fail + // fast instead of blocking on a browser that will never open. + if os.Getenv("COSIGN_ALLOW_INTERACTIVE") == "" { + return nil, fmt.Errorf("no OIDC provider available for keyless signing; configure a workload-identity provider or set COSIGN_ALLOW_INTERACTIVE=1 for the interactive browser flow") + } + // Sigstore's default issuer and client ID + issuerURL := "https://oauth2.sigstore.dev/auth" + clientID := "sigstore" + // This will open a browser window for authentication + oidcToken, err := oauthflow.OIDConnect(issuerURL, clientID, "", "", oauthflow.DefaultIDTokenGetter) + if err != nil { + return nil, fmt.Errorf("failed to get interactive OIDC token: %w", err) + } + tok = oidcToken.RawString + identity = oidcToken.Subject + issuer = issuerURL + } + _ = tok + + // 2. Generate Ephemeral Key Pair + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate ephemeral key: %w", err) + } + signer, err := sigstore_signature.LoadECDSASigner(privKey, crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("failed to load ephemeral signer: %w", err) + } + + // 3. Get Certificate from Fulcio using the real client + certBytes, err := c.getFulcioCertificate(ctx, privKey, identity, tok) + if err != nil { + return nil, fmt.Errorf("failed to get certificate from Fulcio: %w", err) + } + + // 4. Sign Data + sig, err := signer.SignMessage(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("failed to sign data: %w", err) + } + + // 5. Upload to Rekor (Placeholder for real upload) + // rekorClient, _ := rekor.GetByProxy(rekorURL) + // entry, _ := cosign.TLogUpload(ctx, rekorClient, sig, certBytes, data) + + return &Signature{ + Signature: sig, + Certificate: certBytes, + Issuer: issuer, + Identity: identity, + Timestamp: time.Now().Unix(), + }, nil +} + +func (c *CosignAdapter) simulateKeyless(data []byte) (*Signature, error) { + return nil, fmt.Errorf("simulateKeyless is deprecated, use real keyless signing") +} + +func (c *CosignAdapter) signWithKey(data []byte) (*Signature, error) { + sig, err := c.signer.SignMessage(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("failed to sign message: %w", err) + } + + certBytes, err := c.generateCertificate(c.privateKey, "local-key", "local") + if err != nil { + return nil, fmt.Errorf("failed to generate certificate: %w", err) + } + + sigObj := &Signature{ + Signature: sig, + Certificate: certBytes, + Issuer: "local", + Identity: "local-key", + Timestamp: time.Now().Unix(), + } + + return sigObj, nil +} + +func (c *CosignAdapter) getFulcioCertificate(ctx context.Context, privKey *ecdsa.PrivateKey, identity, oidcToken string) ([]byte, error) { + // Parse Fulcio URL + fulcioAddr, err := url.Parse(fulcioURL) + if err != nil { + return nil, fmt.Errorf("failed to parse Fulcio URL: %w", err) + } + + // Create Fulcio client + fulcioClient := api.NewClient(fulcioAddr) + + // Marshal public key to ASN.1 DER format + pubKeyBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal public key: %w", err) + } + + // Create CertificateRequest with the public key + certReq := api.CertificateRequest{ + PublicKey: api.Key{ + Content: pubKeyBytes, + Algorithm: "ecdsa", + }, + } + + // We need to prove possession of the OIDC token's identity by signing the identity + // Fulcio expects a signature over the identity (e.g. email or subject) + proof, err := c.ecdsaSign(privKey, []byte(identity)) + if err != nil { + return nil, fmt.Errorf("failed to sign identity for proof: %w", err) + } + certReq.SignedEmailAddress = proof + + // Call Fulcio API to get certificate + certResp, err := fulcioClient.SigningCert(certReq, oidcToken) + if err != nil { + return nil, fmt.Errorf("Fulcio SigningCert failed: %w", err) + } + + return certResp.CertPEM, nil +} + +func (c *CosignAdapter) generateCertificate(privKey *ecdsa.PrivateKey, identity, issuer string) ([]byte, error) { + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("failed to generate serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: identity, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) + if err != nil { + return nil, fmt.Errorf("failed to create certificate: %w", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + }) + + return certPEM, nil +} + +func (c *CosignAdapter) ecdsaSign(privKey *ecdsa.PrivateKey, data []byte) ([]byte, error) { + signer, err := sigstore_signature.LoadECDSASigner(privKey, crypto.SHA256) + if err != nil { + return nil, err + } + return signer.SignMessage(bytes.NewReader(data)) +} + +func (c *CosignAdapter) GetPrivateKeyPEM() ([]byte, error) { + if c.privateKey == nil { + return nil, fmt.Errorf("no private key available") + } + + derBytes, err := x509.MarshalECPrivateKey(c.privateKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal private key: %w", err) + } + + block := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: derBytes, + } + + return pem.EncodeToMemory(block), nil +} + +func (c *CosignAdapter) GetPublicKeyPEM() ([]byte, error) { + if c.privateKey == nil { + return nil, fmt.Errorf("no private key available") + } + + pubKeyBytes, err := cryptoutils.MarshalPublicKeyToPEM(&c.privateKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal public key: %w", err) + } + + return pubKeyBytes, nil +} + +func (c *CosignAdapter) VerifyData(data []byte, sig *Signature, allowUntrusted bool) error { + if sig == nil { + return fmt.Errorf("VerifyData: Signature value is nil") + } + if len(sig.Certificate) == 0 { + return fmt.Errorf("VerifyData: Signature.Certificate is empty") + } + + var verifier sigstore_signature.Verifier + var err error + + // If we have a certificate, it could be a keyless signature (Fulcio) or a key-based signature with a cert. + // For keyless, we should ideally verify the certificate chain and Rekor bundle. + // For now, we continue to support the simplified verification but using sigstore's abstractions. + + block, _ := pem.Decode(sig.Certificate) + if block != nil && block.Type == "CERTIFICATE" { + var cert *x509.Certificate + cert, err = x509.ParseCertificate(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse certificate: %w", err) + } + + if !allowUntrusted { + if cert.IsCA { + return fmt.Errorf("invalid certificate: must not be CA") + } + + // Build and verify the certificate chain + roots, err := fulcioroots.Get() + if err != nil { + return fmt.Errorf("failed to get Fulcio roots: %w", err) + } + opts := x509.VerifyOptions{ + Roots: roots, + KeyUsages: []x509.ExtKeyUsage{ + x509.ExtKeyUsageCodeSigning, + }, + CurrentTime: time.Unix(sig.Timestamp, 0), + } + if _, err := cert.Verify(opts); err != nil { + return fmt.Errorf("failed to verify certificate chain: %w", err) + } + + if time.Unix(sig.Timestamp, 0).Before(cert.NotBefore) || time.Unix(sig.Timestamp, 0).After(cert.NotAfter) { + return fmt.Errorf("certificate was not valid at signing time") + } + + // In a production environment, we would verify the certificate chain here + // against the Fulcio root set and system roots. + // roots, _ := fulcioroots.Get() + // cert.Verify(x509.VerifyOptions{Roots: roots}) + + // Check identity. Fulcio certs store identity in Subject Alternative Name (SAN) + // but many systems still look at CommonName or use specific extensions. + // Sigstore's verify library is usually used for this, but for now we'll check SANs. + foundIdentity := false + if cert.Subject.CommonName == sig.Identity { + foundIdentity = true + } else { + for _, email := range cert.EmailAddresses { + if email == sig.Identity { + foundIdentity = true + break + } + } + if !foundIdentity { + for _, uri := range cert.URIs { + if uri.String() == sig.Identity { + foundIdentity = true + break + } + } + } + } + + if sig.Identity != "" && !foundIdentity { + return fmt.Errorf("identity mismatch: certificate does not match signature identity %q (CN: %q, SANs: %v)", sig.Identity, cert.Subject.CommonName, cert.EmailAddresses) + } + + // Validate Rekor/CT evidence if Rekor bundle is present + if len(sig.RekorBundle) > 0 { + // In a full implementation, we would use cosign.VerifyBundle + // for now we acknowledge its presence for strict verification + } else if sig.Issuer != "local" && sig.Issuer != "" { + // For non-local certificates, we expect a Rekor bundle in strict mode + // But we'll allow it if we are in interactive mode (where Rekor might not be used) + if sig.Issuer != "https://oauth2.sigstore.dev/auth" { + return fmt.Errorf("strict verification failed: missing Rekor bundle for certificate from %q", sig.Issuer) + } + } + } + verifier, err = sigstore_signature.LoadVerifier(cert.PublicKey, crypto.SHA256) + if err != nil { + return fmt.Errorf("failed to load verifier from certificate: %w", err) + } + } else { + // If not a certificate, it must be a public key + if !allowUntrusted { + return fmt.Errorf("untrusted public key rejected: require valid x509 certificate chain") + } + + pubKey, err := cryptoutils.UnmarshalPEMToPublicKey(sig.Certificate) + if err != nil { + // Try parsing as raw DER + pubKey, err = x509.ParsePKIXPublicKey(sig.Certificate) + if err != nil { + return fmt.Errorf("failed to unmarshal public key: %w", err) + } + } + + verifier, err = sigstore_signature.LoadVerifier(pubKey, crypto.SHA256) + if err != nil { + return fmt.Errorf("failed to load verifier: %w", err) + } + } + + if err := verifier.VerifySignature(bytes.NewReader(sig.Signature), bytes.NewReader(data)); err != nil { + return fmt.Errorf("invalid signature: %w", err) + } + + // In a full Cosign implementation, if we have a Rekor bundle, we would verify it here. + // sig.RekorBundle (if added to the Signature struct) could be used with cosign/pkg/cosign.VerifyBundle. + + if c.useKeyless && !allowUntrusted { + if sig.Issuer == "" || sig.Identity == "" { + return fmt.Errorf("keyless signature missing issuer or identity") + } + } + + return nil +} + +func (c *CosignAdapter) GetContentHash(obj interface{}) (string, error) { + data, err := json.Marshal(obj) + if err != nil { + return "", fmt.Errorf("failed to marshal object: %w", err) + } + + hash, err := utils.CanonicalHash(data) + if err != nil { + return "", err + } + + return hash, nil +} + +func (c *CosignAdapter) EncodeSignatureToAnnotations(sig *Signature) (map[string]string, error) { + annotations := make(map[string]string) + + annotations[AnnotationSignature] = base64.StdEncoding.EncodeToString(sig.Signature) + + if len(sig.Certificate) > 0 { + annotations[AnnotationCertificate] = base64.StdEncoding.EncodeToString(sig.Certificate) + } + if len(sig.RekorBundle) > 0 { + annotations[AnnotationRekorBundle] = base64.StdEncoding.EncodeToString(sig.RekorBundle) + } + if sig.Issuer != "" { + annotations[AnnotationIssuer] = sig.Issuer + } + if sig.Identity != "" { + annotations[AnnotationIdentity] = sig.Identity + } + annotations[AnnotationTimestamp] = fmt.Sprintf("%d", sig.Timestamp) + + return annotations, nil +} + +func (c *CosignAdapter) DecodeSignatureFromAnnotations(annotations map[string]string) (*Signature, error) { + sig := &Signature{} + + signatureB64, ok := annotations[AnnotationSignature] + if !ok { + return nil, fmt.Errorf("missing %s annotation", AnnotationSignature) + } + + var err error + // Reject malformed base64 rather than falling back to the raw string: a raw + // "certificate" that is not a PEM CERTIFICATE block would be parsed as a bare + // public key downstream, a route to accepting attacker-chosen key material. + sig.Signature, err = base64.StdEncoding.DecodeString(signatureB64) + if err != nil { + return nil, fmt.Errorf("%s annotation is not valid base64: %w", AnnotationSignature, err) + } + + if certB64, ok := annotations[AnnotationCertificate]; ok { + sig.Certificate, err = base64.StdEncoding.DecodeString(certB64) + if err != nil { + return nil, fmt.Errorf("%s annotation is not valid base64: %w", AnnotationCertificate, err) + } + } + + if rekorB64, ok := annotations[AnnotationRekorBundle]; ok { + sig.RekorBundle, err = base64.StdEncoding.DecodeString(rekorB64) + if err != nil { + // Try raw if base64 fails + sig.RekorBundle = []byte(rekorB64) + } + } + + sig.Issuer = annotations[AnnotationIssuer] + sig.Identity = annotations[AnnotationIdentity] + + if timestamp, ok := annotations[AnnotationTimestamp]; ok { + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse timestamp: %w", err) + } + sig.Timestamp = ts + } + + return sig, nil +} diff --git a/pkg/signature/cosign_adapter_test.go b/pkg/signature/cosign_adapter_test.go new file mode 100644 index 0000000000..a3fbb05550 --- /dev/null +++ b/pkg/signature/cosign_adapter_test.go @@ -0,0 +1,145 @@ +//go:build experimental_signing + +package signature + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "testing" +) + +func TestNewCosignAdapterWithPrivateKey(t *testing.T) { + privKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + + t.Run("Valid private key", func(t *testing.T) { + adapter, err := NewCosignAdapterWithPrivateKey(false, privKey) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if adapter.privateKey != privKey { + t.Error("Private key not set correctly") + } + }) + + t.Run("Nil private key", func(t *testing.T) { + _, err := NewCosignAdapterWithPrivateKey(false, nil) + if err == nil { + t.Error("Expected error for nil private key, got nil") + } + }) +} + +func TestCosignAdapter_GetKeysPEM(t *testing.T) { + privKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + adapter, _ := NewCosignAdapterWithPrivateKey(false, privKey) + + t.Run("GetPrivateKeyPEM", func(t *testing.T) { + pem, err := adapter.GetPrivateKeyPEM() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(pem) == 0 { + t.Error("Expected non-empty PEM") + } + }) + + t.Run("GetPublicKeyPEM", func(t *testing.T) { + pem, err := adapter.GetPublicKeyPEM() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(pem) == 0 { + t.Error("Expected non-empty PEM") + } + }) + + t.Run("No private key", func(t *testing.T) { + emptyAdapter := &CosignAdapter{} + _, err := emptyAdapter.GetPrivateKeyPEM() + if err == nil { + t.Error("Expected error, got nil") + } + _, err = emptyAdapter.GetPublicKeyPEM() + if err == nil { + t.Error("Expected error, got nil") + } + }) +} + +func TestWithPrivateKey(t *testing.T) { + privKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + opts := &SignOptions{} + WithPrivateKey(privKey)(opts) + if opts.PrivateKey != privKey { + t.Error("PrivateKey option not set correctly") + } +} + +func TestCosignSigner(t *testing.T) { + signer, err := NewCosignSigner(false) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + data := []byte("test data") + sig, err := signer.Sign(data) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(sig.Signature) == 0 { + t.Error("Expected non-empty signature") + } +} + +func TestCosignAdapter_ecdsaSign(t *testing.T) { + privKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + adapter := &CosignAdapter{} + data := []byte("test data") + sig, err := adapter.ecdsaSign(privKey, data) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(sig) == 0 { + t.Error("Expected non-empty signature") + } +} + +func TestVerifyData_ErrorCases(t *testing.T) { + privKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + adapter, _ := NewCosignAdapterWithPrivateKey(false, privKey) + data := []byte("test data") + + t.Run("Invalid certificate PEM", func(t *testing.T) { + sig := &Signature{ + Signature: []byte("sig"), + Certificate: []byte("invalid-pem"), + } + err := adapter.VerifyData(data, sig, false) + if err == nil { + t.Error("Expected error for invalid certificate PEM, got nil") + } + }) + + t.Run("PublicKey is not ECDSA", func(t *testing.T) { + // Mock a non-ECDSA public key? Hard to do with current implementation. + // Skipping for now. + }) + + t.Run("Certificate is CA", func(t *testing.T) { + // Create a CA certificate + template := x509.Certificate{ + IsCA: true, + } + certDER, _ := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) + sig := &Signature{ + Signature: []byte("sig"), + Certificate: certDER, + } + err := adapter.VerifyData(data, sig, false) + if err == nil { + t.Error("Expected error for CA certificate, got nil") + } + }) +} diff --git a/pkg/signature/differential_test.go b/pkg/signature/differential_test.go new file mode 100644 index 0000000000..a94800ef03 --- /dev/null +++ b/pkg/signature/differential_test.go @@ -0,0 +1,349 @@ +//go:build experimental_signing + +package signature + +import ( + "bytes" + "compress/gzip" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + mathrand "math/rand" + "testing" + + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/kubescape/storage/pkg/utils" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func diffKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("gen key: %v", err) + } + return k +} + +func refVerifyAccepts(obj SignableObject) (bool, error) { + ann := obj.GetAnnotations() + if ann == nil { + return false, fmt.Errorf("no annotations") + } + sigB64, ok := ann[AnnotationSignature] + if !ok { + return false, fmt.Errorf("no signature annotation") + } + certB64, ok := ann[AnnotationCertificate] + if !ok { + return false, fmt.Errorf("no certificate annotation") + } + sigBytes, err := base64.StdEncoding.DecodeString(sigB64) + if err != nil { + return false, fmt.Errorf("signature not base64: %w", err) + } + certBytes, err := base64.StdEncoding.DecodeString(certB64) + if err != nil { + return false, fmt.Errorf("certificate not base64: %w", err) + } + block, _ := pem.Decode(certBytes) + if block == nil { + return false, fmt.Errorf("certificate not PEM") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false, fmt.Errorf("parse certificate: %w", err) + } + pub, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok { + return false, fmt.Errorf("certificate key is not ECDSA") + } + var hash string + if enc, present := ann[AnnotationContent]; present { + gz, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return false, fmt.Errorf("embedded content not base64: %w", err) + } + zr, err := gzip.NewReader(bytes.NewReader(gz)) + if err != nil { + return false, fmt.Errorf("embedded content not gzip: %w", err) + } + embedded, err := io.ReadAll(zr) + if err != nil { + return false, fmt.Errorf("embedded content unreadable: %w", err) + } + var id struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + } + if err := json.Unmarshal(embedded, &id); err != nil { + return false, fmt.Errorf("embedded metadata unparsable: %w", err) + } + if id.Metadata.Name != obj.GetName() { + return false, fmt.Errorf("embedded content bound to a different name") + } + hash, err = utils.CanonicalHash(embedded) + if err != nil { + return false, fmt.Errorf("hash embedded: %w", err) + } + } else { + raw, err := json.Marshal(obj.GetContent()) + if err != nil { + return false, fmt.Errorf("marshal content: %w", err) + } + hash, err = utils.CanonicalHash(raw) + if err != nil { + return false, fmt.Errorf("hash content: %w", err) + } + } + digest := sha256.Sum256([]byte(hash)) + return ecdsa.VerifyASN1(pub, digest[:], sigBytes), nil +} + +func randomCP(rng *mathrand.Rand, name string) *v1beta1.ContainerProfile { + spec := v1beta1.ContainerProfileSpec{} + for _, a := range []string{"amd64", "arm64", "386"} { + if rng.Intn(2) == 0 { + spec.Architectures = append(spec.Architectures, a) + } + } + if len(spec.Architectures) == 0 { + spec.Architectures = []string{"amd64"} + } + for _, c := range []string{"CAP_NET_ADMIN", "CAP_SYS_PTRACE", "CAP_CHOWN"} { + if rng.Intn(2) == 0 { + spec.Capabilities = append(spec.Capabilities, c) + } + } + nExec := rng.Intn(3) + 1 + for e := 0; e < nExec; e++ { + spec.Execs = append(spec.Execs, v1beta1.ExecCalls{ + Path: fmt.Sprintf("/bin/tool-%d-%d", rng.Intn(1000), e), + Args: []string{fmt.Sprintf("arg%d", rng.Intn(100))}, + }) + } + if rng.Intn(2) == 0 { + spec.Opens = append(spec.Opens, v1beta1.OpenCalls{Path: fmt.Sprintf("/data/f-%d", rng.Intn(1000)), Flags: []string{"O_RDONLY"}}) + } + for _, s := range []string{"read", "write", "openat", "close", "connect"} { + if rng.Intn(2) == 0 { + spec.Syscalls = append(spec.Syscalls, s) + } + } + return &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: fmt.Sprintf("ns-%d", rng.Intn(1000)), + Labels: map[string]string{ + "app": fmt.Sprintf("app-%d", rng.Intn(1000)), + "tier": fmt.Sprintf("tier-%d", rng.Intn(10)), + }, + }, + Spec: spec, + } +} + +func randomRules(rng *mathrand.Rand, name string) *rulemanagertypesv1.Rules { + var rules []rulemanagertypesv1.Rule + n := rng.Intn(4) + 1 + for i := 0; i < n; i++ { + rules = append(rules, rulemanagertypesv1.Rule{ + Enabled: rng.Intn(2) == 0, + ID: fmt.Sprintf("R%04d", rng.Intn(9999)), + Name: fmt.Sprintf("rule-%d", rng.Intn(1000)), + Severity: rng.Intn(10), + }) + } + return &rulemanagertypesv1.Rules{ + TypeMeta: metav1.TypeMeta{APIVersion: "kubescape.io/v1", Kind: "Rules"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: fmt.Sprintf("ns-%d", rng.Intn(1000)), + Labels: map[string]string{"app": fmt.Sprintf("app-%d", rng.Intn(1000))}, + }, + Spec: rulemanagertypesv1.RulesSpec{Rules: rules}, + } +} + +func TestDifferential_IndependentVerifierAgreesOnAccept(t *testing.T) { + rng := mathrand.New(mathrand.NewSource(0x5321BEEF)) + const iterations = 80 + agreed := 0 + for i := 0; i < iterations; i++ { + for _, embed := range []bool{false, true} { + opts := []SignOption{WithPrivateKey(diffKey(t))} + if embed { + opts = append(opts, WithEmbedContent(true)) + } + + cp := randomCP(rng, fmt.Sprintf("cp-%d", i)) + cpObj := profiles.NewContainerProfileAdapter(cp) + if err := SignObject(cpObj, opts...); err != nil { + t.Fatalf("sign cp %d embed=%v: %v", i, embed, err) + } + refOK, refErr := refVerifyAccepts(cpObj) + prodOK := VerifyObjectAllowUntrusted(cpObj) == nil + if !refOK || !prodOK || refOK != prodOK { + t.Fatalf("cp %d embed=%v: stdlib ref accept=%v (err=%v), production accept=%v — must both accept and agree", i, embed, refOK, refErr, prodOK) + } + agreed++ + + r := randomRules(rng, fmt.Sprintf("rules-%d", i)) + rObj := profiles.NewRulesAdapter(r) + ropts := []SignOption{WithPrivateKey(diffKey(t))} + if embed { + ropts = append(ropts, WithEmbedContent(true)) + } + if err := SignObject(rObj, ropts...); err != nil { + t.Fatalf("sign rules %d embed=%v: %v", i, embed, err) + } + refOK, refErr = refVerifyAccepts(rObj) + prodOK = VerifyObjectAllowUntrusted(rObj) == nil + if !refOK || !prodOK || refOK != prodOK { + t.Fatalf("rules %d embed=%v: stdlib ref accept=%v (err=%v), production accept=%v — must both accept and agree", i, embed, refOK, refErr, prodOK) + } + agreed++ + } + } + t.Logf("independent stdlib verifier agreed with production on %d freshly signed objects (CP+Rules, embed and non-embed)", agreed) +} + +func flipMiddleByteB64(t *testing.T, b64 string) string { + t.Helper() + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + t.Fatalf("decode for flip: %v", err) + } + if len(raw) == 0 { + t.Fatal("cannot flip an empty value") + } + raw[len(raw)/2] ^= 0xFF + return base64.StdEncoding.EncodeToString(raw) +} + +func truncateB64(t *testing.T, b64 string) string { + t.Helper() + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + t.Fatalf("decode for truncate: %v", err) + } + return base64.StdEncoding.EncodeToString(raw[:len(raw)/2]) +} + +type cpMutation struct { + name string + embed bool + apply func(t *testing.T, cp *v1beta1.ContainerProfile) + want error +} + +func TestDifferential_MutationOracle(t *testing.T) { + otherKey := diffKey(t) + otherCP := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Name: "other", Namespace: "x"}} + if err := SignObject(profiles.NewContainerProfileAdapter(otherCP), WithPrivateKey(otherKey)); err != nil { + t.Fatalf("sign other: %v", err) + } + otherCert := otherCP.Annotations[AnnotationCertificate] + + mutations := []cpMutation{ + {"flip a signature byte", false, func(t *testing.T, cp *v1beta1.ContainerProfile) { + cp.Annotations[AnnotationSignature] = flipMiddleByteB64(t, cp.Annotations[AnnotationSignature]) + }, ErrSignatureMismatch}, + {"flip a certificate byte", false, func(t *testing.T, cp *v1beta1.ContainerProfile) { + cp.Annotations[AnnotationCertificate] = flipMiddleByteB64(t, cp.Annotations[AnnotationCertificate]) + }, ErrSignatureMismatch}, + {"replace certificate with a different key's cert", false, func(t *testing.T, cp *v1beta1.ContainerProfile) { + cp.Annotations[AnnotationCertificate] = otherCert + }, ErrSignatureMismatch}, + {"truncate the signature", false, func(t *testing.T, cp *v1beta1.ContainerProfile) { + cp.Annotations[AnnotationSignature] = truncateB64(t, cp.Annotations[AnnotationSignature]) + }, ErrSignatureMismatch}, + {"drop the signature annotation", false, func(t *testing.T, cp *v1beta1.ContainerProfile) { + delete(cp.Annotations, AnnotationSignature) + }, ErrObjectNotSigned}, + {"drop the certificate annotation", false, func(t *testing.T, cp *v1beta1.ContainerProfile) { + delete(cp.Annotations, AnnotationCertificate) + }, ErrSignatureMismatch}, + {"mutate a spec field after signing", false, func(t *testing.T, cp *v1beta1.ContainerProfile) { + cp.Spec.Architectures = append(cp.Spec.Architectures, "zzz-injected") + }, ErrSignatureMismatch}, + {"add a metadata label after signing", false, func(t *testing.T, cp *v1beta1.ContainerProfile) { + cp.Labels["zzz-added"] = "1" + }, ErrSignatureMismatch}, + {"remove a metadata label after signing", false, func(t *testing.T, cp *v1beta1.ContainerProfile) { + delete(cp.Labels, "app") + }, ErrSignatureMismatch}, + {"change a metadata label after signing", false, func(t *testing.T, cp *v1beta1.ContainerProfile) { + cp.Labels["app"] = "changed" + }, ErrSignatureMismatch}, + {"flip a signature byte (embedded)", true, func(t *testing.T, cp *v1beta1.ContainerProfile) { + cp.Annotations[AnnotationSignature] = flipMiddleByteB64(t, cp.Annotations[AnnotationSignature]) + }, ErrSignatureMismatch}, + {"flip a certificate byte (embedded)", true, func(t *testing.T, cp *v1beta1.ContainerProfile) { + cp.Annotations[AnnotationCertificate] = flipMiddleByteB64(t, cp.Annotations[AnnotationCertificate]) + }, ErrSignatureMismatch}, + {"replace certificate (embedded)", true, func(t *testing.T, cp *v1beta1.ContainerProfile) { + cp.Annotations[AnnotationCertificate] = otherCert + }, ErrSignatureMismatch}, + {"truncate the signature (embedded)", true, func(t *testing.T, cp *v1beta1.ContainerProfile) { + cp.Annotations[AnnotationSignature] = truncateB64(t, cp.Annotations[AnnotationSignature]) + }, ErrSignatureMismatch}, + {"drop the signature annotation (embedded)", true, func(t *testing.T, cp *v1beta1.ContainerProfile) { + delete(cp.Annotations, AnnotationSignature) + }, ErrObjectNotSigned}, + {"drop the certificate annotation (embedded)", true, func(t *testing.T, cp *v1beta1.ContainerProfile) { + delete(cp.Annotations, AnnotationCertificate) + }, ErrSignatureMismatch}, + {"mutate a byte of the embedded content", true, func(t *testing.T, cp *v1beta1.ContainerProfile) { + cp.Annotations[AnnotationContent] = flipMiddleByteB64(t, cp.Annotations[AnnotationContent]) + }, ErrSignatureMismatch}, + } + + rng := mathrand.New(mathrand.NewSource(0x0BADC0DE)) + const baseObjects = 12 + checks := 0 + for i := 0; i < baseObjects; i++ { + template := randomCP(rng, fmt.Sprintf("mut-cp-%d", i)) + key := diffKey(t) + for _, m := range mutations { + cp := template.DeepCopy() + opts := []SignOption{WithPrivateKey(key)} + if m.embed { + opts = append(opts, WithEmbedContent(true)) + } + adapter := profiles.NewContainerProfileAdapter(cp) + if err := SignObject(adapter, opts...); err != nil { + t.Fatalf("sign base %d for %q: %v", i, m.name, err) + } + if err := VerifyObjectAllowUntrusted(adapter); err != nil { + t.Fatalf("precondition base %d %q: pristine object must verify, got %v", i, m.name, err) + } + + m.apply(t, cp) + + err := VerifyObjectAllowUntrusted(adapter) + if err == nil { + t.Fatalf("base %d mutation %q: production ACCEPTED a mutated object", i, m.name) + } + if !errors.Is(err, m.want) { + t.Fatalf("base %d mutation %q: want errors.Is(%v), got %v", i, m.name, m.want, err) + } + if refOK, _ := refVerifyAccepts(adapter); refOK { + t.Fatalf("base %d mutation %q: independent stdlib verifier ACCEPTED a mutated object", i, m.name) + } + checks++ + } + } + t.Logf("mutation oracle: %d mutated objects across %d base objects, all rejected by BOTH the independent stdlib verifier and production with the expected sentinel", checks, baseObjects) +} diff --git a/pkg/signature/embedded.go b/pkg/signature/embedded.go new file mode 100644 index 0000000000..13e7829396 --- /dev/null +++ b/pkg/signature/embedded.go @@ -0,0 +1,115 @@ +//go:build experimental_signing + +package signature + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "encoding/json" + "fmt" + "io" +) + +// maxEmbeddedContentBytes bounds the decompressed size of the embedded content +// annotation. A ContainerProfile spec is well under this; the cap defends +// against a gzip bomb in the attacker-controlled annotation (etcd allows ~256KB +// of annotation, which gzip can expand ~1000x) driving node-agent OOM, since +// verification runs on every cache load and reconcile tick. +const maxEmbeddedContentBytes = 8 << 20 // 8 MiB + +// encodeEmbeddedContent packs canonical content bytes for the +// AnnotationContent annotation: base64(gzip(bytes)). +func encodeEmbeddedContent(content []byte) (string, error) { + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + if _, err := zw.Write(content); err != nil { + return "", fmt.Errorf("gzip embedded content: %w", err) + } + if err := zw.Close(); err != nil { + return "", fmt.Errorf("gzip close: %w", err) + } + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +// decodeEmbeddedContent unpacks an AnnotationContent value, bounded to +// maxEmbeddedContentBytes to prevent a decompression bomb. +func decodeEmbeddedContent(encoded string) ([]byte, error) { + gz, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("base64-decode embedded content: %w", err) + } + zr, err := gzip.NewReader(bytes.NewReader(gz)) + if err != nil { + return nil, fmt.Errorf("gunzip embedded content: %w", err) + } + defer zr.Close() + // Read one extra byte so an over-limit payload is detected rather than + // silently truncated (a truncation would change the hash → tamper anyway, + // but an explicit error is clearer and cheaper). + out, err := io.ReadAll(io.LimitReader(zr, maxEmbeddedContentBytes+1)) + if err != nil { + return nil, fmt.Errorf("read embedded content: %w", err) + } + if len(out) > maxEmbeddedContentBytes { + return nil, fmt.Errorf("embedded content exceeds %d bytes", maxEmbeddedContentBytes) + } + return out, nil +} + +// EmbeddedContent returns the canonical signed content embedded in the +// object's annotations, if present. When present, the signature was computed +// over exactly these bytes and they are the verified source of truth — the +// object's own spec may have been normalised by the server since. +func EmbeddedContent(obj SignableObject) ([]byte, bool, error) { + if obj == nil { + return nil, false, fmt.Errorf("nil object") + } + annotations := obj.GetAnnotations() + if annotations == nil { + return nil, false, nil + } + enc, ok := annotations[AnnotationContent] + if !ok { + return nil, false, nil + } + content, err := decodeEmbeddedContent(enc) + if err != nil { + return nil, true, err + } + return content, true, nil +} + +// embeddedIdentity is the metadata the embedded content commits to; used to +// bind the signed content to the carrier object it is stapled onto. +type embeddedIdentity struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata struct { + Name string `json:"name"` + Namespace *string `json:"namespace"` + } `json:"metadata"` +} + +// checkEmbeddedBinding binds the signed resource type, name and optional namespace to +// the carrier. Adapters omitting namespace keep their portable-artifact behavior. +// A mismatch is treated as tampering. +func checkEmbeddedBinding(obj SignableObject, embedded []byte) error { + var id embeddedIdentity + if err := json.Unmarshal(embedded, &id); err != nil { + return fmt.Errorf("parse embedded metadata: %w", err) + } + if resource, ok := obj.(ResourceSignableObject); ok { + apiVersion, kind := resource.ExpectedResourceType() + if id.APIVersion != apiVersion || id.Kind != kind { + return fmt.Errorf("embedded content type %q/%q does not match expected %q/%q", id.APIVersion, id.Kind, apiVersion, kind) + } + } + if id.Metadata.Name != obj.GetName() { + return fmt.Errorf("embedded content name %q does not match object %q", id.Metadata.Name, obj.GetName()) + } + if id.Metadata.Namespace != nil && *id.Metadata.Namespace != obj.GetNamespace() { + return fmt.Errorf("embedded content namespace %q does not match object %q", *id.Metadata.Namespace, obj.GetNamespace()) + } + return nil +} diff --git a/pkg/signature/embedded_test.go b/pkg/signature/embedded_test.go new file mode 100644 index 0000000000..b8d85c9fd2 --- /dev/null +++ b/pkg/signature/embedded_test.go @@ -0,0 +1,143 @@ +//go:build experimental_signing + +package signature + +import ( + "errors" + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// embedCP builds a signable CP-like mock via the real adapter path is not +// available here (profiles imports signature), so use the mock object. +func embedContentFixture(t *testing.T) *MockSignableObject { + t.Helper() + content := map[string]interface{}{ + "apiVersion": "spdx.softwarecomposition.kubescape.io/v1beta1", + "kind": "ContainerProfile", + "metadata": map[string]interface{}{ + "name": "frag", + "namespace": "ns", + "labels": map[string]string{"signature.kubescape.io/bundle": "redis"}, + }, + "spec": v1beta1.ContainerProfileSpec{Execs: []v1beta1.ExecCalls{{Path: "/bin/x"}}}, + } + return NewMockSignableObject("uid", "ns", "frag", content) +} + +// TestEmbedContent_VerifySurvivesContentMutation pins the core property of the +// embedded chain: after signing with WithEmbedContent, mutating the OBJECT's +// content (as the storage server does when it normalises specs) must NOT break +// verification — the signature binds the embedded bytes. +func TestEmbedContent_VerifySurvivesContentMutation(t *testing.T) { + obj := embedContentFixture(t) + if err := SignObject(obj, WithKeyless(false), WithEmbedContent(true)); err != nil { + t.Fatalf("sign: %v", err) + } + if _, ok := obj.GetAnnotations()[AnnotationContent]; !ok { + t.Fatal("embedded content annotation missing after sign") + } + // simulate server-side normalisation: the live object's content changes + obj.content = map[string]interface{}{"spec": "normalised-differently"} + if err := VerifyObjectAllowUntrusted(obj); err != nil { + t.Fatalf("verification must bind the embedded bytes, not the mutated object: %v", err) + } +} + +// TestEmbedContent_TamperedEmbeddedFails: modifying the embedded bytes breaks +// the signature (ErrSignatureMismatch → tamper). +func TestEmbedContent_TamperedEmbeddedFails(t *testing.T) { + obj := embedContentFixture(t) + if err := SignObject(obj, WithKeyless(false), WithEmbedContent(true)); err != nil { + t.Fatalf("sign: %v", err) + } + ann := obj.GetAnnotations() + tampered, err := encodeEmbeddedContent([]byte(`{"spec":{"execs":[{"path":"/bin/backdoor"}]}}`)) + if err != nil { + t.Fatal(err) + } + ann[AnnotationContent] = tampered + obj.SetAnnotations(ann) + err = VerifyObjectAllowUntrusted(obj) + if !errors.Is(err, ErrSignatureMismatch) { + t.Fatalf("want ErrSignatureMismatch on tampered embedded content, got %v", err) + } +} + +// TestEmbedContent_RoundTrip: EmbeddedContent returns exactly the signed bytes. +func TestEmbedContent_RoundTrip(t *testing.T) { + obj := embedContentFixture(t) + if err := SignObject(obj, WithKeyless(false), WithEmbedContent(true)); err != nil { + t.Fatalf("sign: %v", err) + } + content, present, err := EmbeddedContent(obj) + if err != nil || !present { + t.Fatalf("EmbeddedContent: present=%v err=%v", present, err) + } + h1, _ := HashBytes(content) + // legacy default (no embed) must not set the annotation + obj2 := embedContentFixture(t) + if err := SignObject(obj2, WithKeyless(false)); err != nil { + t.Fatal(err) + } + if _, ok := obj2.GetAnnotations()[AnnotationContent]; ok { + t.Fatal("embed must be opt-in — annotation set without WithEmbedContent") + } + if h1 == "" { + t.Fatal("empty hash") + } + _ = metav1.ObjectMeta{} +} + +// TestEmbedContent_BoundToCarrier: a validly-signed embedded blob cannot be +// stapled onto a differently-named/namespaced object (C2/V5). +func TestEmbedContent_BoundToCarrier(t *testing.T) { + obj := embedContentFixture(t) // name=frag, ns=ns + if err := SignObject(obj, WithKeyless(false), WithEmbedContent(true)); err != nil { + t.Fatal(err) + } + ann := obj.GetAnnotations() + // staple the signed annotations onto an object with a different name + victim := NewMockSignableObject("uid2", "ns", "different-name", map[string]interface{}{"spec": "x"}) + victim.SetAnnotations(ann) + err := VerifyObjectAllowUntrusted(victim) + if !errors.Is(err, ErrSignatureMismatch) { + t.Fatalf("stapling a signed blob onto a different-named object must fail as tamper; got %v", err) + } +} + +// TestEmbedContent_MalformedIsTamper: a malformed /content annotation on a +// signed object is a tamper signal (R1016), not a swallowed operational error +// (H1). +func TestEmbedContent_MalformedIsTamper(t *testing.T) { + obj := embedContentFixture(t) + if err := SignObject(obj, WithKeyless(false), WithEmbedContent(true)); err != nil { + t.Fatal(err) + } + ann := obj.GetAnnotations() + ann[AnnotationContent] = "!!!not-base64!!!" + obj.SetAnnotations(ann) + err := VerifyObjectAllowUntrusted(obj) + if !errors.Is(err, ErrSignatureMismatch) { + t.Fatalf("malformed embedded content on a signed object must classify as tamper; got %v", err) + } +} + +func TestMalformedSignatureAnnotationsAreSignatureMismatch(t *testing.T) { + for _, annotation := range []string{AnnotationSignature, AnnotationCertificate, AnnotationTimestamp} { + t.Run(annotation, func(t *testing.T) { + obj := embedContentFixture(t) + if err := SignObjectDisableKeyless(obj); err != nil { + t.Fatal(err) + } + annotations := obj.GetAnnotations() + annotations[annotation] = "!malformed!" + obj.SetAnnotations(annotations) + if err := VerifyObjectAllowUntrusted(obj); !errors.Is(err, ErrSignatureMismatch) { + t.Fatalf("malformed signature material must report ErrSignatureMismatch, got %v", err) + } + }) + } +} diff --git a/pkg/signature/hash.go b/pkg/signature/hash.go new file mode 100644 index 0000000000..cfd3ba0b61 --- /dev/null +++ b/pkg/signature/hash.go @@ -0,0 +1,31 @@ +//go:build experimental_signing + +package signature + +import ( + "encoding/json" + "fmt" + + "github.com/kubescape/storage/pkg/utils" +) + +// HashSignableContent returns the canonical content hash of a SignableObject — +// the exact hash that Sign/Verify compute over (json.Marshal of GetContent +// through utils.CanonicalHash, order-independent). Exposed so the bundle layer +// can derive a fragment's leaf digest identically to what its signature covers. +func HashSignableContent(obj SignableObject) (string, error) { + if obj == nil { + return "", fmt.Errorf("nil object") + } + data, err := json.Marshal(obj.GetContent()) + if err != nil { + return "", fmt.Errorf("failed to marshal object: %w", err) + } + return utils.CanonicalHash(data) +} + +// HashBytes returns the canonical hash of raw content bytes — the same hash +// Sign/Verify compute for embedded content. +func HashBytes(content []byte) (string, error) { + return utils.CanonicalHash(content) +} diff --git a/pkg/signature/interface.go b/pkg/signature/interface.go new file mode 100644 index 0000000000..b507f99d81 --- /dev/null +++ b/pkg/signature/interface.go @@ -0,0 +1,85 @@ +//go:build experimental_signing + +package signature + +import ( + "crypto/ecdsa" +) + +type Signer interface { + Sign(data []byte) (*Signature, error) +} + +type Verifier interface { + Verify(data []byte, sig *Signature) error +} + +type SignableObject interface { + GetAnnotations() map[string]string + SetAnnotations(annotations map[string]string) + GetUID() string + GetNamespace() string + GetName() string + GetContent() interface{} + GetUpdatedObject() interface{} +} + +// ResourceSignableObject binds embedded Kubernetes content to the resource type +// the adapter handles. ExpectedResourceType must return constants independent +// of the carrier's mutable TypeMeta. Non-resource config and policy adapters do +// not implement this interface. +type ResourceSignableObject interface { + SignableObject + ExpectedResourceType() (apiVersion, kind string) +} + +type Signature struct { + Signature []byte + Certificate []byte + RekorBundle []byte + Issuer string + Identity string + Timestamp int64 +} + +type SignOptions struct { + UseKeyless bool + PrivateKey *ecdsa.PrivateKey + EmbedContent bool +} + +type SignOption func(*SignOptions) + +func WithKeyless(useKeyless bool) SignOption { + return func(opts *SignOptions) { + opts.UseKeyless = useKeyless + } +} + +func WithPrivateKey(privateKey *ecdsa.PrivateKey) SignOption { + return func(opts *SignOptions) { + opts.PrivateKey = privateKey + } +} + +// WithEmbedContent stores the canonical signed content in the +// signature.kubescape.io/content annotation, making the signature verifiable +// regardless of server-side spec normalisation (the embedded bytes become the +// verified source of truth). +func WithEmbedContent(embed bool) SignOption { + return func(opts *SignOptions) { + opts.EmbedContent = embed + } +} + +type VerifyOptions struct { + AllowUntrusted bool +} + +type VerifyOption func(*VerifyOptions) + +func WithUntrusted(allowUntrusted bool) VerifyOption { + return func(opts *VerifyOptions) { + opts.AllowUntrusted = allowUntrusted + } +} diff --git a/pkg/signature/profiles/adapter_test.go b/pkg/signature/profiles/adapter_test.go new file mode 100644 index 0000000000..f7cd173d5d --- /dev/null +++ b/pkg/signature/profiles/adapter_test.go @@ -0,0 +1,284 @@ +//go:build experimental_signing + +package profiles + +import ( + "errors" + "testing" + + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestContainerProfileAdapter(t *testing.T) { + profile := &v1beta1.ContainerProfile{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "softwarecomposition.kubescape.io/v1beta1", + Kind: "ContainerProfile", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cp", + Namespace: "default", + UID: types.UID("cp-uid-123"), + Labels: map[string]string{"app": "test"}, + }, + Spec: v1beta1.ContainerProfileSpec{ + Architectures: []string{"amd64"}, + Capabilities: []string{"CAP_NET_BIND_SERVICE"}, + }, + } + + adapter := NewContainerProfileAdapter(profile) + if adapter == nil { + t.Fatal("Expected non-nil adapter") + } + if adapter.GetUID() != "cp-uid-123" { + t.Errorf("Expected UID 'cp-uid-123', got '%s'", adapter.GetUID()) + } + if adapter.GetNamespace() != "default" { + t.Errorf("Expected namespace 'default', got '%s'", adapter.GetNamespace()) + } + if adapter.GetName() != "test-cp" { + t.Errorf("Expected name 'test-cp', got '%s'", adapter.GetName()) + } + if got := adapter.GetAnnotations(); got != nil { + t.Errorf("Expected nil annotations on an unset profile, got %v", got) + } + adapter.SetAnnotations(map[string]string{"test-key": "test-value"}) + if profile.Annotations["test-key"] != "test-value" { + t.Error("Failed to set annotations") + } + if got := adapter.GetAnnotations(); got["test-key"] != "test-value" { + t.Error("GetAnnotations should return the set annotations") + } + + content, ok := adapter.GetContent().(map[string]interface{}) + if !ok { + t.Fatal("Expected map[string]interface{} content type") + } + metadata, ok := content["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("Expected metadata to be map[string]interface{}") + } + if metadata["name"] != "test-cp" { + t.Errorf("Expected content name 'test-cp', got '%v'", metadata["name"]) + } + // metadata.namespace is deliberately absent from the signed content so a + // vendor-signed artifact can be installed into any namespace. + if _, present := metadata["namespace"]; present { + t.Errorf("namespace must not be part of the signed content, got '%v'", metadata["namespace"]) + } + if content["apiVersion"] != "softwarecomposition.kubescape.io/v1beta1" { + t.Errorf("Expected apiVersion, got '%v'", content["apiVersion"]) + } + if content["kind"] != "ContainerProfile" { + t.Errorf("Expected kind 'ContainerProfile', got '%v'", content["kind"]) + } +} + +func TestContainerProfileAdapterSignAndVerify(t *testing.T) { + profile := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sign-test-cp", + Namespace: "default", + UID: types.UID("sign-cp-uid"), + Labels: map[string]string{"test": "signing"}, + }, + Spec: v1beta1.ContainerProfileSpec{ + Architectures: []string{"amd64", "arm64"}, + Capabilities: []string{"CAP_NET_ADMIN"}, + }, + } + + adapter := NewContainerProfileAdapter(profile) + if err := signature.SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("SignObjectDisableKeyless failed: %v", err) + } + if _, ok := profile.Annotations[signature.AnnotationSignature]; !ok { + t.Error("Expected signature annotation on profile") + } + if err := signature.VerifyObjectAllowUntrusted(adapter); err != nil { + t.Fatalf("VerifyObjectAllowUntrusted failed: %v", err) + } +} + +func TestSeccompProfileAdapter(t *testing.T) { + profile := &v1beta1.SeccompProfile{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "softwarecomposition.kubescape.io/v1beta1", + Kind: "SeccompProfile", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-seccomp", + Namespace: "default", + UID: types.UID("seccomp-uid-456"), + Labels: map[string]string{"seccomp": "test"}, + }, + Spec: v1beta1.SeccompProfileSpec{ + Containers: []v1beta1.SingleSeccompProfile{{Name: "test-container"}}, + }, + } + + adapter := NewSeccompProfileAdapter(profile) + if adapter == nil { + t.Fatal("Expected non-nil adapter") + } + if adapter.GetUID() != "seccomp-uid-456" { + t.Errorf("Expected UID 'seccomp-uid-456', got '%s'", adapter.GetUID()) + } + if adapter.GetName() != "test-seccomp" { + t.Errorf("Expected name 'test-seccomp', got '%s'", adapter.GetName()) + } + if got := adapter.GetAnnotations(); got != nil { + t.Errorf("Expected nil annotations on an unset profile, got %v", got) + } + adapter.SetAnnotations(map[string]string{"seccomp-key": "seccomp-value"}) + if profile.Annotations["seccomp-key"] != "seccomp-value" { + t.Error("Failed to set annotations") + } + scContent, ok := adapter.GetContent().(map[string]interface{}) + if !ok { + t.Fatal("Expected map[string]interface{} content type") + } + if scContent["kind"] != "SeccompProfile" { + t.Errorf("Expected kind 'SeccompProfile', got '%v'", scContent["kind"]) + } +} + +func TestSeccompProfileAdapterSignAndVerify(t *testing.T) { + profile := &v1beta1.SeccompProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sign-test-seccomp", + Namespace: "default", + UID: types.UID("sign-seccomp-uid"), + Labels: map[string]string{"test": "seccomp-signing"}, + }, + Spec: v1beta1.SeccompProfileSpec{ + Containers: []v1beta1.SingleSeccompProfile{{Name: "app-container"}}, + }, + } + + adapter := NewSeccompProfileAdapter(profile) + if err := signature.SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("SignObjectDisableKeyless failed: %v", err) + } + if _, ok := profile.Annotations[signature.AnnotationSignature]; !ok { + t.Error("Expected signature annotation on profile") + } + if err := signature.VerifyObjectAllowUntrusted(adapter); err != nil { + t.Fatalf("VerifyObjectAllowUntrusted failed: %v", err) + } +} + +func TestAdapterUniqueness(t *testing.T) { + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "unique-cp", Namespace: "default", UID: types.UID("cp-unique-uid")}, + Spec: v1beta1.ContainerProfileSpec{Architectures: []string{"amd64"}}, + } + sp := &v1beta1.SeccompProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "unique-sp", Namespace: "default", UID: types.UID("sp-unique-uid")}, + Spec: v1beta1.SeccompProfileSpec{}, + } + cpAdapter := NewContainerProfileAdapter(cp) + spAdapter := NewSeccompProfileAdapter(sp) + + if err := signature.SignObjectDisableKeyless(cpAdapter); err != nil { + t.Fatalf("sign CP: %v", err) + } + if err := signature.SignObjectDisableKeyless(spAdapter); err != nil { + t.Fatalf("sign SP: %v", err) + } + cpSig, err := signature.GetObjectSignature(cpAdapter) + if err != nil { + t.Fatalf("GetObjectSignature CP: %v", err) + } + spSig, err := signature.GetObjectSignature(spAdapter) + if err != nil { + t.Fatalf("GetObjectSignature SP: %v", err) + } + if cpSig == nil || spSig == nil { + t.Fatal("nil signature") + } + if cpSig.Issuer != "local" || spSig.Issuer != "local" { + t.Errorf("Expected issuer 'local', got CP=%q SP=%q", cpSig.Issuer, spSig.Issuer) + } +} + +// TestContainerProfileAdapter_GetContentDoesNotMutate pins that GetContent +// normalizes PolicyByRuleId on a copy and never mutates the wrapped profile. +func TestContainerProfileAdapter_GetContentDoesNotMutate(t *testing.T) { + profile := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "cp", Namespace: "default"}, + Spec: v1beta1.ContainerProfileSpec{Execs: []v1beta1.ExecCalls{{Path: "/bin/sh"}}}, + } + adapter := NewContainerProfileAdapter(profile) + + content := adapter.GetContent().(map[string]interface{}) + + if profile.Spec.PolicyByRuleId != nil { + t.Error("GetContent mutated PolicyByRuleId on the wrapped profile") + } + if got := adapter.GetAnnotations(); got != nil { + t.Errorf("GetAnnotations must be read-only, got %v", got) + } + spec := content["spec"].(v1beta1.ContainerProfileSpec) + if spec.PolicyByRuleId == nil { + t.Error("GetContent should normalize PolicyByRuleId to {} in the returned content") + } +} + +// TestContainerProfileAdapter_SignFromNilAnnotations pins that signing an object +// starting with NO annotations works end to end and IsSigned reads a nil map +// without panicking. +func TestContainerProfileAdapter_SignFromNilAnnotations(t *testing.T) { + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "no-annotations", Namespace: "default"}, + Spec: v1beta1.ContainerProfileSpec{Architectures: []string{"amd64"}}, + } + if cp.Annotations != nil { + t.Fatal("precondition: profile must start with nil annotations") + } + adapter := NewContainerProfileAdapter(cp) + + if signature.IsSigned(adapter) { + t.Fatal("IsSigned must be false (and must not panic) for nil annotations") + } + if err := signature.SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("signing an annotation-less object must still work, got %v", err) + } + if cp.Annotations == nil { + t.Fatal("signing should have attached the signature annotation") + } + if !signature.IsSigned(adapter) { + t.Fatal("object should report as signed after SignObject") + } +} + +func TestEmbeddedSeccompProfileBindsNamespace(t *testing.T) { + profile := &v1beta1.SeccompProfile{ObjectMeta: metav1.ObjectMeta{Name: "sandbox", Namespace: "source"}} + adapter := NewSeccompProfileAdapter(profile) + if err := signature.SignObject(adapter, signature.WithKeyless(false), signature.WithEmbedContent(true)); err != nil { + t.Fatal(err) + } + if err := signature.VerifyObjectAllowUntrusted(adapter); err != nil { + t.Fatal(err) + } + profile.Namespace = "target" + if err := signature.VerifyObjectAllowUntrusted(adapter); !errors.Is(err, signature.ErrSignatureMismatch) { + t.Fatalf("relocating namespace-bound seccomp content must fail: %v", err) + } +} + +func TestEmbeddedContainerProfileRemainsNamespacePortable(t *testing.T) { + profile := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Name: "portable", Namespace: "source"}} + adapter := NewContainerProfileAdapter(profile) + if err := signature.SignObject(adapter, signature.WithKeyless(false), signature.WithEmbedContent(true)); err != nil { + t.Fatal(err) + } + profile.Namespace = "target" + if err := signature.VerifyObjectAllowUntrusted(adapter); err != nil { + t.Fatalf("an adapter omitting namespace must stay portable: %v", err) + } +} diff --git a/pkg/signature/profiles/binding_adapter.go b/pkg/signature/profiles/binding_adapter.go new file mode 100644 index 0000000000..8a0c3de9fb --- /dev/null +++ b/pkg/signature/profiles/binding_adapter.go @@ -0,0 +1,66 @@ +//go:build experimental_signing + +package profiles + +import ( + rulebindingtypesv1 "github.com/kubescape/node-agent/pkg/rulebindingmanager/types/v1" +) + +type RuleBindingAdapter struct { + binding *rulebindingtypesv1.RuntimeAlertRuleBinding +} + +func NewRuleBindingAdapter(binding *rulebindingtypesv1.RuntimeAlertRuleBinding) *RuleBindingAdapter { + return &RuleBindingAdapter{ + binding: binding, + } +} + +func (r *RuleBindingAdapter) GetAnnotations() map[string]string { + return r.binding.Annotations +} + +func (r *RuleBindingAdapter) SetAnnotations(annotations map[string]string) { + r.binding.Annotations = annotations +} + +func (r *RuleBindingAdapter) GetUID() string { + return string(r.binding.UID) +} + +func (r *RuleBindingAdapter) GetNamespace() string { + return r.binding.Namespace +} + +func (r *RuleBindingAdapter) GetName() string { + return r.binding.Name +} + +func (r *RuleBindingAdapter) GetContent() interface{} { + apiVersion := r.binding.APIVersion + if apiVersion == "" { + apiVersion = "kubescape.io/v1" + } + kind := r.binding.Kind + if kind == "" { + kind = "RuntimeRuleAlertBinding" + } + return map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "name": r.binding.Name, + "labels": r.binding.Labels, + }, + "spec": r.binding.Spec, + } +} + +func (r *RuleBindingAdapter) GetUpdatedObject() interface{} { + return r.binding +} + +// ExpectedResourceType identifies the resource handled by this adapter, independently of live TypeMeta. +func (*RuleBindingAdapter) ExpectedResourceType() (apiVersion, kind string) { + return "kubescape.io/v1", "RuntimeRuleAlertBinding" +} diff --git a/pkg/signature/profiles/containerprofile_adapter.go b/pkg/signature/profiles/containerprofile_adapter.go new file mode 100644 index 0000000000..ff9e6bd5a2 --- /dev/null +++ b/pkg/signature/profiles/containerprofile_adapter.go @@ -0,0 +1,93 @@ +//go:build experimental_signing + +package profiles + +import ( + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +// ContainerProfileAdapter wraps a ContainerProfile as a SignableObject. +// +// The AP/NN → ContainerProfile migration collapses the former +// ApplicationProfileAdapter + NetworkNeighborhoodAdapter pair into this single +// adapter: a ContainerProfile already carries both the process/file surface +// (execs/opens/caps/syscalls/seccomp/rulePolicies) and the network surface +// (ingress/egress/endpoints), so one signed object covers what previously took +// two. The signed content is metadata{name,namespace,labels} + spec and +// deliberately EXCLUDES annotations, which storage mutates on save +// (managed-by, completion, size, sync-checksum, and the signature annotations +// themselves). +type ContainerProfileAdapter struct { + profile *v1beta1.ContainerProfile +} + +func NewContainerProfileAdapter(profile *v1beta1.ContainerProfile) *ContainerProfileAdapter { + return &ContainerProfileAdapter{ + profile: profile, + } +} + +func (a *ContainerProfileAdapter) GetAnnotations() map[string]string { + // Read-only: must not mutate the wrapped (often cached) profile. nil-map + // initialization belongs on the write path (SetAnnotations). + return a.profile.Annotations +} + +func (a *ContainerProfileAdapter) SetAnnotations(annotations map[string]string) { + a.profile.Annotations = annotations +} + +func (a *ContainerProfileAdapter) GetUID() string { + return string(a.profile.UID) +} + +func (a *ContainerProfileAdapter) GetNamespace() string { + return a.profile.Namespace +} + +func (a *ContainerProfileAdapter) GetName() string { + return a.profile.Name +} + +func (a *ContainerProfileAdapter) GetContent() interface{} { + // Work on a deep copy so signing/verification never mutates the wrapped + // profile. PolicyByRuleId is normalized (nil -> {}) on the copy only, for a + // stable JSON representation in the signed content. + profile := a.profile.DeepCopy() + if profile.Spec.PolicyByRuleId == nil { + profile.Spec.PolicyByRuleId = make(map[string]v1beta1.RulePolicy) + } + + apiVersion := profile.APIVersion + if apiVersion == "" { + apiVersion = "spdx.softwarecomposition.kubescape.io/v1beta1" + } + kind := profile.Kind + if kind == "" { + kind = "ContainerProfile" + } + return map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + // metadata.namespace is deliberately NOT signed: a vendor cannot know + // which namespace a customer will install into, and re-signing per + // namespace would defeat offline signing. Placement is a deployment + // decision governed by RBAC; what the signature binds is the content and + // the labels (bundle membership + fragment class), which is what confines + // a fragment's authority. + "metadata": map[string]interface{}{ + "name": profile.Name, + "labels": profile.Labels, + }, + "spec": profile.Spec, + } +} + +func (a *ContainerProfileAdapter) GetUpdatedObject() interface{} { + return a.profile +} + +// ExpectedResourceType identifies the resource handled by this adapter, independently of live TypeMeta. +func (*ContainerProfileAdapter) ExpectedResourceType() (apiVersion, kind string) { + return "spdx.softwarecomposition.kubescape.io/v1beta1", "ContainerProfile" +} diff --git a/pkg/signature/profiles/deflate_signature_test.go b/pkg/signature/profiles/deflate_signature_test.go new file mode 100644 index 0000000000..acc89ef8aa --- /dev/null +++ b/pkg/signature/profiles/deflate_signature_test.go @@ -0,0 +1,170 @@ +//go:build experimental_signing + +package profiles + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + mapset "github.com/deckarep/golang-set/v2" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/storage/pkg/apis/softwarecomposition" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + storagefile "github.com/kubescape/storage/pkg/registry/file" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// deflateSpec runs storage's write-path rewrite over a spec, exactly as +// ContainerProfileProcessor.PreSave does, and returns it as the versioned type +// the signature is computed over. +func deflateSpec(t *testing.T, in v1beta1.ContainerProfileSpec) v1beta1.ContainerProfileSpec { + t.Helper() + var internal softwarecomposition.ContainerProfileSpec + require.NoError(t, v1beta1.Convert_v1beta1_ContainerProfileSpec_To_softwarecomposition_ContainerProfileSpec(&in, &internal, nil)) + deflated := storagefile.DeflateContainerProfileSpec(internal, mapset.NewSet[string](), dynamicpathdetector.DefaultCollapseSettings()) + var out v1beta1.ContainerProfileSpec + require.NoError(t, v1beta1.Convert_softwarecomposition_ContainerProfileSpec_To_v1beta1_ContainerProfileSpec(&deflated, &out, nil)) + return out +} + +func specJSON(t *testing.T, s v1beta1.ContainerProfileSpec) string { + t.Helper() + b, err := json.Marshal(s) + require.NoError(t, err) + return string(b) +} + +func TestConversionAloneDoesNotDisturbASignedSpec(t *testing.T) { + in := v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{{Identifier: "peers", Type: v1beta1.CommunicationTypeEgress, IPAddresses: []string{"10.10.0.1"}}}, + } + var internal softwarecomposition.ContainerProfileSpec + require.NoError(t, v1beta1.Convert_v1beta1_ContainerProfileSpec_To_softwarecomposition_ContainerProfileSpec(&in, &internal, nil)) + var back v1beta1.ContainerProfileSpec + require.NoError(t, v1beta1.Convert_softwarecomposition_ContainerProfileSpec_To_v1beta1_ContainerProfileSpec(&internal, &back, nil)) + require.Equal(t, specJSON(t, in), specJSON(t, back), + "the internal/versioned round-trip must be lossless, or every result below measures the conversion instead of the deflate") +} + +func TestDeflateIsAFixpointAfterOnePass(t *testing.T) { + in := v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{{Identifier: "peers", Type: v1beta1.CommunicationTypeEgress, IPAddresses: []string{"10.10.0.2", "10.10.0.1"}}}, + } + once := deflateSpec(t, in) + twice := deflateSpec(t, once) + require.Equal(t, specJSON(t, once), specJSON(t, twice), + "deflate must be idempotent, otherwise no authored document could ever be signed stably") +} + +// entlein/node-agent#51. Storage deflates every ContainerProfile spec on write +// (ContainerProfileProcessor.PreSave, no provenance check) and the signature +// covers the spec, so the signed bytes survive the write only if what was +// signed is ALREADY a deflate fixpoint. Exceeding the address-collapse +// threshold is one way to not be a fixpoint; this is another, reached with two +// addresses and no collapse at all. +func TestSigningAPreDeflateSpecLosesTheSignatureOnWrite(t *testing.T) { + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "authored", Namespace: "flux"}, + Spec: v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{{Identifier: "peers", Type: v1beta1.CommunicationTypeEgress, IPAddresses: []string{"10.10.0.1", "10.10.0.2"}}}, + }, + } + require.NoError(t, signature.SignObjectDisableKeyless(NewContainerProfileAdapter(cp))) + require.NoError(t, signature.VerifyObjectAllowUntrusted(NewContainerProfileAdapter(cp))) + + stored := cp.DeepCopy() + stored.Spec = deflateSpec(t, cp.Spec) + require.NotEqual(t, specJSON(t, cp.Spec), specJSON(t, stored.Spec), + "the premise: an authored spec is not automatically in deflated form") + require.Error(t, signature.VerifyObjectAllowUntrusted(NewContainerProfileAdapter(stored)), + "#51: the stored spec differs from the signed one, so verification fails") +} + +// Signing the deflated form is what makes it survive, which is the shape of the +// mitigation a generator has to adopt while #51 is open. +func TestSigningTheDeflatedFormSurvivesTheWrite(t *testing.T) { + spec := v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{{Identifier: "peers", Type: v1beta1.CommunicationTypeEgress, IPAddresses: []string{"10.10.0.1", "10.10.0.2"}}}, + } + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "authored", Namespace: "flux"}, + Spec: deflateSpec(t, spec), + } + require.NoError(t, signature.SignObjectDisableKeyless(NewContainerProfileAdapter(cp))) + + stored := cp.DeepCopy() + stored.Spec = deflateSpec(t, cp.Spec) + require.NoError(t, signature.VerifyObjectAllowUntrusted(NewContainerProfileAdapter(stored)), + "a signed deflate fixpoint must survive the write unchanged") +} + +// The identifier merge reaches the same outage without any address collapse: +// two distinct entries sharing an Identifier become one whose ports are the +// union, which is both a signature break and a silent widening. +func TestDuplicateIdentifiersMergeAndUnionPorts(t *testing.T) { + port80, port443 := int32(80), int32(443) + spec := v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{ + {Identifier: "peer", Type: v1beta1.CommunicationTypeEgress, IPAddresses: []string{"10.0.0.1"}, + Ports: []v1beta1.NetworkPort{{Name: "TCP-80", Protocol: v1beta1.ProtocolTCP, Port: &port80}}}, + {Identifier: "peer", Type: v1beta1.CommunicationTypeEgress, IPAddresses: []string{"10.0.0.2"}, + Ports: []v1beta1.NetworkPort{{Name: "TCP-443", Protocol: v1beta1.ProtocolTCP, Port: &port443}}}, + }, + } + stored := deflateSpec(t, spec) + require.Len(t, stored.Egress, 1, "entries sharing an identifier are merged into one") + require.Len(t, stored.Egress[0].Ports, 2, + "and their ports are unioned — the merged entry admits both, which neither original did") +} + +func TestAddressCollapseRewritesAboveTheThreshold(t *testing.T) { + n := dynamicpathdetector.NetworkIPGroupThreshold + 1 + addrs := make([]string, 0, n) + for i := 0; i < n; i++ { + addrs = append(addrs, fmt.Sprintf("10.10.%d.%d", i/250, i%250)) + } + spec := v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{{Identifier: "peers", Type: v1beta1.CommunicationTypeEgress, IPAddresses: addrs}}, + } + stored := deflateSpec(t, spec) + require.NotEqual(t, addrs, stored.Egress[0].IPAddresses, + "above the collapse threshold the literals are replaced by a CIDR cover") + require.Less(t, len(stored.Egress[0].IPAddresses), n, + "the cover must be smaller than the set it replaces") +} + +// Every signed ContainerProfile fixture in this repo must be a deflate +// fixpoint. They are today, which is why #51 has never bitten a component test +// — not because they are small. A fixture that stops being one would fail its +// signature check only on a live cluster, long after the commit that broke it. +func TestCommittedSignedFixturesAreDeflateFixpoints(t *testing.T) { + files, err := filepath.Glob("../../../tests/resources/signed/fragments/*-signed.yaml") + require.NoError(t, err) + require.NotEmpty(t, files, "the fixtures must be found, or this guard passes vacuously") + + checked := 0 + for _, f := range files { + b, err := os.ReadFile(f) + require.NoError(t, err) + cp := &v1beta1.ContainerProfile{} + if err := yaml.Unmarshal(b, cp); err != nil || cp.Kind != "ContainerProfile" { + continue + } + checked++ + name := filepath.Base(f) + require.NoErrorf(t, signature.VerifyObjectAllowUntrusted(NewContainerProfileAdapter(cp)), + "%s must verify as committed", name) + + stored := cp.DeepCopy() + stored.Spec = deflateSpec(t, cp.Spec) + require.NoErrorf(t, signature.VerifyObjectAllowUntrusted(NewContainerProfileAdapter(stored)), + "%s must still verify after storage's write-path deflate (#51)", name) + } + require.NotZero(t, checked, "no ContainerProfile fixtures were actually checked") +} diff --git a/pkg/signature/profiles/embedded_type_test.go b/pkg/signature/profiles/embedded_type_test.go new file mode 100644 index 0000000000..7680a550c5 --- /dev/null +++ b/pkg/signature/profiles/embedded_type_test.go @@ -0,0 +1,133 @@ +//go:build experimental_signing + +package profiles + +import ( + "errors" + "testing" + + bindingtypes "github.com/kubescape/node-agent/pkg/rulebindingmanager/types/v1" + ruletypes "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// alteredContent signs authentic bytes with an intentionally invalid resource +// identity, so tests exercise binding rather than a broken cryptographic hash. +type alteredContent struct { + signature.SignableObject + content interface{} +} + +func (a alteredContent) GetContent() interface{} { return a.content } + +func resourceAdapters() []struct { + name string + newObject func() signature.ResourceSignableObject + portable bool +} { + meta := metav1.ObjectMeta{Name: "shared-name", Namespace: "source"} + return []struct { + name string + newObject func() signature.ResourceSignableObject + portable bool + }{ + {"ContainerProfile", func() signature.ResourceSignableObject { + return NewContainerProfileAdapter(&v1beta1.ContainerProfile{ObjectMeta: meta}) + }, true}, + {"SeccompProfile", func() signature.ResourceSignableObject { + return NewSeccompProfileAdapter(&v1beta1.SeccompProfile{ObjectMeta: meta}) + }, false}, + {"Rules", func() signature.ResourceSignableObject { + return NewRulesAdapter(&ruletypes.Rules{ObjectMeta: meta}) + }, true}, + {"RuleBinding", func() signature.ResourceSignableObject { + return NewRuleBindingAdapter(&bindingtypes.RuntimeAlertRuleBinding{ObjectMeta: meta}) + }, true}, + } +} + +func signEmbedded(t *testing.T, obj signature.SignableObject) { + t.Helper() + if err := signature.SignObject(obj, signature.WithKeyless(false), signature.WithEmbedContent(true)); err != nil { + t.Fatal(err) + } +} + +func TestEmbeddedResourceRejectsContainerProfileReplay(t *testing.T) { + source := NewContainerProfileAdapter(&v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "shared-name", Namespace: "source"}, + Spec: v1beta1.ContainerProfileSpec{Architectures: []string{"amd64"}}, + }) + signEmbedded(t, source) + if err := signature.VerifyObjectAllowUntrusted(source); err != nil { + t.Fatalf("source signature must be valid: %v", err) + } + for _, tc := range resourceAdapters()[1:] { + t.Run(tc.name, func(t *testing.T) { + target := tc.newObject() + target.SetAnnotations(source.GetAnnotations()) + // Spoofing the live TypeMeta must not change the expected adapter type. + carrier := target.GetUpdatedObject().(interface { + GetObjectKind() schema.ObjectKind + }) + carrier.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{ + Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Kind: "ContainerProfile", + }) + if err := signature.VerifyObjectAllowUntrusted(target); !errors.Is(err, signature.ErrSignatureMismatch) { + t.Fatalf("cross-resource replay must fail as tampering: %v", err) + } + }) + } +} + +func TestEmbeddedResourceRequiresExpectedType(t *testing.T) { + for _, tc := range resourceAdapters() { + t.Run(tc.name, func(t *testing.T) { + for _, field := range []string{"apiVersion", "kind"} { + for _, value := range []string{"", "other"} { + t.Run(field+"/"+value, func(t *testing.T) { + obj := tc.newObject() + content := obj.GetContent().(map[string]interface{}) + if value == "" { + delete(content, field) + } else { + content[field] = value + } + signEmbedded(t, alteredContent{SignableObject: obj, content: content}) + if err := signature.VerifyObjectAllowUntrusted(obj); !errors.Is(err, signature.ErrSignatureMismatch) { + t.Fatalf("missing or mismatched %s must fail: %v", field, err) + } + }) + } + } + }) + } +} + +func TestEmbeddedResourceEmptyTypeMetaAndNamespacePortability(t *testing.T) { + for _, tc := range resourceAdapters() { + t.Run(tc.name, func(t *testing.T) { + obj := tc.newObject() + content := obj.GetContent().(map[string]interface{}) + apiVersion, kind := obj.ExpectedResourceType() + if content["apiVersion"] != apiVersion || content["kind"] != kind { + t.Fatal("empty TypeMeta must use the expected resource type") + } + signEmbedded(t, obj) + if err := signature.VerifyObjectAllowUntrusted(obj); err != nil { + t.Fatalf("canonical fallback must verify: %v", err) + } + obj.GetUpdatedObject().(interface{ SetNamespace(string) }).SetNamespace("destination") + err := signature.VerifyObjectAllowUntrusted(obj) + if tc.portable && err != nil { + t.Fatalf("same-kind relocation must remain portable: %v", err) + } + if !tc.portable && !errors.Is(err, signature.ErrSignatureMismatch) { + t.Fatalf("namespace-bound resource relocation must fail: %v", err) + } + }) + } +} diff --git a/pkg/signature/profiles/empty_typemeta_test.go b/pkg/signature/profiles/empty_typemeta_test.go new file mode 100644 index 0000000000..ca847c4fac --- /dev/null +++ b/pkg/signature/profiles/empty_typemeta_test.go @@ -0,0 +1,80 @@ +//go:build experimental_signing + +package profiles + +import ( + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestContainerProfileAdapterEmptyTypeMeta(t *testing.T) { + profile := &v1beta1.ContainerProfile{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "", + Kind: "", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ap", + Namespace: "default", + }, + Spec: v1beta1.ContainerProfileSpec{ + Architectures: []string{"amd64"}, + }, + } + + adapter := NewContainerProfileAdapter(profile) + + content := adapter.GetContent() + if content == nil { + t.Fatal("Expected non-nil content") + } + + apContent, ok := content.(map[string]interface{}) + if !ok { + t.Fatal("Expected map[string]interface{} content type") + } + + if apContent["apiVersion"] != "spdx.softwarecomposition.kubescape.io/v1beta1" { + t.Errorf("Expected fallback apiVersion 'spdx.softwarecomposition.kubescape.io/v1beta1', got '%v'", apContent["apiVersion"]) + } + + if apContent["kind"] != "ContainerProfile" { + t.Errorf("Expected fallback kind 'ContainerProfile', got '%v'", apContent["kind"]) + } +} + +func TestSeccompProfileAdapterEmptyTypeMeta(t *testing.T) { + profile := &v1beta1.SeccompProfile{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "", + Kind: "", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-seccomp", + Namespace: "default", + }, + Spec: v1beta1.SeccompProfileSpec{}, + } + + adapter := NewSeccompProfileAdapter(profile) + + content := adapter.GetContent() + if content == nil { + t.Fatal("Expected non-nil content") + } + + scContent, ok := content.(map[string]interface{}) + if !ok { + t.Fatal("Expected map[string]interface{} content type") + } + + if scContent["apiVersion"] != "spdx.softwarecomposition.kubescape.io/v1beta1" { + t.Errorf("Expected fallback apiVersion 'spdx.softwarecomposition.kubescape.io/v1beta1', got '%v'", scContent["apiVersion"]) + } + + if scContent["kind"] != "SeccompProfile" { + t.Errorf("Expected fallback kind 'SeccompProfile', got '%v'", scContent["kind"]) + } +} diff --git a/pkg/signature/profiles/rules_adapter.go b/pkg/signature/profiles/rules_adapter.go new file mode 100644 index 0000000000..cbc2f302a0 --- /dev/null +++ b/pkg/signature/profiles/rules_adapter.go @@ -0,0 +1,69 @@ +//go:build experimental_signing + +package profiles + +import ( + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" +) + +type RulesAdapter struct { + rules *rulemanagertypesv1.Rules +} + +func NewRulesAdapter(rules *rulemanagertypesv1.Rules) *RulesAdapter { + return &RulesAdapter{ + rules: rules, + } +} + +func (r *RulesAdapter) GetAnnotations() map[string]string { + return r.rules.Annotations +} + +func (r *RulesAdapter) SetAnnotations(annotations map[string]string) { + r.rules.Annotations = annotations +} + +func (r *RulesAdapter) GetUID() string { + return string(r.rules.UID) +} + +func (r *RulesAdapter) GetNamespace() string { + return r.rules.Namespace +} + +func (r *RulesAdapter) GetName() string { + return r.rules.Name +} + +func (r *RulesAdapter) GetContent() interface{} { + apiVersion := r.rules.APIVersion + if apiVersion == "" { + apiVersion = "kubescape.io/v1" + } + kind := r.rules.Kind + if kind == "" { + kind = "Rules" + } + return map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + // metadata.namespace is deliberately NOT signed — see the note in + // containerprofile_adapter.go. Which namespace a rules fragment applies to + // is decided by where it is installed, not by the vendor's signature. + "metadata": map[string]interface{}{ + "name": r.rules.Name, + "labels": r.rules.Labels, + }, + "spec": r.rules.Spec, + } +} + +func (r *RulesAdapter) GetUpdatedObject() interface{} { + return r.rules +} + +// ExpectedResourceType identifies the resource handled by this adapter, independently of live TypeMeta. +func (*RulesAdapter) ExpectedResourceType() (apiVersion, kind string) { + return "kubescape.io/v1", "Rules" +} diff --git a/pkg/signature/profiles/rules_adapter_test.go b/pkg/signature/profiles/rules_adapter_test.go new file mode 100644 index 0000000000..fdbbf0ed9d --- /dev/null +++ b/pkg/signature/profiles/rules_adapter_test.go @@ -0,0 +1,187 @@ +//go:build experimental_signing + +package profiles + +import ( + "strings" + "testing" + + rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/signature" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" +) + +func TestRulesAdapterGetContent(t *testing.T) { + rules := &rulemanagertypesv1.Rules{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rules", + Namespace: "default", + UID: k8stypes.UID("test-uid"), + Labels: map[string]string{"label": "value"}, + }, + Spec: rulemanagertypesv1.RulesSpec{ + Rules: []rulemanagertypesv1.Rule{ + { + Enabled: true, + ID: "rule-1", + Name: "Test Rule", + Description: "A test rule", + Expressions: rulemanagertypesv1.RuleExpressions{ + Message: "message", + UniqueID: "uniqueId", + RuleExpression: []rulemanagertypesv1.RuleExpression{}, + }, + ProfileDependency: 0, + Severity: 1, + SupportPolicy: false, + Tags: []string{"test"}, + }, + }, + }, + } + + adapter := NewRulesAdapter(rules) + content := adapter.GetContent() + + if content == nil { + t.Fatal("Expected content not to be nil") + } + + contentMap, ok := content.(map[string]interface{}) + if !ok { + t.Fatal("Expected content to be a map") + } + + if contentMap["apiVersion"] != "kubescape.io/v1" { + t.Errorf("Expected apiVersion 'kubescape.io/v1', got '%v'", contentMap["apiVersion"]) + } + + if contentMap["kind"] != "Rules" { + t.Errorf("Expected kind 'Rules', got '%v'", contentMap["kind"]) + } + + metadata, ok := contentMap["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("Expected metadata to be a map") + } + + if metadata["name"] != "test-rules" { + t.Errorf("Expected name 'test-rules', got '%v'", metadata["name"]) + } + + // namespace is deliberately not signed — see adapter_test.go. + if _, present := metadata["namespace"]; present { + t.Errorf("namespace must not be part of the signed content, got '%v'", metadata["namespace"]) + } + + if _, ok := contentMap["spec"]; !ok { + t.Error("Expected spec in content") + } +} + +func TestRulesAdapterSignAndVerify(t *testing.T) { + rules := &rulemanagertypesv1.Rules{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "kubescape.io/v1", + Kind: "Rules", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "sign-test-rules", + Namespace: "default", + UID: k8stypes.UID("sign-rules-uid"), + Labels: map[string]string{ + "test": "rules-signing", + }, + }, + Spec: rulemanagertypesv1.RulesSpec{ + Rules: []rulemanagertypesv1.Rule{ + { + Enabled: true, + ID: "test-rule-id", + Name: "Test Rule", + Description: "A test rule", + Expressions: rulemanagertypesv1.RuleExpressions{ + Message: "message", + UniqueID: "uniqueId", + RuleExpression: []rulemanagertypesv1.RuleExpression{}, + }, + ProfileDependency: 0, + Severity: 1, + SupportPolicy: false, + Tags: []string{"test"}, + }, + }, + }, + } + + adapter := NewRulesAdapter(rules) + + err := signature.SignObjectDisableKeyless(adapter) + if err != nil { + t.Fatalf("SignObjectDisableKeyless failed: %v", err) + } + + if rules.Annotations == nil { + t.Error("Expected annotations to be set on rules") + } + + if _, ok := rules.Annotations[signature.AnnotationSignature]; !ok { + t.Error("Expected signature annotation on rules") + } + + err = signature.VerifyObjectAllowUntrusted(adapter) + if err != nil { + t.Fatalf("VerifyObjectAllowUntrusted failed: %v", err) + } +} + +func TestRulesAdapterSignAndVerifyWithTampering(t *testing.T) { + rules := &rulemanagertypesv1.Rules{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "kubescape.io/v1", + Kind: "Rules", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "tamper-test-rules", + Namespace: "default", + }, + Spec: rulemanagertypesv1.RulesSpec{ + Rules: []rulemanagertypesv1.Rule{ + { + Enabled: true, + ID: "tamper-rule-id", + Name: "Tamper Test Rule", + Description: "A tamper test rule", + Expressions: rulemanagertypesv1.RuleExpressions{ + Message: "message", + UniqueID: "uniqueId", + RuleExpression: []rulemanagertypesv1.RuleExpression{}, + }, + ProfileDependency: 0, + Severity: 1, + SupportPolicy: false, + Tags: []string{"test"}, + }, + }, + }, + } + + adapter := NewRulesAdapter(rules) + + err := signature.SignObjectDisableKeyless(adapter) + if err != nil { + t.Fatalf("SignObjectDisableKeyless failed: %v", err) + } + + rules.Spec.Rules[0].Name = "Modified Rule Name" + + err = signature.VerifyObjectAllowUntrusted(adapter) + if err == nil { + t.Fatal("Expected verification to fail after tampering, but it succeeded") + } + + if !strings.Contains(err.Error(), "signature verification failed") { + t.Errorf("Expected signature verification error, got: %v", err) + } +} diff --git a/pkg/signature/profiles/seccompprofile_adapter.go b/pkg/signature/profiles/seccompprofile_adapter.go new file mode 100644 index 0000000000..63a389bda1 --- /dev/null +++ b/pkg/signature/profiles/seccompprofile_adapter.go @@ -0,0 +1,69 @@ +//go:build experimental_signing + +package profiles + +import ( + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +type SeccompProfileAdapter struct { + profile *v1beta1.SeccompProfile +} + +func NewSeccompProfileAdapter(profile *v1beta1.SeccompProfile) *SeccompProfileAdapter { + return &SeccompProfileAdapter{ + profile: profile, + } +} + +func (s *SeccompProfileAdapter) GetAnnotations() map[string]string { + // Read-only: must not mutate the wrapped object on read (see + // ApplicationProfileAdapter.GetAnnotations). + return s.profile.Annotations +} + +func (s *SeccompProfileAdapter) SetAnnotations(annotations map[string]string) { + s.profile.Annotations = annotations +} + +func (s *SeccompProfileAdapter) GetUID() string { + return string(s.profile.UID) +} + +func (s *SeccompProfileAdapter) GetNamespace() string { + return s.profile.Namespace +} + +func (s *SeccompProfileAdapter) GetName() string { + return s.profile.Name +} + +func (s *SeccompProfileAdapter) GetContent() interface{} { + apiVersion := s.profile.APIVersion + if apiVersion == "" { + apiVersion = "spdx.softwarecomposition.kubescape.io/v1beta1" + } + kind := s.profile.Kind + if kind == "" { + kind = "SeccompProfile" + } + return map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "name": s.profile.Name, + "namespace": s.profile.Namespace, + "labels": s.profile.Labels, + }, + "spec": s.profile.Spec, + } +} + +func (s *SeccompProfileAdapter) GetUpdatedObject() interface{} { + return s.profile +} + +// ExpectedResourceType identifies the resource handled by this adapter, independently of live TypeMeta. +func (*SeccompProfileAdapter) ExpectedResourceType() (apiVersion, kind string) { + return "spdx.softwarecomposition.kubescape.io/v1beta1", "SeccompProfile" +} diff --git a/pkg/signature/sign.go b/pkg/signature/sign.go new file mode 100644 index 0000000000..7cb13175ae --- /dev/null +++ b/pkg/signature/sign.go @@ -0,0 +1,154 @@ +//go:build experimental_signing + +package signature + +import ( + "encoding/json" + "fmt" + "maps" + + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + apivalidation "k8s.io/apimachinery/pkg/api/validation" +) + +func SignObject(obj SignableObject, opts ...SignOption) error { + if obj == nil { + return fmt.Errorf("object is nil") + } + options := &SignOptions{ + UseKeyless: true, + } + + for _, opt := range opts { + opt(options) + } + + content := obj.GetContent() + + // With EmbedContent the exact signed bytes ride along in an annotation, so + // the signature stays verifiable regardless of server-side normalisation. + var contentBytes []byte + if options.EmbedContent { + var err error + contentBytes, err = json.Marshal(content) + if err != nil { + return fmt.Errorf("failed to marshal content for embedding: %w", err) + } + // Match the verifier's decompressed limit before constructing a signer + // or changing annotations, so signing cannot produce an unusable artifact. + if len(contentBytes) > maxEmbeddedContentBytes { + return fmt.Errorf("embedded content exceeds %d bytes", maxEmbeddedContentBytes) + } + } + + var adapter *CosignAdapter + var err error + + if options.PrivateKey != nil { + adapter, err = NewCosignAdapterWithPrivateKey(false, options.PrivateKey) + } else { + adapter, err = NewCosignAdapter(options.UseKeyless) + } + + if err != nil { + return fmt.Errorf("failed to create cosign adapter: %w", err) + } + + hash, err := adapter.GetContentHash(content) + if err != nil { + return fmt.Errorf("failed to compute content hash: %w", err) + } + + logger.L().Debug("Signing object", + helpers.String("namespace", obj.GetNamespace()), + helpers.String("name", obj.GetName()), + helpers.String("contentHash", hash)) + + sig, err := adapter.SignData([]byte(hash)) + if err != nil { + return fmt.Errorf("failed to sign object: %w", err) + } + + annotations, err := adapter.EncodeSignatureToAnnotations(sig) + if err != nil { + return fmt.Errorf("failed to encode signature to annotations: %w", err) + } + + if options.EmbedContent { + enc, err := encodeEmbeddedContent(contentBytes) + if err != nil { + return fmt.Errorf("failed to encode embedded content: %w", err) + } + annotations[AnnotationContent] = enc + } + + if err := publishSignedAnnotations(obj, annotations); err != nil { + return err + } + + logger.L().Info("Successfully signed object", + helpers.String("namespace", obj.GetNamespace()), + helpers.String("name", obj.GetName()), + helpers.String("identity", sig.Identity), + helpers.String("issuer", sig.Issuer)) + + return nil +} + +// Publish only after the complete annotation set passes resource size limits. +func publishSignedAnnotations(obj SignableObject, signed map[string]string) error { + annotations := maps.Clone(obj.GetAnnotations()) + if annotations == nil { + annotations = make(map[string]string) + } + // Re-signing without embedding must remove a previously embedded payload. + delete(annotations, AnnotationContent) + maps.Copy(annotations, signed) + if _, resource := obj.(ResourceSignableObject); resource { + if err := apivalidation.ValidateAnnotationsSize(annotations); err != nil { + return fmt.Errorf("signed object exceeds Kubernetes annotation limit: %w; reduce embedded content or existing annotations", err) + } + } + obj.SetAnnotations(annotations) + return nil +} + +func SignObjectDisableKeyless(obj SignableObject) error { + return SignObject(obj, WithKeyless(false)) +} + +func SignObjectKeyless(obj SignableObject) error { + return SignObject(obj, WithKeyless(true)) +} + +func GetObjectSignature(obj SignableObject) (*Signature, error) { + if obj == nil { + return nil, fmt.Errorf("GetObjectSignature: nil object") + } + annotations := obj.GetAnnotations() + if annotations == nil { + return nil, fmt.Errorf("object has no annotations") + } + + adapter := &CosignAdapter{} + sig, err := adapter.DecodeSignatureFromAnnotations(annotations) + if err != nil { + return nil, fmt.Errorf("failed to decode signature from annotations: %w", err) + } + + return sig, nil +} + +func IsSigned(obj SignableObject) bool { + if obj == nil { + return false + } + annotations := obj.GetAnnotations() + if annotations == nil { + return false + } + + _, ok := annotations[AnnotationSignature] + return ok +} diff --git a/pkg/signature/sign_annotation_limit_test.go b/pkg/signature/sign_annotation_limit_test.go new file mode 100644 index 0000000000..1cb6cc8ba5 --- /dev/null +++ b/pkg/signature/sign_annotation_limit_test.go @@ -0,0 +1,72 @@ +//go:build experimental_signing + +package signature + +import ( + "maps" + "strings" + "testing" + + "github.com/stretchr/testify/require" + apivalidation "k8s.io/apimachinery/pkg/api/validation" +) + +type resourceAnnotationFixture struct{ *MockSignableObject } + +func (*resourceAnnotationFixture) ExpectedResourceType() (string, string) { return "v1", "Example" } + +func TestSignResourceAnnotationsRejectOverflowAtomically(t *testing.T) { + for _, embed := range []bool{false, true} { + t.Run(map[bool]string{false: "signature", true: "embedded"}[embed], func(t *testing.T) { + obj := &resourceAnnotationFixture{embeddedObjectAtSize(t, 100)} + obj.SetAnnotations(map[string]string{"existing": strings.Repeat("x", apivalidation.TotalAnnotationSizeLimitB-len("existing")), AnnotationSignature: "old-signature", AnnotationContent: "old-content"}) + before := maps.Clone(obj.GetAnnotations()) + err := SignObject(obj, WithKeyless(false), WithEmbedContent(embed)) + require.ErrorContains(t, err, "Kubernetes annotation limit") + require.Equal(t, before, obj.GetAnnotations()) + }) + } +} + +func TestPublishSignedAnnotationsResourceBoundary(t *testing.T) { + // Generate authentic signature/certificate/content annotations once; their + // variable-length encoding must count toward the resource's total budget. + signedObj := embeddedObjectAtSize(t, 100) + require.NoError(t, SignObject(signedObj, WithKeyless(false), WithEmbedContent(true))) + signed := signedObj.GetAnnotations() + signedSize := 0 + for k, v := range signed { + signedSize += len(k) + len(v) + } + for _, extra := range []int{0, 1} { + t.Run(map[int]string{0: "exact limit", 1: "one byte over"}[extra], func(t *testing.T) { + obj := &resourceAnnotationFixture{embeddedObjectAtSize(t, 100)} + obj.SetAnnotations(map[string]string{"existing": strings.Repeat("x", apivalidation.TotalAnnotationSizeLimitB-signedSize-len("existing")+extra)}) + before := maps.Clone(obj.GetAnnotations()) + alias := obj.GetAnnotations() + err := publishSignedAnnotations(obj, signed) + require.Equal(t, before, alias, "publication must never mutate the original annotation map") + if extra == 0 { + require.NoError(t, err) + require.NoError(t, apivalidation.ValidateAnnotationsSize(obj.GetAnnotations())) + require.Equal(t, signed[AnnotationCertificate], obj.GetAnnotations()[AnnotationCertificate]) + } else { + require.ErrorContains(t, err, "262145") + require.Equal(t, before, obj.GetAnnotations()) + } + }) + } + t.Run("nil remains nil on rejection", func(t *testing.T) { + obj := &resourceAnnotationFixture{embeddedObjectAtSize(t, 100)} + obj.SetAnnotations(nil) + require.Error(t, publishSignedAnnotations(obj, map[string]string{AnnotationContent: strings.Repeat("x", apivalidation.TotalAnnotationSizeLimitB)})) + require.Nil(t, obj.GetAnnotations()) + }) +} + +func TestSignNonResourceHasNoKubernetesAnnotationLimit(t *testing.T) { + obj := embeddedObjectAtSize(t, 100) + obj.SetAnnotations(map[string]string{"existing": strings.Repeat("x", apivalidation.TotalAnnotationSizeLimitB)}) + require.NoError(t, SignObject(obj, WithKeyless(false), WithEmbedContent(true))) + require.NoError(t, VerifyObjectAllowUntrusted(obj)) +} diff --git a/pkg/signature/sign_embedding_limit_test.go b/pkg/signature/sign_embedding_limit_test.go new file mode 100644 index 0000000000..85021d2e75 --- /dev/null +++ b/pkg/signature/sign_embedding_limit_test.go @@ -0,0 +1,55 @@ +//go:build experimental_signing + +package signature + +import ( + "encoding/json" + "maps" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func embeddedObjectAtSize(t *testing.T, size int) *MockSignableObject { + t.Helper() + content := map[string]interface{}{ + "metadata": map[string]string{"name": "boundary", "namespace": "ns"}, + "spec": "", + } + empty, err := json.Marshal(content) + require.NoError(t, err) + content["spec"] = strings.Repeat("x", size-len(empty)) + encoded, err := json.Marshal(content) + require.NoError(t, err) + require.Len(t, encoded, size) + return NewMockSignableObject("uid", "ns", "boundary", content) +} + +func TestSignEmbeddedContentSizeBoundary(t *testing.T) { + t.Run("exact limit round trip", func(t *testing.T) { + obj := embeddedObjectAtSize(t, maxEmbeddedContentBytes) + require.NoError(t, SignObject(obj, WithKeyless(false), WithEmbedContent(true))) + embedded, present, err := EmbeddedContent(obj) + require.NoError(t, err) + require.True(t, present) + require.Len(t, embedded, maxEmbeddedContentBytes) + require.NoError(t, VerifyObjectAllowUntrusted(obj)) + }) + for _, existing := range []bool{false, true} { + name := "over limit unsigned" + if existing { + name = "over limit preserves annotations" + } + t.Run(name, func(t *testing.T) { + obj := embeddedObjectAtSize(t, maxEmbeddedContentBytes+1) + if existing { + obj.SetAnnotations(map[string]string{AnnotationSignature: "old-signature", AnnotationContent: "old-content", "unrelated": "retained"}) + } + before := maps.Clone(obj.GetAnnotations()) + err := SignObject(obj, WithKeyless(false), WithEmbedContent(true)) + require.ErrorContains(t, err, "embedded content exceeds") + require.Equal(t, before, obj.GetAnnotations(), "rejection must leave all annotations untouched") + }) + } +} diff --git a/pkg/signature/sign_test.go b/pkg/signature/sign_test.go new file mode 100644 index 0000000000..8aa5a4705e --- /dev/null +++ b/pkg/signature/sign_test.go @@ -0,0 +1,257 @@ +//go:build experimental_signing + +package signature + +import ( + "os" + "testing" +) + +type MockSignableObject struct { + annotations map[string]string + uid string + namespace string + name string + content interface{} +} + +func NewMockSignableObject(uid, namespace, name string, content interface{}) *MockSignableObject { + return &MockSignableObject{ + annotations: make(map[string]string), + uid: uid, + namespace: namespace, + name: name, + content: content, + } +} + +func (m *MockSignableObject) GetAnnotations() map[string]string { + return m.annotations +} + +func (m *MockSignableObject) SetAnnotations(annotations map[string]string) { + m.annotations = annotations +} + +func (m *MockSignableObject) GetUID() string { + return m.uid +} + +func (m *MockSignableObject) GetNamespace() string { + return m.namespace +} + +func (m *MockSignableObject) GetName() string { + return m.name +} + +func (m *MockSignableObject) GetContent() interface{} { + return m.content +} + +func (m *MockSignableObject) GetUpdatedObject() interface{} { + return m.content +} + +func TestSignObjectKeyless(t *testing.T) { + if os.Getenv("ENABLE_KEYLESS_TESTS") == "" { + t.Skip("Skipping TestSignObjectKeyless. Set ENABLE_KEYLESS_TESTS to run.") + } + profileContent := map[string]interface{}{ + "type": "test-profile", + "data": "test-data", + } + + profile := NewMockSignableObject("test-uid", "test-ns", "test-profile", profileContent) + + err := SignObjectKeyless(profile) + if err != nil { + t.Fatalf("SignObjectKeyless failed: %v", err) + } + + if !IsSigned(profile) { + t.Error("Profile should be signed") + } + + sig, err := GetObjectSignature(profile) + if err != nil { + t.Fatalf("GetObjectSignature failed: %v", err) + } + + if len(sig.Signature) == 0 { + t.Error("Signature should not be empty") + } + + if len(sig.Certificate) == 0 { + t.Error("Certificate should not be empty") + } + + if sig.Issuer == "" { + t.Error("Issuer should not be empty for keyless signing") + } + + if sig.Identity == "" { + t.Error("Identity should not be empty for keyless signing") + } +} + +func TestSignObjectDisableKeyless(t *testing.T) { + profileContent := map[string]interface{}{ + "type": "test-profile", + "data": "test-data", + } + + profile := NewMockSignableObject("test-uid", "test-ns", "test-profile-key", profileContent) + + err := SignObjectDisableKeyless(profile) + if err != nil { + t.Fatalf("SignObjectDisableKeyless failed: %v", err) + } + + if !IsSigned(profile) { + t.Error("Profile should be signed") + } + + sig, err := GetObjectSignature(profile) + if err != nil { + t.Fatalf("GetObjectSignature failed: %v", err) + } + + if len(sig.Signature) == 0 { + t.Error("Signature should not be empty") + } + + if sig.Issuer != "local" { + t.Errorf("Expected issuer 'local', got '%s'", sig.Issuer) + } + + if sig.Identity != "local-key" { + t.Errorf("Expected identity 'local-key', got '%s'", sig.Identity) + } +} + +func TestIsSigned(t *testing.T) { + tests := []struct { + name string + profile *MockSignableObject + expected bool + }{ + { + name: "Unsigned profile", + profile: NewMockSignableObject("uid", "ns", "name", map[string]string{}), + expected: false, + }, + { + name: "Profile with empty annotations", + profile: &MockSignableObject{annotations: make(map[string]string)}, + expected: false, + }, + { + name: "Profile with signature annotation", + profile: func() *MockSignableObject { + p := NewMockSignableObject("uid", "ns", "name", map[string]string{}) + p.SetAnnotations(map[string]string{ + AnnotationSignature: "test-sig", + }) + return p + }(), + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsSigned(tt.profile) + if result != tt.expected { + t.Errorf("IsSigned() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestGetObjectSignature(t *testing.T) { + tests := []struct { + name string + profile *MockSignableObject + wantErr bool + setupSign bool + setupAnnotations func(*MockSignableObject) + }{ + { + name: "Nil annotations", + profile: &MockSignableObject{uid: "uid", namespace: "ns", name: "name", content: map[string]string{}, annotations: nil}, + wantErr: true, + setupSign: false, + }, + { + name: "Missing signature annotation", + profile: NewMockSignableObject("uid", "ns", "name", map[string]string{}), + wantErr: true, + setupAnnotations: func(p *MockSignableObject) { + p.SetAnnotations(map[string]string{ + AnnotationIssuer: "test-issuer", + }) + }, + }, + { + name: "Complete signature", + profile: NewMockSignableObject("uid", "ns", "name", map[string]string{}), + wantErr: false, + setupSign: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setupSign { + if os.Getenv("ENABLE_KEYLESS_TESTS") == "" { + t.Skip("Skipping subtest with SignObjectKeyless. Set ENABLE_KEYLESS_TESTS to run.") + } + SignObjectKeyless(tt.profile) + } else if tt.setupAnnotations != nil { + tt.setupAnnotations(tt.profile) + } + + sig, err := GetObjectSignature(tt.profile) + + if tt.wantErr { + if err == nil { + t.Error("Expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("GetObjectSignature failed: %v", err) + } + + if sig == nil { + t.Fatal("Expected signature, got nil") + } + }) + } +} + +func TestResignWithoutEmbeddingClearsPreviousContent(t *testing.T) { + content := map[string]interface{}{ + "metadata": map[string]interface{}{"name": "resigned"}, + "spec": map[string]interface{}{"version": 1}, + } + obj := NewMockSignableObject("uid", "", "resigned", content) + if err := SignObject(obj, WithKeyless(false), WithEmbedContent(true)); err != nil { + t.Fatal(err) + } + if err := VerifyObjectAllowUntrusted(obj); err != nil { + t.Fatal(err) + } + content["spec"] = map[string]interface{}{"version": 2} + if err := SignObject(obj, WithKeyless(false), WithEmbedContent(false)); err != nil { + t.Fatal(err) + } + if _, present := obj.annotations[AnnotationContent]; present { + t.Error("re-signing without embedding must remove stale content") + } + if err := VerifyObjectAllowUntrusted(obj); err != nil { + t.Fatalf("new signature must verify against edited content: %v", err) + } +} diff --git a/pkg/signature/signer.go b/pkg/signature/signer.go new file mode 100644 index 0000000000..fd44f9e26c --- /dev/null +++ b/pkg/signature/signer.go @@ -0,0 +1,22 @@ +//go:build experimental_signing + +package signature + +type CosignSigner struct { + adapter *CosignAdapter +} + +func NewCosignSigner(useKeyless bool) (*CosignSigner, error) { + adapter, err := NewCosignAdapter(useKeyless) + if err != nil { + return nil, err + } + + return &CosignSigner{ + adapter: adapter, + }, nil +} + +func (s *CosignSigner) Sign(data []byte) (*Signature, error) { + return s.adapter.SignData(data) +} diff --git a/pkg/signature/verifier.go b/pkg/signature/verifier.go new file mode 100644 index 0000000000..8ee561a56c --- /dev/null +++ b/pkg/signature/verifier.go @@ -0,0 +1,40 @@ +//go:build experimental_signing + +package signature + +import "fmt" + +type CosignVerifier struct { + adapter *CosignAdapter +} + +func NewCosignVerifier(useKeyless bool) (*CosignVerifier, error) { + adapter, err := NewCosignAdapter(useKeyless) + if err != nil { + return nil, err + } + + return &CosignVerifier{ + adapter: adapter, + }, nil +} + +func (v *CosignVerifier) Verify(data []byte, sig *Signature) error { + if v == nil || v.adapter == nil { + return fmt.Errorf("verifier not initialized") + } + if sig == nil { + return fmt.Errorf("signature is nil") + } + return v.adapter.VerifyData(data, sig, false) +} + +func (v *CosignVerifier) VerifyAllowUntrusted(data []byte, sig *Signature) error { + if v == nil || v.adapter == nil { + return fmt.Errorf("verifier not initialized") + } + if sig == nil { + return fmt.Errorf("signature is nil") + } + return v.adapter.VerifyData(data, sig, true) +} diff --git a/pkg/signature/verify.go b/pkg/signature/verify.go new file mode 100644 index 0000000000..aae306991d --- /dev/null +++ b/pkg/signature/verify.go @@ -0,0 +1,121 @@ +//go:build experimental_signing + +package signature + +import ( + "fmt" + + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + "github.com/kubescape/storage/pkg/utils" +) + +func VerifyObject(obj SignableObject, opts ...VerifyOption) error { + if obj == nil { + return fmt.Errorf("object is nil") + } + options := &VerifyOptions{ + AllowUntrusted: false, + } + + for _, opt := range opts { + opt(options) + } + + annotations := obj.GetAnnotations() + if annotations == nil { + return fmt.Errorf("%w (missing %s annotation)", ErrObjectNotSigned, AnnotationSignature) + } + + if _, ok := annotations[AnnotationSignature]; !ok { + return fmt.Errorf("%w (missing %s annotation)", ErrObjectNotSigned, AnnotationSignature) + } + + // useKeyless=true is fine for verification since we use the certificate + // stored in the object annotations, regardless of how the object was signed + adapter, err := NewCosignAdapter(true) + if err != nil { + return fmt.Errorf("failed to create cosign adapter: %w", err) + } + + sig, err := adapter.DecodeSignatureFromAnnotations(annotations) + if err != nil { + // Annotation bytes are supplied by the object, so decoding failures + // are invalid signature material, not verification machinery failures. + return fmt.Errorf("%w: failed to decode signature from annotations: %w", ErrSignatureMismatch, err) + } + + // When the canonical signed content is embedded (vendor-shipped artifacts), + // verify against THOSE bytes: the server normalises specs on save, so the + // object's own content may legitimately differ from what was signed. The + // embedded bytes are then the verified source of truth for consumers. + var hash string + if embedded, present, embErr := EmbeddedContent(obj); present { + // A malformed embedded blob on an object that CLAIMS to be signed is a + // tamper signal, not an operational hiccup — wrap with + // ErrSignatureMismatch so the caller raises R1016 and fails closed + // rather than swallowing it as "operational" and (in permissive mode) + // loading the object anyway. + if embErr != nil { + return fmt.Errorf("%w: malformed embedded content: %v", ErrSignatureMismatch, embErr) + } + // Bind the embedded content to THIS carrier: the signed bytes must + // commit to the same name+namespace, else a validly-signed blob could be + // stapled onto a different object (whose live spec would then be used). + if bindErr := checkEmbeddedBinding(obj, embedded); bindErr != nil { + return fmt.Errorf("%w: %v", ErrSignatureMismatch, bindErr) + } + hash, embErr = utils.CanonicalHash(embedded) + if embErr != nil { + return fmt.Errorf("%w: cannot hash embedded content: %v", ErrSignatureMismatch, embErr) + } + } else { + content := obj.GetContent() + var err error + hash, err = adapter.GetContentHash(content) + if err != nil { + return fmt.Errorf("failed to compute content hash: %w", err) + } + } + + verifier, err := NewCosignVerifier(true) + if err != nil { + return fmt.Errorf("failed to create verifier: %w", err) + } + + var verifyErr error + if options.AllowUntrusted { + verifyErr = verifier.VerifyAllowUntrusted([]byte(hash), sig) + } else { + verifyErr = verifier.Verify([]byte(hash), sig) + } + + if verifyErr != nil { + logger.L().Warning("Object signature verification failed", + helpers.String("namespace", obj.GetNamespace()), + helpers.String("name", obj.GetName()), + helpers.String("error", verifyErr.Error())) + + // Wrap with the ErrSignatureMismatch sentinel so callers can + // distinguish actual tamper from operational errors (hash + // computation, verifier construction) returned above. + // errors.Is(err, ErrSignatureMismatch) is the canonical check. + return fmt.Errorf("%w: %w", ErrSignatureMismatch, verifyErr) + } + + logger.L().Debug("Successfully verified object signature", + helpers.String("namespace", obj.GetNamespace()), + helpers.String("name", obj.GetName()), + helpers.String("identity", sig.Identity), + helpers.String("issuer", sig.Issuer)) + + return nil +} + +func VerifyObjectStrict(obj SignableObject) error { + return VerifyObject(obj, WithUntrusted(false)) +} + +func VerifyObjectAllowUntrusted(obj SignableObject) error { + return VerifyObject(obj, WithUntrusted(true)) +} diff --git a/pkg/signature/verify_test.go b/pkg/signature/verify_test.go new file mode 100644 index 0000000000..aeee84c36f --- /dev/null +++ b/pkg/signature/verify_test.go @@ -0,0 +1,417 @@ +//go:build experimental_signing + +package signature + +import ( + "io" + "os" + "strings" + "testing" + + logger "github.com/kubescape/go-logger" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestVerifyObjectStrict(t *testing.T) { + if os.Getenv("ENABLE_KEYLESS_TESTS") == "" { + t.Skip("Skipping TestVerifyObjectStrict. Set ENABLE_KEYLESS_TESTS to run.") + } + profileContent := map[string]interface{}{ + "type": "test-profile", + "data": "test-data", + "value": 123, + } + + profile := NewMockSignableObject("test-uid", "test-ns", "test-profile-verify", profileContent) + + err := SignObjectKeyless(profile) + if err != nil { + t.Fatalf("SignObjectKeyless failed: %v", err) + } + + err = VerifyObjectStrict(profile) + if err != nil { + t.Fatalf("VerifyObjectStrict failed: %v", err) + } +} + +func TestVerifyObjectAllowUntrusted(t *testing.T) { + profileContent := map[string]interface{}{ + "type": "test-profile", + "data": "test-data", + "value": 456, + } + + profile := NewMockSignableObject("test-uid", "test-ns", "test-profile-verify-2", profileContent) + + err := SignObjectDisableKeyless(profile) + if err != nil { + t.Fatalf("SignObjectDisableKeyless failed: %v", err) + } + + err = VerifyObjectAllowUntrusted(profile) + if err != nil { + t.Fatalf("VerifyObjectAllowUntrusted failed: %v", err) + } +} + +func TestVerifyObjectTampered(t *testing.T) { + if os.Getenv("ENABLE_KEYLESS_TESTS") == "" { + t.Skip("Skipping TestVerifyObjectTampered. Set ENABLE_KEYLESS_TESTS to run.") + } + originalContent := map[string]interface{}{ + "type": "test-profile", + "data": "test-data", + "value": 789, + "confident": "secret", + } + + profile := NewMockSignableObject("test-uid", "test-ns", "test-profile-tamper", originalContent) + + err := SignObjectKeyless(profile) + if err != nil { + t.Fatalf("SignObjectKeyless failed: %v", err) + } + + tamperedContent := map[string]interface{}{ + "type": "test-profile", + "data": "test-data", + "value": 999, + "confident": "mod", + } + profile.content = tamperedContent + + err = VerifyObjectStrict(profile) + if err == nil { + t.Error("Expected verification failure for tampered profile, got success") + } +} + +func TestVerifyObjectNoAnnotations(t *testing.T) { + profileContent := map[string]interface{}{ + "type": "test-profile", + "data": "test-data", + } + + profile := NewMockSignableObject("test-uid", "test-ns", "test-profile-no-sig", profileContent) + + err := VerifyObjectStrict(profile) + if err == nil { + t.Error("Expected error for profile without annotations, got nil") + } +} + +func TestVerifyObjectMissingSignature(t *testing.T) { + profileContent := map[string]interface{}{ + "type": "test-profile", + "data": "test-data", + } + + profile := NewMockSignableObject("test-uid", "test-ns", "test-profile-missing-sig", profileContent) + profile.SetAnnotations(map[string]string{ + AnnotationIssuer: "test-issuer", + AnnotationIdentity: "test-identity", + }) + + err := VerifyObjectStrict(profile) + if err == nil { + t.Error("Expected error for profile without signature annotation, got nil") + } +} + +func TestSignAndVerifyRoundTrip(t *testing.T) { + if os.Getenv("ENABLE_KEYLESS_TESTS") == "" { + t.Skip("Skipping TestSignAndVerifyRoundTrip. Set ENABLE_KEYLESS_TESTS to run.") + } + profileContent := map[string]interface{}{ + "type": "roundtrip-profile", + "containers": []string{"nginx", "redis"}, + "capabilities": []string{"NET_BIND_SERVICE"}, + "networkPolicy": "allow", + } + + profile := NewMockSignableObject("roundtrip-uid", "roundtrip-ns", "roundtrip-profile", profileContent) + + err := SignObjectKeyless(profile) + if err != nil { + t.Fatalf("SignObjectKeyless failed: %v", err) + } + + if !IsSigned(profile) { + t.Fatal("Profile should be signed after signing") + } + + sig, err := GetObjectSignature(profile) + if err != nil { + t.Fatalf("GetObjectSignature failed: %v", err) + } + + if len(sig.Signature) == 0 { + t.Error("Signature should not be empty") + } + + err = VerifyObjectStrict(profile) + if err != nil { + t.Fatalf("VerifyObjectStrict failed after signing: %v", err) + } +} + +func TestSignAndVerifyDifferentKeys(t *testing.T) { + if os.Getenv("ENABLE_KEYLESS_TESTS") == "" { + t.Skip("Skipping TestSignAndVerifyDifferentKeys. Set ENABLE_KEYLESS_TESTS to run.") + } + profileContent := map[string]interface{}{ + "type": "multi-key-test", + "data": "data", + } + + profile1 := NewMockSignableObject("uid1", "ns", "profile1", profileContent) + profile2 := NewMockSignableObject("uid2", "ns", "profile2", profileContent) + + err := SignObjectDisableKeyless(profile1) + if err != nil { + t.Fatalf("SignObjectDisableKeyless failed for profile1: %v", err) + } + + err = SignObjectKeyless(profile2) + if err != nil { + t.Fatalf("SignObjectKeyless failed for profile2: %v", err) + } + + sig1, err := GetObjectSignature(profile1) + if err != nil { + t.Fatalf("GetObjectSignature failed for profile1: %v", err) + } + + sig2, err := GetObjectSignature(profile2) + if err != nil { + t.Fatalf("GetObjectSignature failed for profile2: %v", err) + } + + if sig1.Issuer != "local" { + t.Errorf("Expected key-based signing issuer 'local', got '%s'", sig1.Issuer) + } + + if sig1.Identity != "local-key" { + t.Errorf("Expected key-based signing identity 'local-key', got '%s'", sig1.Identity) + } + + if sig2.Issuer == "" { + t.Errorf("Expected keyless signing to have issuer, got empty") + } + + if sig2.Identity == "" { + t.Errorf("Expected keyless signing to have identity, got empty") + } +} + +// captureLogOutput redirects the global logger to a pipe, runs fn, and returns +// the captured log text. The logger is restored to its previous writer afterward. +func captureLogOutput(t *testing.T, fn func()) string { + t.Helper() + + // Ensure the global logger is initialized as pretty (supports SetWriter). + logger.InitLogger("pretty") + + oldWriter := logger.L().GetWriter() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + logger.L().SetWriter(w) + + fn() + + w.Close() + var buf strings.Builder + io.Copy(&buf, r) + r.Close() + + // Restore original writer. + logger.L().SetWriter(oldWriter) + + return buf.String() +} + +// TestTamperedAPLogsWarning signs an ApplicationProfile, tampers with it, +// verifies it, and asserts the warning log contains the expected fields: +// namespace, name, and "Object signature verification failed". +func TestTamperedAPLogsWarning(t *testing.T) { + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tamper-warn-ap", + Namespace: "tamper-ns", + }, + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/usr/bin/curl"}}, + Syscalls: []string{"read", "write"}, + }, + } + + adapter := profiles.NewContainerProfileAdapter(cp) + if err := SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("sign failed: %v", err) + } + + // Tamper: add an exec entry. + cp.Spec.Execs = append(cp.Spec.Execs, v1beta1.ExecCalls{Path: "/usr/bin/nslookup"}) + + tamperedAdapter := profiles.NewContainerProfileAdapter(cp) + + logOutput := captureLogOutput(t, func() { + err := VerifyObjectAllowUntrusted(tamperedAdapter) + if err == nil { + t.Error("expected verification to fail for tampered AP") + } + }) + + // Assert warning log contains expected fields. + if !strings.Contains(logOutput, "Object signature verification failed") { + t.Errorf("expected warning message in log output, got:\n%s", logOutput) + } + if !strings.Contains(logOutput, "tamper-ns") { + t.Errorf("expected namespace 'tamper-ns' in log output, got:\n%s", logOutput) + } + if !strings.Contains(logOutput, "tamper-warn-ap") { + t.Errorf("expected name 'tamper-warn-ap' in log output, got:\n%s", logOutput) + } + if !strings.Contains(logOutput, "invalid signature") { + t.Errorf("expected 'invalid signature' in log output, got:\n%s", logOutput) + } +} + +// TestTamperedNNLogsWarning signs a NetworkNeighborhood, tampers with it, +// verifies it, and asserts the warning log contains the expected fields. +func TestTamperedNNLogsWarning(t *testing.T) { + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tamper-warn-nn", + Namespace: "tamper-ns", + }, + Spec: v1beta1.ContainerProfileSpec{ + Egress: []v1beta1.NetworkNeighbor{ + { + Identifier: "legit", + DNSNames: []string{"example.com."}, + IPAddress: "93.184.216.34", + }, + }, + }, + } + + adapter := profiles.NewContainerProfileAdapter(cp) + if err := SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("sign failed: %v", err) + } + + // Tamper: add an egress entry. + cp.Spec.Egress = append(cp.Spec.Egress, + v1beta1.NetworkNeighbor{ + Identifier: "evil", + DNSNames: []string{"evil-c2.io."}, + IPAddress: "6.6.6.6", + }) + + tamperedAdapter := profiles.NewContainerProfileAdapter(cp) + + logOutput := captureLogOutput(t, func() { + err := VerifyObjectAllowUntrusted(tamperedAdapter) + if err == nil { + t.Error("expected verification to fail for tampered NN") + } + }) + + if !strings.Contains(logOutput, "Object signature verification failed") { + t.Errorf("expected warning message in log output, got:\n%s", logOutput) + } + if !strings.Contains(logOutput, "tamper-ns") { + t.Errorf("expected namespace 'tamper-ns' in log output, got:\n%s", logOutput) + } + if !strings.Contains(logOutput, "tamper-warn-nn") { + t.Errorf("expected name 'tamper-warn-nn' in log output, got:\n%s", logOutput) + } + if !strings.Contains(logOutput, "invalid signature") { + t.Errorf("expected 'invalid signature' in log output, got:\n%s", logOutput) + } +} + +// TestSuccessfulVerifyLogsDebug preserves diagnostic details without per-fragment Info noise. +func TestSuccessfulVerifyLogsDebug(t *testing.T) { + t.Cleanup(func() { logger.InitLogger("pretty") }) + t.Setenv(logger.EnvLoggerLevel, "debug") + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-ap", + Namespace: "valid-ns", + }, + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/usr/sbin/nginx"}}, + Syscalls: []string{"read", "write", "openat"}, + }, + } + + adapter := profiles.NewContainerProfileAdapter(cp) + if err := SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("sign failed: %v", err) + } + + logOutput := captureLogOutput(t, func() { + if err := VerifyObjectAllowUntrusted(adapter); err != nil { + t.Fatalf("expected verification to succeed: %v", err) + } + }) + + if !strings.Contains(logOutput, "Successfully verified object signature") { + t.Errorf("expected debug message in log output, got:\n%s", logOutput) + } + if !strings.Contains(logOutput, "valid-ns") { + t.Errorf("expected namespace 'valid-ns' in log output, got:\n%s", logOutput) + } + if !strings.Contains(logOutput, "valid-ap") { + t.Errorf("expected name 'valid-ap' in log output, got:\n%s", logOutput) + } + if !strings.Contains(logOutput, "local-key") { + t.Errorf("expected identity 'local-key' in log output, got:\n%s", logOutput) + } +} + +// TestSignLogsInfo verifies that signing an object produces the +// "Successfully signed object" info log with identity and issuer. +func TestSignLogsInfo(t *testing.T) { + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sign-log-ap", + Namespace: "sign-ns", + }, + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/app/main"}}, + Syscalls: []string{"read"}, + }, + } + + adapter := profiles.NewContainerProfileAdapter(cp) + + logOutput := captureLogOutput(t, func() { + if err := SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("sign failed: %v", err) + } + }) + + if !strings.Contains(logOutput, "Successfully signed object") { + t.Errorf("expected sign info message in log output, got:\n%s", logOutput) + } + if !strings.Contains(logOutput, "sign-ns") { + t.Errorf("expected namespace 'sign-ns' in log output, got:\n%s", logOutput) + } + if !strings.Contains(logOutput, "sign-log-ap") { + t.Errorf("expected name 'sign-log-ap' in log output, got:\n%s", logOutput) + } + if !strings.Contains(logOutput, "local-key") { + t.Errorf("expected identity 'local-key' in log output, got:\n%s", logOutput) + } + if !strings.Contains(logOutput, "local") { + t.Errorf("expected issuer 'local' in log output, got:\n%s", logOutput) + } +} diff --git a/tests/resources/signed/ct-root.pub b/tests/resources/signed/ct-root.pub new file mode 100644 index 0000000000..1b4447ab30 --- /dev/null +++ b/tests/resources/signed/ct-root.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMO5EpeQDbR706oFh94T8K+yNK0pi +JEUpsKUD3t2vD4MEtZZdrptU1UONFpiWxy2nwGCzlpZmIXtRrXDBDVaNfQ== +-----END PUBLIC KEY----- diff --git a/tests/resources/signed/fragments/bundle37-admission-signed.yaml b/tests/resources/signed/fragments/bundle37-admission-signed.yaml new file mode 100644 index 0000000000..48de4d038e --- /dev/null +++ b/tests/resources/signed/fragments/bundle37-admission-signed.yaml @@ -0,0 +1,46 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJXekNDQVFHZ0F3SUJBZ0lRQ0FWMTF1ZGNya1Y0RHJaQklXRGlHakFLQmdncWhrak9QUVFEQWpBVU1SSXcKRUFZRFZRUURFd2xzYjJOaGJDMXJaWGt3SGhjTk1qWXdPREV4TVRjek9UQXpXaGNOTWpjd09ERXhNVGN6T1RBegpXakFVTVJJd0VBWURWUVFERXdsc2IyTmhiQzFyWlhrd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DCkFBVGNZaERBb0g1SGdWdEpJaHNDL1RVWW1tc3pNdW1STkZCTnJOZ05xRGhxSVNuanVYeDhKcU5FSFF6bmRjd1UKbDM0c2ZEWGxmTnd1ZEd4WUtzYTRWZkZjb3pVd016QU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WURWUjBsQkF3dwpDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQUtCZ2dxaGtqT1BRUURBZ05JQURCRkFpRUFxL08zCndZditaZGh2RTZIQnRyREtrck9HZUQ2ZU12WkJJdkM2bEVsRkdZQUNJRU4yV3hCY00ra2xZU3hIUDhuV2FIYVQKVi91ODJlVHArajhtZEZJcFZiQUIKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + signature.kubescape.io/content: H4sIAAAAAAAA/3ySQYvcMAyF/4vOninLQpfmtkwvhVIGduml9KDYSlaMIhtb6e4y5L8XTzJJp9De/Cz5RfryzoCJv1MuHBUaKCm87Uvs7BUz+TikWNg46v40tlQ8Jtpz/PDrriXDO3BwYg3QwCGqISvlY44dC4GDgQwDGkJzBsGWpNRT4V7Rxky3hu2oQQgamA/3D+D+1dpl7AdS23nBUqABDAOXy/iTA8XhT5vdTbEk8nUIzP6FjXw1L9DoKOLAY8KWhY23O3ojv4qYSFdR3otHkU2Tr7Su69dNl48F6nAUe/Q2E4ZpckAaUmS19T0P2NOXz7W+iGfsZ5VHoWMU9pfBzpMDDqTGHVM4oMiToT9tRtpnqlx+nLe+XDGJxFcKOy9MauDA3lNFxWqUFQUchLofzIdvOGwcUsw2Wy58nw/H3cf7h0/gIOVo0UeZb2FuhqZWp59VhScS8hZzpTGg+ZevaxwwJWggU+ByHWxa/mJJ6Ok/T2sospJRqam4pm3/VwAudpweQ5ipANShaGFUt5um3wEAAP//5UhAygMDAAA= + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEUCIGFKoKENiRhBmp37z/M/47Xxsosl08FVkSStpvBsd9QwAiEAvDb22KI3SW+IW0CfVxf0d7TKvMHpXTnhbtRaIJzBeLI= + signature.kubescape.io/timestamp: "1786469943" + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: admission + name: bundle37-admission +spec: + architectures: null + capabilities: null + egress: null + endpoints: null + execs: null + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: + - dns: "" + dnsNames: null + identifier: allowed-client + ipAddress: "" + namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: bundle37 + podSelector: + matchLabels: + app: redis-client + ports: + - name: TCP-6379 + port: 6379 + protocol: TCP + type: internal + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: null +status: {} diff --git a/tests/resources/signed/fragments/bundle37-admission.yaml b/tests/resources/signed/fragments/bundle37-admission.yaml new file mode 100644 index 0000000000..cf1294fe11 --- /dev/null +++ b/tests/resources/signed/fragments/bundle37-admission.yaml @@ -0,0 +1,21 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: bundle37-admission + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: admission +spec: + ingress: + - identifier: allowed-client + type: internal + podSelector: + matchLabels: + app: redis-client + namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: bundle37 + ports: + - name: TCP-6379 + port: 6379 + protocol: TCP diff --git a/tests/resources/signed/fragments/bundle37-base-signed.yaml b/tests/resources/signed/fragments/bundle37-base-signed.yaml new file mode 100644 index 0000000000..ca852c6813 --- /dev/null +++ b/tests/resources/signed/fragments/bundle37-base-signed.yaml @@ -0,0 +1,44 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJYRENDQVFHZ0F3SUJBZ0lRUFJTcWloUW1URytsMVlXMFozSjJQakFLQmdncWhrak9QUVFEQWpBVU1SSXcKRUFZRFZRUURFd2xzYjJOaGJDMXJaWGt3SGhjTk1qWXdPVEl3TVRjeU5qQXpXaGNOTWpjd09USXdNVGN5TmpBegpXakFVTVJJd0VBWURWUVFERXdsc2IyTmhiQzFyWlhrd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DCkFBVE9vc2JXT1o2M2I2TVRaMzl4WTNNaDFiWXlYSTRvT3NtanlES0hXS1N5a2ptaDdmR01FZnZDYmp5Qm9DNUoKc25DeFV2R2pjSUhSa3h0aWZSMlZhb2FWb3pVd016QU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WURWUjBsQkF3dwpDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQUtCZ2dxaGtqT1BRUURBZ05KQURCR0FpRUE0d3QrCkE5WGhCZnlDUXVUSk9tWjEwTjZKVHFKWGdpczJSN05aM2w1QUVaWUNJUUNWNDV0NXorQzVXbTVScWd4WXg5MEgKdlJRb3RKRjVsK29jOVpRTUNTanVndz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + signature.kubescape.io/content: H4sIAAAAAAAA/3RRwWocMQz9F52dLKGlhbmV9FLoIdDSS9mDRtbMivXIxpKbDcv8e/FsstkeetOTnp7s986ARX5xNckKA1iJp3vLkz9jZcpLySYuWe+PbWQjLHwveffnYWTHBwhwFI0wwGNWR1GuTzVPkhgCLOwY0RGGMyQcOVmvTGZFb5X/FRybxsQwwKX48BnC/6hTxXlh9TtKaNZX0BjWAIrLrcLdW98KUz+NlQ7iTF3SYPgNuMRPH2EfgLDgKElc+kBbSgH4xNRZZyjoBxhgN4ruxmYvYz5BAKzzJrK1LTEX2K/hnd2sbiNqNcG6D5AL61XdXowwpU2BUrZuGGVVJocLFXtRGWN3ItORO36u4txfbEw9nDe3u7Gvv4w8YUv+hfwSKKxrANZYsqhf78uCM3/72uev4CfOF1Rb4qechDYzzmsAiawuk3B8xJR+ONLxKrSg0+H7NV4s5TbEvqxzZXu39Qat698AAAD//24n/dd9AgAA + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEUCIFTjlXxwq5fHNeUksMDpj72n+J7EZZqA5iiN8eveVZHmAiEA6AZHgZXmh9N62HqOaHJv5g7SAyx7OSZoWa8t/ZgDAms= + signature.kubescape.io/timestamp: "1789925163" + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: base + name: bundle37-base +spec: + architectures: + - amd64 + capabilities: null + egress: null + endpoints: null + execs: + - args: + - /bin/sleep + path: /bin/busybox + - path: /usr/bin/curl + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: null + matchLabels: + app: bundle37 + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: + - close + - connect + - openat + - read + - socket + - write +status: {} diff --git a/tests/resources/signed/fragments/bundle37-base.yaml b/tests/resources/signed/fragments/bundle37-base.yaml new file mode 100644 index 0000000000..f119c781b0 --- /dev/null +++ b/tests/resources/signed/fragments/bundle37-base.yaml @@ -0,0 +1,24 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: bundle37-base + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: base +spec: + architectures: + - amd64 + execs: + - path: /bin/busybox + args: + - /bin/sleep + - path: /usr/bin/curl + syscalls: + - close + - connect + - openat + - read + - socket + - write + matchLabels: + app: bundle37 diff --git a/tests/resources/signed/fragments/bundle37-overlay-signed.yaml b/tests/resources/signed/fragments/bundle37-overlay-signed.yaml new file mode 100644 index 0000000000..a0bc9a12ab --- /dev/null +++ b/tests/resources/signed/fragments/bundle37-overlay-signed.yaml @@ -0,0 +1,34 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJYRENDQVFHZ0F3SUJBZ0lRWmVPSFh6ZURHM3FISTcvTWw0bktvakFLQmdncWhrak9QUVFEQWpBVU1SSXcKRUFZRFZRUURFd2xzYjJOaGJDMXJaWGt3SGhjTk1qWXdPVEl3TVRjeU5qQXpXaGNOTWpjd09USXdNVGN5TmpBegpXakFVTVJJd0VBWURWUVFERXdsc2IyTmhiQzFyWlhrd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DCkFBVGNZaERBb0g1SGdWdEpJaHNDL1RVWW1tc3pNdW1STkZCTnJOZ05xRGhxSVNuanVYeDhKcU5FSFF6bmRjd1UKbDM0c2ZEWGxmTnd1ZEd4WUtzYTRWZkZjb3pVd016QU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WURWUjBsQkF3dwpDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQUtCZ2dxaGtqT1BRUURBZ05KQURCR0FpRUE5dWc4Ckc0QTdpQko1aDZEOUxuU3NTSVVmdklONVRBQWdoejl2N2JsU1haRUNJUURwVlg3Y2FQL2VoMXFzOHFVcEwvS1MKcGsxNUc2aW5FWk1uVTNtd2ZzYWVCZz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + signature.kubescape.io/content: H4sIAAAAAAAA/3SRMW8bMQyF/8ubFRtBhwK3FenSLUCLLkUGnsS7ENZRgkilNoz778XFqe0O3fj0pA98T2dQlZ/cTIpigNV03FmZ/Dc1jmWpxcSl6O7QR7ZIlXdS9m+PIzs9IuAgmjDgqaiTKLfnVibJjICFnRI5YTgj08jZtslkVvLe+F/g2DVlxoDL8Okzwv+uTo3mhdUfYiYzDChv3DKdsAYoLfeQhzvLKsdtAWrxVZzjBjYM2nMOiFRplCwutzM+cjQMv86o5K8YsB9F92O301iOCKA2bzb23dq7JQkv60tAqaxXip0sUs43zXFr9W9NWyMfiyWeqGf/Ev3yE1jXANZUi6hf38tCM3/7uvkf4gfNF9V65ueSJb6HOK8BklhdJuH0RDl/d4qHG0jnxnZLe6fW9U8AAAD//yggPL0VAgAA + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEUCIQCFdxaRdcGCR/MoZT78qi1fYijUg41Xeh93VyBsTwpd2QIgI2PWtEM0XnyX8IImg4n+NTzmJ1Qi2t4yqFl7+6FS1AE= + signature.kubescape.io/timestamp: "1789925163" + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: overlay + name: bundle37-overlay +spec: + architectures: null + capabilities: null + egress: null + endpoints: null + execs: + - args: + - /usr/bin/id + path: /bin/busybox + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: null + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: null +status: {} diff --git a/tests/resources/signed/fragments/bundle37-overlay-v2-signed.yaml b/tests/resources/signed/fragments/bundle37-overlay-v2-signed.yaml new file mode 100644 index 0000000000..656a960891 --- /dev/null +++ b/tests/resources/signed/fragments/bundle37-overlay-v2-signed.yaml @@ -0,0 +1,33 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJXekNDQVFHZ0F3SUJBZ0lRSURINUhQODZwV2ZhVGRack9pYnUyREFLQmdncWhrak9QUVFEQWpBVU1SSXcKRUFZRFZRUURFd2xzYjJOaGJDMXJaWGt3SGhjTk1qWXdPVEF4TVRVeE5UVTRXaGNOTWpjd09UQXhNVFV4TlRVNApXakFVTVJJd0VBWURWUVFERXdsc2IyTmhiQzFyWlhrd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DCkFBVGNZaERBb0g1SGdWdEpJaHNDL1RVWW1tc3pNdW1STkZCTnJOZ05xRGhxSVNuanVYeDhKcU5FSFF6bmRjd1UKbDM0c2ZEWGxmTnd1ZEd4WUtzYTRWZkZjb3pVd016QU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WURWUjBsQkF3dwpDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQUtCZ2dxaGtqT1BRUURBZ05JQURCRkFpQkN6V1ZCCmdnb05ZSGo3TlV5a1NzTUFZSmtzTytOMW9lbEpNUG9kOWN6SFF3SWhBUDFsVDBjc0ZjcnRGSmNPUWV5cGVvRS8KRE8yYUs0c0gxU0xYcWJScUFmVHEKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + signature.kubescape.io/content: H4sIAAAAAAAA/3RQsW5bMQz8F86KjbRDAW1FunQL0KJL0YFP4nshzEcJIuUmMPTvxYsTOx288STe8e5OgJV/UTMuChGs5uedldn/YqNU1lqMnYvuDn0iS1hpx2V/vJ/I8R4CHFgzRHgo6shK7bGVmYUgwEqOGR0hnkBwIrFtMl4UvTf6X3DqmoUgwnn4/AXCrdW54bKS+l0SNIMI5UhN8OU243hJ9wlGAMX146W7d/4IYJXS5hJbemKntGkZRO0iARJWnFjY+fpGz5QM4u8TVPQniLDv1vYT676/nhl/ApRKeiHYiyUUuWJKW8vvtW0NvXnINGMX/5r87B3GCECaa2H1C59XXOj7t+3/DfzE5YxaF3oswunV72kE4EzqPDPlBxT54ZgOVyFdGtk12Ac0xr8AAAD//1UCfMUlAgAA + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEQCIEO5lLvwZX+vVG62C24aUHgB0C3WCdyyDVFBoC3WGtIcAiAj1461Th94GNsFR7fGbY1+EbnAYtSSc63+rKxnhqhZpw== + signature.kubescape.io/timestamp: "1788275758" + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: overlay + signature.kubescape.io/version: "2" + name: bundle37-overlay +spec: + architectures: null + capabilities: null + egress: null + endpoints: null + execs: + - path: /usr/bin/uname + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: null + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: null +status: {} diff --git a/tests/resources/signed/fragments/bundle37-overlay-v2.yaml b/tests/resources/signed/fragments/bundle37-overlay-v2.yaml new file mode 100644 index 0000000000..5f33fbc2d6 --- /dev/null +++ b/tests/resources/signed/fragments/bundle37-overlay-v2.yaml @@ -0,0 +1,11 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: bundle37-overlay + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: overlay + signature.kubescape.io/version: "2" +spec: + execs: + - path: /usr/bin/uname diff --git a/tests/resources/signed/fragments/bundle37-overlay-v3-signed.yaml b/tests/resources/signed/fragments/bundle37-overlay-v3-signed.yaml new file mode 100644 index 0000000000..893a19c7ad --- /dev/null +++ b/tests/resources/signed/fragments/bundle37-overlay-v3-signed.yaml @@ -0,0 +1,35 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJXekNDQVFHZ0F3SUJBZ0lRSThnRUo4S1g2VWFnRVkyQ0g5SGJWekFLQmdncWhrak9QUVFEQWpBVU1SSXcKRUFZRFZRUURFd2xzYjJOaGJDMXJaWGt3SGhjTk1qWXdPVEEwTURneU1qVXlXaGNOTWpjd09UQTBNRGd5TWpVeQpXakFVTVJJd0VBWURWUVFERXdsc2IyTmhiQzFyWlhrd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DCkFBVGNZaERBb0g1SGdWdEpJaHNDL1RVWW1tc3pNdW1STkZCTnJOZ05xRGhxSVNuanVYeDhKcU5FSFF6bmRjd1UKbDM0c2ZEWGxmTnd1ZEd4WUtzYTRWZkZjb3pVd016QU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WURWUjBsQkF3dwpDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQUtCZ2dxaGtqT1BRUURBZ05JQURCRkFpRUE4bytnCmxGUUVtWnRHMjZHZGlJVGNVSmFXT3BCNVNqQ1VGM1ZBRGlKK3JzQUNJSFNFb1BaYVhHY0NzRTZGUDFUYTROTTkKdDFndWFpcGJvOW4wYVVGaUVsNVEKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + signature.kubescape.io/content: H4sIAAAAAAAA/3RRsW4bMQz9F86KjSBDgduKdOkWoEWXogNP4p0J8yhBpJwEhv69uDjxpWiy8ZHie3xPZ8DCv6gaZ4UBrKSnneXJH7FSzEvJxs5Zd8c2kkUstOO8P92O5HgLAY6sCQa4z+rISvWh5omFIMBCjgkdYTiD4Ehia2U8K3qr9C/h2DQJwQCX4u4LhM+eThXnhdRvoqAZDJBPVAWfP984Xd3dQQ+guLxXunnb7wGsUFyvxBoP7BRXLoNBm0iAiAVHFnbeevRE0WD4fYaCfoAB9s3qfmTdc4Ie/m+3F/WPJo+HjAtD/xMgF9KrhD1bRJENU1z/5S3oNdPXqxNN2MS/Rr+4hd4DkKaSWf26zwvO9P3bOn8FP3G+oNqEHrJwfHF47gE4kTpPTOkeRX44xuNGpHMl26J4h3r/GwAA//96fjv8VwIAAA== + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEUCIHBPweLhNT5Ih0+J+SSPGX6inu1bJPzkTl9mvw9q0VL+AiEAoniR9jgb5HlIyTsF+RvlbA7T08sIJ+upHfVzSiNeywc= + signature.kubescape.io/timestamp: "1788510172" + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: overlay + signature.kubescape.io/version: "3" + name: bundle37-overlay +spec: + architectures: null + capabilities: null + egress: null + endpoints: null + execs: + - path: /usr/bin/id + - path: /usr/bin/uname + - path: /usr/bin/whoami + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: null + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: null +status: {} diff --git a/tests/resources/signed/fragments/bundle37-overlay-v3.yaml b/tests/resources/signed/fragments/bundle37-overlay-v3.yaml new file mode 100644 index 0000000000..4d4a3bb3fa --- /dev/null +++ b/tests/resources/signed/fragments/bundle37-overlay-v3.yaml @@ -0,0 +1,13 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: bundle37-overlay + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: overlay + signature.kubescape.io/version: "3" +spec: + execs: + - path: /usr/bin/id + - path: /usr/bin/uname + - path: /usr/bin/whoami diff --git a/tests/resources/signed/fragments/bundle37-overlay.yaml b/tests/resources/signed/fragments/bundle37-overlay.yaml new file mode 100644 index 0000000000..97b6205786 --- /dev/null +++ b/tests/resources/signed/fragments/bundle37-overlay.yaml @@ -0,0 +1,12 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: bundle37-overlay + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: overlay +spec: + execs: + - path: /bin/busybox + args: + - /usr/bin/id diff --git a/tests/resources/signed/fragments/bundle56-base-signed.yaml b/tests/resources/signed/fragments/bundle56-base-signed.yaml new file mode 100644 index 0000000000..cbf1d72209 --- /dev/null +++ b/tests/resources/signed/fragments/bundle56-base-signed.yaml @@ -0,0 +1,36 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJXekNDQVFLZ0F3SUJBZ0lSQUs5T0dObHY4YXhidjZBTVpwVy9kRFF3Q2dZSUtvWkl6ajBFQXdJd0ZERVMKTUJBR0ExVUVBeE1KYkc5allXd3RhMlY1TUI0WERUSTJNRGt5TURFNE5EWXpPRm9YRFRJM01Ea3lNREU0TkRZegpPRm93RkRFU01CQUdBMVVFQXhNSmJHOWpZV3d0YTJWNU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEClFnQUV6cUxHMWptZXQyK2pFMmQvY1dOeklkVzJNbHlPS0RySm84Z3loMWlrc3BJNW9lM3hqQkg3d200OGdhQXUKU2JKd3NWTHhvM0NCMFpNYlluMGRsV3FHbGFNMU1ETXdEZ1lEVlIwUEFRSC9CQVFEQWdlQU1CTUdBMVVkSlFRTQpNQW9HQ0NzR0FRVUZCd01ETUF3R0ExVWRFd0VCL3dRQ01BQXdDZ1lJS29aSXpqMEVBd0lEUndBd1JBSWdYN29CCjJRc1dNcWpKbWpRbDVyNWZ5WUliR3ZPd3YzYVlFZVk3ZHdya3Z2c0NJSEVyQ0txZDdXMmpZdFowdE9TbnhkMDMKamMrN3pKejlWdWU0SGdDSmhnQ28KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + signature.kubescape.io/content: H4sIAAAAAAAA/3RRsW4bMQz9F86KDQNthtuKdCnQIUCLLkUGnsQ7E+ZRgshLUxj690JxenEGb3rge0+Pj2fAwr+oGmeFAaykl53lyf9gpZiXko2ds+5O60gWsdCO8/75MJLjAQKcWBMM8JDVkZXqY80TC0GAhRwTOsJwBsGRxPrLeFb0tdJHw3HVJAQDXB6f7yHcok4V54XU76KgWZeg0W3687baAVoAxeX6m7tXcQtghWLPhzUe2Sl2I4PhN+CS7j/BU4CIBUcWdu4DXUUC0AvFzjpDQT/CAPvV6n5k3ZsQFWhPAXIh3QT21yKKvGOKveT/rfWC3oIkmnAV/xL9kh5aC0CaSmb1Tc8LzvTta5+/gZ84X1BdhR6zcHzNe24BOJE6T0zpAUV+OMbTZrSgx+P37UxYyvUxuljnSva++RVq7V8AAAD//5o05apFAgAA + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEUCIQCh3WerTMOE37FYj+FKHV9z+0wLXqygxbYwKFISLVIxowIgb7OlRsJWHCCT/UBE3Yj/klo7QUzdgslcPpqdWm8MRig= + signature.kubescape.io/timestamp: "1789929998" + labels: + signature.kubescape.io/bundle: bundle56 + signature.kubescape.io/fragment-class: base + signature.kubescape.io/version: "1" + name: bundle56-base +spec: + architectures: + - amd64 + capabilities: null + egress: null + endpoints: null + execs: + - path: /usr/bin/sleep + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: null + matchLabels: + app: bundle56 + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: null +status: {} diff --git a/tests/resources/signed/fragments/bundle56-base.yaml b/tests/resources/signed/fragments/bundle56-base.yaml new file mode 100644 index 0000000000..69074a8c20 --- /dev/null +++ b/tests/resources/signed/fragments/bundle56-base.yaml @@ -0,0 +1,14 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: bundle56-base + labels: + signature.kubescape.io/bundle: bundle56 + signature.kubescape.io/fragment-class: base + signature.kubescape.io/version: "1" +spec: + architectures: [amd64] + execs: + - path: /usr/bin/sleep + matchLabels: + app: bundle56 diff --git a/tests/resources/signed/fragments/bundle56-overlay-v1-signed.yaml b/tests/resources/signed/fragments/bundle56-overlay-v1-signed.yaml new file mode 100644 index 0000000000..a29ee51634 --- /dev/null +++ b/tests/resources/signed/fragments/bundle56-overlay-v1-signed.yaml @@ -0,0 +1,33 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJXekNDQVFHZ0F3SUJBZ0lRUldhemQ3NFptVVNqZ0Z0cFZqVGlvVEFLQmdncWhrak9QUVFEQWpBVU1SSXcKRUFZRFZRUURFd2xzYjJOaGJDMXJaWGt3SGhjTk1qWXdPVEEwTVRBMU1EVXpXaGNOTWpjd09UQTBNVEExTURVegpXakFVTVJJd0VBWURWUVFERXdsc2IyTmhiQzFyWlhrd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DCkFBVGNZaERBb0g1SGdWdEpJaHNDL1RVWW1tc3pNdW1STkZCTnJOZ05xRGhxSVNuanVYeDhKcU5FSFF6bmRjd1UKbDM0c2ZEWGxmTnd1ZEd4WUtzYTRWZkZjb3pVd016QU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WURWUjBsQkF3dwpDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQUtCZ2dxaGtqT1BRUURBZ05JQURCRkFpRUFrdlYxCnY5NUJSLzhPakxzT1FYdTR6SzI2K3dsYUxvWG4yZXNYN1AyL2Eya0NJRi8zSmRoUFI0K3RwcXhxREd2aWtNSG4KL1Q4eHZ2dmRFbHRVbEJvbW9vSWgKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + signature.kubescape.io/content: H4sIAAAAAAAA/3RQsW4bMQz9F86KDQ/toK1Il24BWnQpOvAk3oUwjxJEnpvgoH8vrk586eCNT+J7fO+tgJV/UjMuChGs5peDldH/YKNU5lqMnYsezstAlrDSgcvxchrI8QQBzqwZIjwWdWSl9tTKyEIQYCbHjI4QVxAcSGybjCdFXxr9LzgsmoUgwnX49BnCvdWx4TST+kMSNIMI5UJN8PU+43JLd4IeQHH+eOnhnd8DWKW0ucSWntkpbVoGUReRAAkrDizsvL/RCyWD+GuFiv4MEY6LtePAeuQM/XeAUklv2/ZqCUV2TGmr+L2zrZ43A5lGXMS/JL8ah94DkOZaWP3G5xkn+vZ1+38DP3C6orYIPRXh9M/s2gNwJnUemfIjinx3TOddSKdGtqf6gHr/GwAA///QBZlNIgIAAA== + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEYCIQDu2Q5XnZkhfi1ZEM3hSRc19sXfK8bcFCZZYwPtPFRLegIhANWzWTHvw0MCBc1aK3UAFNgCUrSMi8ZZ0bVRMVOX8wLO + signature.kubescape.io/timestamp: "1788519053" + labels: + signature.kubescape.io/bundle: bundle56 + signature.kubescape.io/fragment-class: overlay + signature.kubescape.io/version: "1" + name: bundle56-overlay +spec: + architectures: null + capabilities: null + egress: null + endpoints: null + execs: + - path: /usr/bin/id + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: null + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: null +status: {} diff --git a/tests/resources/signed/fragments/bundle56-overlay-v1.yaml b/tests/resources/signed/fragments/bundle56-overlay-v1.yaml new file mode 100644 index 0000000000..919454eb1d --- /dev/null +++ b/tests/resources/signed/fragments/bundle56-overlay-v1.yaml @@ -0,0 +1,11 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: bundle56-overlay + labels: + signature.kubescape.io/bundle: bundle56 + signature.kubescape.io/fragment-class: overlay + signature.kubescape.io/version: "1" +spec: + execs: + - path: /usr/bin/id diff --git a/tests/resources/signed/fragments/bundle56-overlay-v2-signed.yaml b/tests/resources/signed/fragments/bundle56-overlay-v2-signed.yaml new file mode 100644 index 0000000000..c9e89acbcc --- /dev/null +++ b/tests/resources/signed/fragments/bundle56-overlay-v2-signed.yaml @@ -0,0 +1,33 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJXakNDQVFHZ0F3SUJBZ0lRU2ViMmNJTnU3ZVd3UkZvalRyVHdHekFLQmdncWhrak9QUVFEQWpBVU1SSXcKRUFZRFZRUURFd2xzYjJOaGJDMXJaWGt3SGhjTk1qWXdPVEEwTVRBMU1EVTBXaGNOTWpjd09UQTBNVEExTURVMApXakFVTVJJd0VBWURWUVFERXdsc2IyTmhiQzFyWlhrd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DCkFBVGNZaERBb0g1SGdWdEpJaHNDL1RVWW1tc3pNdW1STkZCTnJOZ05xRGhxSVNuanVYeDhKcU5FSFF6bmRjd1UKbDM0c2ZEWGxmTnd1ZEd4WUtzYTRWZkZjb3pVd016QU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WURWUjBsQkF3dwpDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQUtCZ2dxaGtqT1BRUURBZ05IQURCRUFpQmpVTzFZCitCZmVkd2VRd1RHSnVEQ21jdTJscU5JeDVqYUJSSlA5TkJNV29nSWdXM3dteVp2RlFwNDdSeExaOGpDZTNNODYKNEUvbHVYYlZobWdSVFljck8xbz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + signature.kubescape.io/content: H4sIAAAAAAAA/3RQsW5bMQz8F86KjRRoB21FunQL0KJL0YFP4nshzEcJIuUmMPTvxYsTOx288STe8e5OgJV/UTMuChGs5uedldn/YqNU1lqMnYvuDn0iS1hpx2V/vJ/I8R4CHFgzRHgo6shK7bGVmYUgwEqOGR0hnkBwIrFtMl4UvTf6X3DqmoUgwnn4/AXCrdW54bKS+l0SNIMI5UhN8OU243hJ9wlGAMX146W7d/4IYJXS5hJbemKntGkZRO0iARJWnFjY+fpGz5QM4u8TVPQniLDv1vYT676/nhl/ApRKeiHYiyUUuWJKW8vvtW0NvXnINGMX/5r87B3GCECaa2H1C59XXOj7t+3/DfzE5YxaF3oswunV72kE4EzqPDPlBxT54ZgOVyFdGtk12Ac0xr8AAAD//8VdXhklAgAA + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEUCIGWmgI9MOkKxOt3I+/7B1tj9y112130cGrgY6fmnwrRSAiEArS/BI0klnX/u6egNco1uiOXlzwpg1i+2nFGl69EvbJk= + signature.kubescape.io/timestamp: "1788519054" + labels: + signature.kubescape.io/bundle: bundle56 + signature.kubescape.io/fragment-class: overlay + signature.kubescape.io/version: "2" + name: bundle56-overlay +spec: + architectures: null + capabilities: null + egress: null + endpoints: null + execs: + - path: /usr/bin/uname + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: null + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: null +status: {} diff --git a/tests/resources/signed/fragments/bundle56-overlay-v2.yaml b/tests/resources/signed/fragments/bundle56-overlay-v2.yaml new file mode 100644 index 0000000000..abb8945ffe --- /dev/null +++ b/tests/resources/signed/fragments/bundle56-overlay-v2.yaml @@ -0,0 +1,11 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: bundle56-overlay + labels: + signature.kubescape.io/bundle: bundle56 + signature.kubescape.io/fragment-class: overlay + signature.kubescape.io/version: "2" +spec: + execs: + - path: /usr/bin/uname diff --git a/tests/resources/signed/fragments/bundle56-overlay-v3-signed.yaml b/tests/resources/signed/fragments/bundle56-overlay-v3-signed.yaml new file mode 100644 index 0000000000..43c68465c6 --- /dev/null +++ b/tests/resources/signed/fragments/bundle56-overlay-v3-signed.yaml @@ -0,0 +1,35 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJXekNDQVFHZ0F3SUJBZ0lRS2pHbGlnendqaW5UemhMVGUybUlDVEFLQmdncWhrak9QUVFEQWpBVU1SSXcKRUFZRFZRUURFd2xzYjJOaGJDMXJaWGt3SGhjTk1qWXdPVEEwTVRBMU1EVTBXaGNOTWpjd09UQTBNVEExTURVMApXakFVTVJJd0VBWURWUVFERXdsc2IyTmhiQzFyWlhrd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DCkFBVGNZaERBb0g1SGdWdEpJaHNDL1RVWW1tc3pNdW1STkZCTnJOZ05xRGhxSVNuanVYeDhKcU5FSFF6bmRjd1UKbDM0c2ZEWGxmTnd1ZEd4WUtzYTRWZkZjb3pVd016QU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WURWUjBsQkF3dwpDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQUtCZ2dxaGtqT1BRUURBZ05JQURCRkFpRUE3TmtUCkFSQ1RLYkU5VDBuZzh5andETVdGRUdkbHJ0b1p6dWFkMEJjWjBlMENJQVB1V1dkaTdaeWk5NlZaMW1xdTdQVmgKNEF4Ny9pSkpuaWE1NGNYR3N1emUKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + signature.kubescape.io/content: H4sIAAAAAAAA/3RRsW4bMQz9F86KjaBoh9uKdOkWoEWXogNP4p0J8yhBpJwEhv69uDjxpWiy8ZHie3xPZ8DCv6gaZ4UBrKTHneXJH7BSzEvJxs5Zd8c2kkUstOO8P92O5HgLAY6sCQa4y+rISvW+5omFIMBCjgkdYTiD4Ehia2U8K3qr9C/h2DQJwQCX4vMXCB89nSrOC6nfREEzGCCfqAo+fbxxurr7BD2A4vJW6eZ1vwewQnG9Ems8sFNcuQwGbSIBIhYcWdh569EjRYPh9xkK+gEG2Der+5F1zwl6+L/dntXfmzwcMi4M/U+AXEivEvZkEUU2THH9l9eg10xfrk40YRP/Gv3iFnoPQJpKZvXrPi840/dv6/wF/MT5gmoTus/C8dnhuQfgROo8MaU7FPnhGI8bkc6VbIviDer9bwAAAP//yjeXAFcCAAA= + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEYCIQCK0weIhssIQI3IS5nMAU1rLaN4dgdklD690jyPVorMXwIhAO7C4QU0kIvroi4l8LVN/WS9OoElkcVgo7crK4KGdbAB + signature.kubescape.io/timestamp: "1788519054" + labels: + signature.kubescape.io/bundle: bundle56 + signature.kubescape.io/fragment-class: overlay + signature.kubescape.io/version: "3" + name: bundle56-overlay +spec: + architectures: null + capabilities: null + egress: null + endpoints: null + execs: + - path: /usr/bin/id + - path: /usr/bin/uname + - path: /usr/bin/whoami + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: null + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: null +status: {} diff --git a/tests/resources/signed/fragments/bundle56-overlay-v3.yaml b/tests/resources/signed/fragments/bundle56-overlay-v3.yaml new file mode 100644 index 0000000000..944a0ce9b0 --- /dev/null +++ b/tests/resources/signed/fragments/bundle56-overlay-v3.yaml @@ -0,0 +1,13 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: bundle56-overlay + labels: + signature.kubescape.io/bundle: bundle56 + signature.kubescape.io/fragment-class: overlay + signature.kubescape.io/version: "3" +spec: + execs: + - path: /usr/bin/id + - path: /usr/bin/uname + - path: /usr/bin/whoami diff --git a/tests/resources/signed/fragments/masq-admission-operator-signed.yaml b/tests/resources/signed/fragments/masq-admission-operator-signed.yaml new file mode 100644 index 0000000000..efd3f4ae0c --- /dev/null +++ b/tests/resources/signed/fragments/masq-admission-operator-signed.yaml @@ -0,0 +1,44 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJXekNDQVFLZ0F3SUJBZ0lSQUx1NHVjZDJqYytrTmIvbTBXM0VGdnd3Q2dZSUtvWkl6ajBFQXdJd0ZERVMKTUJBR0ExVUVBeE1KYkc5allXd3RhMlY1TUI0WERUSTJNRGd5TnpFNE1UWXdOMW9YRFRJM01EZ3lOekU0TVRZdwpOMW93RkRFU01CQUdBMVVFQXhNSmJHOWpZV3d0YTJWNU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEClFnQUUzR0lRd0tCK1I0RmJTU0liQXYwMUdKcHJNekxwa1RSUVRhellEYWc0YWlFcDQ3bDhmQ2FqUkIwTTUzWE0KRkpkK0xIdzE1WHpjTG5Sc1dDckd1Rlh4WEtNMU1ETXdEZ1lEVlIwUEFRSC9CQVFEQWdlQU1CTUdBMVVkSlFRTQpNQW9HQ0NzR0FRVUZCd01ETUF3R0ExVWRFd0VCL3dRQ01BQXdDZ1lJS29aSXpqMEVBd0lEUndBd1JBSWdlUHVLCmFUb2pLd21OZ3MxUW9idlYza3hlNHFjaG95S2RhWUxJZkVsclAvZ0NJRDZjQWg3S3RJWHhjK21iWnFnbE9RN2wKTHB1OFMwWGFGQnNHS0UwMXg3ZzEKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + signature.kubescape.io/content: H4sIAAAAAAAA/3SSQW/bMAyF/wvPStbeCt+K7DJgGAK02GXYgaFolwhNaRK9tQj83wfVTowO2I1PpJ75PusCmOU7lyrJoIOa4+u+pt7/YGFKY05VXJLtz9OJK2HmvaRPv+9P7HgPAc5iETo4JHMU43IsqRdlCDCyY0RH6C6geGKtraoyGPpU+KPhabKoDB2MWH+tIvxvuC84jGy+I8VaoQOMo9T3AHMAw/FqtPvQqJmprYCFXsSZmnGFzibVAIQZT6Lisp3xK9NNpMx2E/WtEqpumqmxuoZvOdePRe5xUn8kX/jCPAdgizmJ+e2+jDjwl8+tv4pnHBZVJuVjUqH3xS5zAIlsLr1wPKDqkyOdNyMbCjcmPy7bXLniIBU2hwD+lhsjMediqBAgtnCwFN9w3CDkVHzxW8E+H467hzsIkEvyREmXM1hGoXu4m3+2Oj6xMnkqDcOITi9fb68Ac/5np3n9czUj8XZzTZUfY1xyATR3XlO29jz/DQAA///iVFxywwIAAA== + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEUCIQDHgQxc2UynYPmMBN+CdqkIls12VlS3oVPuWu0B6epCygIgHQEnJ80Ubx0uM030t625WQohkGgHXgat9xQEnUtHo9s= + signature.kubescape.io/timestamp: "1787854567" + labels: + signature.kubescape.io/bundle: masqbundle + signature.kubescape.io/fragment-class: admission + name: masq-admission +spec: + architectures: null + capabilities: null + egress: null + endpoints: null + execs: null + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: + - dns: "" + dnsNames: null + identifier: masq-client + ipAddress: "" + namespaceSelector: null + podSelector: + matchLabels: + app: masq-client + ports: + - name: TCP-80 + port: 80 + protocol: TCP + type: internal + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: null +status: {} diff --git a/tests/resources/signed/fragments/masq-admission-untrusted-signed.yaml b/tests/resources/signed/fragments/masq-admission-untrusted-signed.yaml new file mode 100644 index 0000000000..9a3812bb95 --- /dev/null +++ b/tests/resources/signed/fragments/masq-admission-untrusted-signed.yaml @@ -0,0 +1,44 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJXekNDQVFHZ0F3SUJBZ0lRTlBxd1FKbXlXSnY1UFcyWkFSS3ZlekFLQmdncWhrak9QUVFEQWpBVU1SSXcKRUFZRFZRUURFd2xzYjJOaGJDMXJaWGt3SGhjTk1qWXdPREkzTVRneE5qQTRXaGNOTWpjd09ESTNNVGd4TmpBNApXakFVTVJJd0VBWURWUVFERXdsc2IyTmhiQzFyWlhrd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DCkFBUVdkU1hvdTJyamFrTW4xUGpNQ05aUzYvSDA0aXhVcDBHcHpnSEt0V2lZRllFdWFjQmZOL2tvQ1Z2UUs4eEQKVDZQM0J2TXE3aWo2Y1IzU2thU3JnMzJab3pVd016QU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WURWUjBsQkF3dwpDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQUtCZ2dxaGtqT1BRUURBZ05JQURCRkFpRUE1OHQyCk16Y1pNZXhrWlBFckpIRWxla3FBdDNRY256ZHdYVVpuc0xZT3NzOENJQ25GYXNJbWJIZnNnUFRjeGtGYk1za3UKSkMzbzVEV21FUCtGNExaYVltb2YKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + signature.kubescape.io/content: H4sIAAAAAAAA/3SSQW/bMAyF/wvPStbeCt+K7DJgGAK02GXYgaFolwhNaRK9tQj83wfVTowO2I1PpJ75PusCmOU7lyrJoIOa4+u+pt7/YGFKY05VXJLtz9OJK2HmvaRPv+9P7HgPAc5iETo4JHMU43IsqRdlCDCyY0RH6C6geGKtraoyGPpU+KPhabKoDB2MWH+tIvxvuC84jGy+I8VaoQOMo9T3AHMAw/FqtPvQqJmprYCFXsSZmnGFzibVAIQZT6Lisp3xK9NNpMx2E/WtEqpumqmxuoZvOdePRe5xUn8kX/jCPAdgizmJ+e2+jDjwl8+tv4pnHBZVJuVjUqH3xS5zAIlsLr1wPKDqkyOdNyMbCjcmPy7bXLniIBU2hwD+lhsjMediqBAgtnCwFN9w3CDkVHzxW8E+H467hzsIkEvyREmXM1hGoXu4m3+2Oj6xMnkqDcOITi9fb68Ac/5np3n9czUj8XZzTZUfY1xyATR3XlO29jz/DQAA///iVFxywwIAAA== + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEUCIQDIK9P4GincXr3Cy2sQEOstVTAtRUyC5i4YhPwbF0yWRAIgXSlv6MJxKCtYXA5oRqrjsh5kaZLCbuilesmWjmHsWsI= + signature.kubescape.io/timestamp: "1787854568" + labels: + signature.kubescape.io/bundle: masqbundle + signature.kubescape.io/fragment-class: admission + name: masq-admission +spec: + architectures: null + capabilities: null + egress: null + endpoints: null + execs: null + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: + - dns: "" + dnsNames: null + identifier: masq-client + ipAddress: "" + namespaceSelector: null + podSelector: + matchLabels: + app: masq-client + ports: + - name: TCP-80 + port: 80 + protocol: TCP + type: internal + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: null +status: {} diff --git a/tests/resources/signed/fragments/masq-admission-vendorkey-signed.yaml b/tests/resources/signed/fragments/masq-admission-vendorkey-signed.yaml new file mode 100644 index 0000000000..71c8590f94 --- /dev/null +++ b/tests/resources/signed/fragments/masq-admission-vendorkey-signed.yaml @@ -0,0 +1,44 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJYVENDQVFLZ0F3SUJBZ0lSQVBGUEcxYXBvVlkwU1J5WEdrektKZDh3Q2dZSUtvWkl6ajBFQXdJd0ZERVMKTUJBR0ExVUVBeE1KYkc5allXd3RhMlY1TUI0WERUSTJNRGd5TnpFNE1UWXdPVm9YRFRJM01EZ3lOekU0TVRZdwpPVm93RkRFU01CQUdBMVVFQXhNSmJHOWpZV3d0YTJWNU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEClFnQUV6cUxHMWptZXQyK2pFMmQvY1dOeklkVzJNbHlPS0RySm84Z3loMWlrc3BJNW9lM3hqQkg3d200OGdhQXUKU2JKd3NWTHhvM0NCMFpNYlluMGRsV3FHbGFNMU1ETXdEZ1lEVlIwUEFRSC9CQVFEQWdlQU1CTUdBMVVkSlFRTQpNQW9HQ0NzR0FRVUZCd01ETUF3R0ExVWRFd0VCL3dRQ01BQXdDZ1lJS29aSXpqMEVBd0lEU1FBd1JnSWhBT1FvCkordWxhcXIza0pEOG5BTEg0N0QybTUyazBOcVRtVzAzNjNqNEdpb29BaUVBbFNCb0MwMnpaUUFwQ0JYRHg4amQKTWpjYk9MeVNlcnJ3eU9rYkxMMzVRczQ9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + signature.kubescape.io/content: H4sIAAAAAAAA/3SSQW/bMAyF/wvPStbeCt+K7DJgGAK02GXYgaFolwhNaRK9tQj83wfVTowO2I1PpJ75PusCmOU7lyrJoIOa4+u+pt7/YGFKY05VXJLtz9OJK2HmvaRPv+9P7HgPAc5iETo4JHMU43IsqRdlCDCyY0RH6C6geGKtraoyGPpU+KPhabKoDB2MWH+tIvxvuC84jGy+I8VaoQOMo9T3AHMAw/FqtPvQqJmprYCFXsSZmnGFzibVAIQZT6Lisp3xK9NNpMx2E/WtEqpumqmxuoZvOdePRe5xUn8kX/jCPAdgizmJ+e2+jDjwl8+tv4pnHBZVJuVjUqH3xS5zAIlsLr1wPKDqkyOdNyMbCjcmPy7bXLniIBU2hwD+lhsjMediqBAgtnCwFN9w3CDkVHzxW8E+H467hzsIkEvyREmXM1hGoXu4m3+2Oj6xMnkqDcOITi9fb68Ac/5np3n9czUj8XZzTZUfY1xyATR3XlO29jz/DQAA///iVFxywwIAAA== + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEUCIQD2Ou4frwQo6895Xl6uQLahFdOfAIqesMIANl1czZH+GAIgHnG6Ym657u8N+73tPiOZFXZaVcLS5UWskSTVNHOC578= + signature.kubescape.io/timestamp: "1787854569" + labels: + signature.kubescape.io/bundle: masqbundle + signature.kubescape.io/fragment-class: admission + name: masq-admission +spec: + architectures: null + capabilities: null + egress: null + endpoints: null + execs: null + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: + - dns: "" + dnsNames: null + identifier: masq-client + ipAddress: "" + namespaceSelector: null + podSelector: + matchLabels: + app: masq-client + ports: + - name: TCP-80 + port: 80 + protocol: TCP + type: internal + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: null +status: {} diff --git a/tests/resources/signed/fragments/masq-admission.yaml b/tests/resources/signed/fragments/masq-admission.yaml new file mode 100644 index 0000000000..e76a71869d --- /dev/null +++ b/tests/resources/signed/fragments/masq-admission.yaml @@ -0,0 +1,18 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: masq-admission + labels: + signature.kubescape.io/bundle: masqbundle + signature.kubescape.io/fragment-class: admission +spec: + ingress: + - identifier: masq-client + type: internal + podSelector: + matchLabels: + app: masq-client + ports: + - name: TCP-80 + port: 80 + protocol: TCP diff --git a/tests/resources/signed/fragments/masq-base-signed.yaml b/tests/resources/signed/fragments/masq-base-signed.yaml new file mode 100644 index 0000000000..ae6689320e --- /dev/null +++ b/tests/resources/signed/fragments/masq-base-signed.yaml @@ -0,0 +1,46 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJYRENDQVFLZ0F3SUJBZ0lSQUxHTEdBVG4yNU93RVZ2bUJGRUNxdjh3Q2dZSUtvWkl6ajBFQXdJd0ZERVMKTUJBR0ExVUVBeE1KYkc5allXd3RhMlY1TUI0WERUSTJNRGd5TnpFNE1UWXdNMW9YRFRJM01EZ3lOekU0TVRZdwpNMW93RkRFU01CQUdBMVVFQXhNSmJHOWpZV3d0YTJWNU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEClFnQUV6cUxHMWptZXQyK2pFMmQvY1dOeklkVzJNbHlPS0RySm84Z3loMWlrc3BJNW9lM3hqQkg3d200OGdhQXUKU2JKd3NWTHhvM0NCMFpNYlluMGRsV3FHbGFNMU1ETXdEZ1lEVlIwUEFRSC9CQVFEQWdlQU1CTUdBMVVkSlFRTQpNQW9HQ0NzR0FRVUZCd01ETUF3R0ExVWRFd0VCL3dRQ01BQXdDZ1lJS29aSXpqMEVBd0lEU0FBd1JRSWhBS0pIClV4SVdvRDhvbEVHZEtRWGhOY2dwd1JUWjRnQzVONlY1bFFkYmt6VElBaUEydytsNXZIR0thYjEzNmpIcW9jTTEKZ2FtT3VaZmFxWEJSRU1hdWc4aFNIUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + signature.kubescape.io/content: H4sIAAAAAAAA/3RSPW/bQAz9L5zPNgIEHbQV6VKgQ4AWXYoMFEXJhCne9UglDgz99+LijzRDNj3y6ZF8706ARX5zdckGHXgZjlvPY7xgZcpzyS4h2baHpWcnLLyVvHu+6znwDhIcxAbo4CFboBjXx5pHUYYEMwcOGAjdCRR7Vm9fLpNhLJU/CvaLDcrQwYz+9wLSZ+Sx4jSzxYYU3aGDHp1hTWA4XzU215oXpjYYK+0lmJqcQ/cHcB6+3MNTAsKCvaiEtIYtqgn4yNRYJygYe+hg14vtXJkLrOm9OmQ6cN2wRX0tWSy2vv9AWLzuvP1rk9gR1qcEubDdBvmrE6qeNyLiEveQoG+uJiDN3nygbMYUkEDFgw3OItgqlbFRvS3S8EuV4HaWM7X8roE07y9WDDziovGV4pw5rGsCtuHtgNtmMuPE37+1/gX8wumM6qL8mFXozbHTmkAGtpBReHhA1Z+BdLgJzRi0/3F7AVjKNSPn+sy1pSQ2VfZ3+/9D6/ovAAD//7EADRmjAgAA + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEUCIFSFU7AT7P885kxUEGM74DL7IdG9eUZ2QC1VI8sZdrV6AiEA1ThD763G6ifisV1NrfcNLMiP8jhVzf0TYhErMxgACu8= + signature.kubescape.io/timestamp: "1787854563" + labels: + signature.kubescape.io/bundle: masqbundle + signature.kubescape.io/fragment-class: base + name: masq-base +spec: + architectures: + - amd64 + capabilities: null + egress: null + endpoints: null + execs: + - path: /bin/sleep + - path: /docker-entrypoint.sh + - path: /usr/sbin/nginx + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: null + matchLabels: + app: masq-server + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: + - accept4 + - bind + - close + - connect + - listen + - openat + - read + - socket + - write +status: {} diff --git a/tests/resources/signed/fragments/masq-base.yaml b/tests/resources/signed/fragments/masq-base.yaml new file mode 100644 index 0000000000..4e9d43ac5c --- /dev/null +++ b/tests/resources/signed/fragments/masq-base.yaml @@ -0,0 +1,26 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: masq-base + labels: + signature.kubescape.io/bundle: masqbundle + signature.kubescape.io/fragment-class: base +spec: + architectures: + - amd64 + execs: + - path: /bin/sleep + - path: /docker-entrypoint.sh + - path: /usr/sbin/nginx + syscalls: + - accept4 + - bind + - close + - connect + - listen + - openat + - read + - socket + - write + matchLabels: + app: masq-server diff --git a/tests/resources/signed/fragments/masq-baseline-signed.yaml b/tests/resources/signed/fragments/masq-baseline-signed.yaml new file mode 100644 index 0000000000..8d5f9c9db7 --- /dev/null +++ b/tests/resources/signed/fragments/masq-baseline-signed.yaml @@ -0,0 +1,44 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJXekNDQVFLZ0F3SUJBZ0lSQU93UFcwd3BGNmRybkR4LzRuZklBQVF3Q2dZSUtvWkl6ajBFQXdJd0ZERVMKTUJBR0ExVUVBeE1KYkc5allXd3RhMlY1TUI0WERUSTJNRGd5TnpFNE1UWXdOMW9YRFRJM01EZ3lOekU0TVRZdwpOMW93RkRFU01CQUdBMVVFQXhNSmJHOWpZV3d0YTJWNU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEClFnQUUzR0lRd0tCK1I0RmJTU0liQXYwMUdKcHJNekxwa1RSUVRhellEYWc0YWlFcDQ3bDhmQ2FqUkIwTTUzWE0KRkpkK0xIdzE1WHpjTG5Sc1dDckd1Rlh4WEtNMU1ETXdEZ1lEVlIwUEFRSC9CQVFEQWdlQU1CTUdBMVVkSlFRTQpNQW9HQ0NzR0FRVUZCd01ETUF3R0ExVWRFd0VCL3dRQ01BQXdDZ1lJS29aSXpqMEVBd0lEUndBd1JBSWdYLzBDCnFLWC9sZkVyT3BFcE44L0RicTV2Z1R6emMyZFZtby84TlgraGFMZ0NJRWpLenhGTzJWZWhkYXU0UzYzaTk0MEYKZ2I1ZG1DY2kyZ09PemE0WE4zZG4KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + signature.kubescape.io/content: H4sIAAAAAAAA/3SRMW/cMAyF/wtn3TXZAm/BdSlQFAck6FJ0oCXaIY6iVIluExj+74Vix0aHbnw09czvcQbM/J1K5aTQQc3h9VzTYH+wkE8xp8rGSc+3qafqMdOZ06ff9z0Z3oODG2uADi5JDVmpXEsaWAgcRDIMaAjdDII9SW1V5VHRpkL/GvaTBiHoIGL9tQn3v+Gh4BhJ7eQFa4UOMESu7wCLA8X4YXTqsZKwUuvXTL5tgMW/sJFvvhU6nUQceMzYs7Dx0aNX8rtImXQX9a16FDk0+RbVB3vD3H4WaMBJ7NHbGi8siwPSkBOr7e854khfPrfvm3jGcVVlEromYf++2Lw44EBqPDCFC4o8GfrbYaRjoRbJj/mYK9BBT8qjggN7yy0dVqOiKOAgNC5Yi28YD/6ciq1WW6TPl+vp4Q4c5JIs+SRrD9ZR6B7ulp+tDk8k5C2VlkBE8y9f9/tjzvtx1p2W7WY1o6fj5QaUH0NYkQCaO22A7fOy/A0AAP//QM8QBr0CAAA= + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEYCIQC2nq/MP3Mw23aOy47A0CRhyQ+gaHsAIVthgtglBwugbAIhAIo9WZi6sFWneO+6UR5Rc25t9w4vEURz1FBwTIu84nf5 + signature.kubescape.io/timestamp: "1787854567" + labels: + signature.kubescape.io/bundle: masqbundle + signature.kubescape.io/fragment-class: admission + name: masq-baseline +spec: + architectures: null + capabilities: null + egress: null + endpoints: null + execs: null + identifiedCallStacks: null + imageID: "" + imageTag: "" + ingress: + - dns: "" + dnsNames: null + identifier: benign + ipAddress: "" + namespaceSelector: null + podSelector: + matchLabels: + app: masq-benign + ports: + - name: TCP-80 + port: 80 + protocol: TCP + type: internal + opens: null + rulePolicies: null + seccompProfile: + spec: + defaultAction: "" + syscalls: null +status: {} diff --git a/tests/resources/signed/fragments/masq-baseline.yaml b/tests/resources/signed/fragments/masq-baseline.yaml new file mode 100644 index 0000000000..02f4baaeb8 --- /dev/null +++ b/tests/resources/signed/fragments/masq-baseline.yaml @@ -0,0 +1,18 @@ +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ContainerProfile +metadata: + name: masq-baseline + labels: + signature.kubescape.io/bundle: masqbundle + signature.kubescape.io/fragment-class: admission +spec: + ingress: + - identifier: benign + type: internal + podSelector: + matchLabels: + app: masq-benign + ports: + - name: TCP-80 + port: 80 + protocol: TCP diff --git a/tests/resources/signed/keys/ct-nonroot.pem b/tests/resources/signed/keys/ct-nonroot.pem new file mode 100644 index 0000000000..daf3361f80 --- /dev/null +++ b/tests/resources/signed/keys/ct-nonroot.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIKlpLIWajZesp3r7oP+XCLiL9O+fW6AkeDqbR9GlWEwGoAoGCCqGSM49 +AwEHoUQDQgAE0YJFBwzzf0Empfc0I7eC+Ay+xrK0WIhvvtZNS8aJ9MRj59NF9FBg +VV8J0gPPyOR5OP2JurgpWsdCyTlxfaEVOw== +-----END EC PRIVATE KEY----- diff --git a/tests/resources/signed/keys/ct-operator.pem b/tests/resources/signed/keys/ct-operator.pem new file mode 100644 index 0000000000..1ee2e1003a --- /dev/null +++ b/tests/resources/signed/keys/ct-operator.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEICzNqoWiZT57jmozAl+QGXbubG4QS3wZv0DsUWT1MrdLoAoGCCqGSM49 +AwEHoUQDQgAE3GIQwKB+R4FbSSIbAv01GJprMzLpkTRQTazYDag4aiEp47l8fCaj +RB0M53XMFJd+LHw15XzcLnRsWCrGuFXxXA== +-----END EC PRIVATE KEY----- diff --git a/tests/resources/signed/keys/ct-root.pem b/tests/resources/signed/keys/ct-root.pem new file mode 100644 index 0000000000..4f98956c5f --- /dev/null +++ b/tests/resources/signed/keys/ct-root.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIE/8fN8biXoVR6LPLw8Wizqo52xQ4BN2gsVNHxvq3RxZoAoGCCqGSM49 +AwEHoUQDQgAEMO5EpeQDbR706oFh94T8K+yNK0piJEUpsKUD3t2vD4MEtZZdrptU +1UONFpiWxy2nwGCzlpZmIXtRrXDBDVaNfQ== +-----END EC PRIVATE KEY----- diff --git a/tests/resources/signed/keys/ct-untrusted.pem b/tests/resources/signed/keys/ct-untrusted.pem new file mode 100644 index 0000000000..7bfe5a68e1 --- /dev/null +++ b/tests/resources/signed/keys/ct-untrusted.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIGwv9cfoLzKXKp93acWFrnVTWGkva9t7UGVkT+RWZGH+oAoGCCqGSM49 +AwEHoUQDQgAEFnUl6Ltq42pDJ9T4zAjWUuvx9OIsVKdBqc4ByrVomBWBLmnAXzf5 +KAlb0CvMQ0+j9wbzKu4o+nEd0pGkq4N9mQ== +-----END EC PRIVATE KEY----- diff --git a/tests/resources/signed/keys/ct-vendor.pem b/tests/resources/signed/keys/ct-vendor.pem new file mode 100644 index 0000000000..f845f3520e --- /dev/null +++ b/tests/resources/signed/keys/ct-vendor.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEILL+/q2HAiLWNQD2Oyk44H/y3xPO2Z8eSpHoaN3Vh+EsoAoGCCqGSM49 +AwEHoUQDQgAEzqLG1jmet2+jE2d/cWNzIdW2MlyOKDrJo8gyh1ikspI5oe3xjBH7 +wm48gaAuSbJwsVLxo3CB0ZMbYn0dlWqGlQ== +-----END EC PRIVATE KEY----- diff --git a/tests/resources/signed/rules/cluster-baseline-signed.yaml b/tests/resources/signed/rules/cluster-baseline-signed.yaml new file mode 100644 index 0000000000..bc94e901d4 --- /dev/null +++ b/tests/resources/signed/rules/cluster-baseline-signed.yaml @@ -0,0 +1,1056 @@ +apiVersion: kubescape.io/v1 +kind: Rules +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJYRENDQVFLZ0F3SUJBZ0lSQU1FVTdCZkN6bVZ1YVJ4YUlUbEVFWGd3Q2dZSUtvWkl6ajBFQXdJd0ZERVMKTUJBR0ExVUVBeE1KYkc5allXd3RhMlY1TUI0WERUSTJNRGt5TWpBd05ESXlNRm9YRFRJM01Ea3lNakF3TkRJeQpNRm93RkRFU01CQUdBMVVFQXhNSmJHOWpZV3d0YTJWNU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEClFnQUV6cUxHMWptZXQyK2pFMmQvY1dOeklkVzJNbHlPS0RySm84Z3loMWlrc3BJNW9lM3hqQkg3d200OGdhQXUKU2JKd3NWTHhvM0NCMFpNYlluMGRsV3FHbGFNMU1ETXdEZ1lEVlIwUEFRSC9CQVFEQWdlQU1CTUdBMVVkSlFRTQpNQW9HQ0NzR0FRVUZCd01ETUF3R0ExVWRFd0VCL3dRQ01BQXdDZ1lJS29aSXpqMEVBd0lEU0FBd1JRSWhBTXdoCktiRzdHeXY3ZVNCS0lHcUFQS2NDRC94RGRJR3l0ZGlpWkNZNlpCSUpBaUFvWG43NVJzZlhkUGR2RW1xNnFVeVQKREhYNmpydms3RkJWQkVMTElWYmFlZz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + signature.kubescape.io/content: H4sIAAAAAAAA/+xdeXMbt5L/Ksj8Q3IfSZGU5YNbrleO5SSu+FD5eG+3LBcLnGmSiGaACYDh4Tx/9y0cM4M5SUmUr+w/iQlgGg2gf92NRgP6y8Mx+RdwQRj1pt5VMgfh4xiGhJ2sx17fuyI08KbemyQE4fW9CCQOsMTe9C8vxHMIhfqXIEuKZcJhWCCw4HgZAZUDP8RCeFNvjgV4n/sexRF4U8+XA1Uy4Jr4574nYvAVPVMw/fCXBxTPQwi8qeQJ9D2ieRmNRoo1S+U9hW0MvoQAxZz5IAQKcUL9FQRe3wtA+JzE0ozvHCT4UqCk8RuB5ApLhDkgyiQiFMkVIMVmSCh4fQ+2MQeh5ksPPQIh8FLx0WlhZIo66B8I1kDl0GdRhP6BOmhD5ApdPD/XdUJyQpdd0yQmQc/rewklfybwXI25+OkspwZbiLFceX09ac8y5szsqSbvdrFiD7bgF9j3pt5PfjzcYDFTdYmEoJt2QyUmFPjzoI9izAUMlyB1q5nqzDbDfCn6zqD6RZZ6Pe/zx899L+ZsQUI4hxhoANTfedNRXoolfgN/JoSrRf7LYzGoeaVJGPY1y0pscBh6fc/HMZ6TkEgCWQuxEz4Ow/wLGsSMUJkXLNVoz1mECS0VPgkC9b+cGKF1jW1pqbUSVlgDJ3LnTcd9TyRxzLi8YCFRA1zgUEDfk3ipxNhTEwpbOVXo4BSkhlJamM221/cwZREOd56eHyVBXj9dOBzHIfGxEmQ7d97HvoeXQKXFr51HhThv6nl9j4h3nCyXwJ+EwGWKoYhIDu+wL4nvTb13T0aj0cRLi8FfaaFTNePR2SPvc98BoR1WhsJJjsJfSAgCPfG12D/RwyAgFH7c8e1FoxoXwoaKBiIRt8GhSy7Qvd0Ii7qJZM6HFnOHIPQweCrBL8OzmxMYCom5FP8mctXtnID0Tzo99J//oKYWa8xPQrY8oBVP6J5W+1soOiJmLDzxOTuk8WazOWQAZL6nFYvlnhaJUBPh43BPOxzHlRbo8WPUORkGzL8CDnTdqatWQD0REC5OgK4JZ7TTQ5fJaDS5b/6LfmpcRjWxAnwOUpzkqkFZTgF8TXzAvs8SKjVflxTtXcejEJNR3On1LmlxENZSqOYzJav1xiKn2bukN1f/H/7yYg4LsvWmnhZ2rYbyolS6a4rVNJSKa4pKAltTqyS0rlcyLxcrGSwV5UJXqlBSZlTqFvtKSzvSVSyviJX3+WNmEY1l+nsZRG3x+qmePII1tCzVmMNHDeZwdFY0hyWf9DS3hm/tMtzCEoqdkBAhQ6bgk25WREJIhGo13yFnKlA6FweaR6ePWvNopekVjuCGHqtDYb8NtI2bvFRbPUtEm/JxejyWA3oI3KyT+uPgLV+MO8Xaoa5nCWv3cqy9IDTZoqfO6twCde4i3xXqsj52SIlywR/FcQY2QpFdg6MissZDtb3uB2gBAg0ozUe3B6i217sEqQXlj2sUSwvyLSL1LEfq+au314MmoUsXnIGec8ThzwSEvDY+h4cC1PajkJJQS8RFKU3Rt+AsKm4n7XgsnA4BHz0IeQGtAi7/fgg0sO77UKz9oR8mQgIfah90WNmQ+PGQiJkZ5YzQmZH0ZqTSL2xLD0NpanG/WZiaNcvBSkFuGL86OjLH4wZkPhgPlaVsQ+f92jiq3TEiu2VEkl0BtXGUw6CahnBYPa2DobiHzrQUkslt4iLES+FW64LhH4zQbqffaTSNN4zUHGOPXwCpQy8H94kedKen4xSXFDX2es2AwPV6buvY7RSuxBBH+BOjeCPU5N5lx+URH7fzYuNLWhMOmSmxm4lksSDbOlWaEbtlYMT04E09Q+97Cguc3Ui1OluSwgo2+jtCYglq0gj1wySACx1+0QdKnisjXt8ri42nFuY4MfX79Ur5bPKwVSE/yBXyvxm/ChkO1C5BoN+zOUFPLp47ulZPTZNKNic7ygliC5RPK/JDAnoKG7VwpvJ0mFPR6aB/os7vZRqoWw2n99AGC5SeKu0Lrk9R55UxjcohpIpzRpXKLw1ZLT9w7XTlnuM1vK/qYPSJVon9KepYQ+3UBEIqwb7ZKVvXoa66V8vgy9CJJtsTM1fzpI16Nc7bnR7Z9YsDsnNRHpNdvqt8Ul+/f/fr6+evfu0U+b16KJSziWMyM+s3w0ZDdAsz288n+oJx2TBo++khPqul+/2cQdoevrBytfKar3Kube90M/mwXjtOxqNW7fgw145vAAfomYmNKw7QvzAn6ithNETMmb9o81Y54ECrSIfGuo5Gu7P65lAytb7qfJflChT10HH90yavSZ8ylF2hBl+o6XjrJq5QRutoztB3eEpyM8TuTxvQUu+I480cJUXpC7hDZ5PhaDRuBf2jHPTw88Uv6IKzJccResFwU4aPBqRqG9u2yo9qx/E8XhRaFw5HuvN4YZwa9dNEmFLc1vs/yuVRPzENUPfni19mF29e/zp78frJea+zNzKkmOlkvwoekx/psO4eHTCPF/UqwI8CZa0TQmX3rAHJ+85ZNHfNhnV89HjQh/xcVA1MQ/w7ROyKCYVFuzbmf3eZ1XPWZGNbdyDjUW1I6C1QQSRZA/qFhGATfVrQ5wZu0i/V8PZY00IQyv3OEjyOET1ahk5bgo5Y4YBtDjCXexIpbg60ShqFYenvaSNLKNSyeGx/97rWb09Gwbg+y/WZnjWU7pnfcbxYEP+wuKxZH2T9fSTNt4VUuyMepJR6KxyoIMlcMNv9mkLmtGL0sl2hQnsi1Jgc9HIm2c2OY5xOq5bW7XRW6m6/trjtvvknClJtnGNO1ljCjMTFDfM3t0O+c03ydTbIdarEQUjD3vnOjnpGDYrkXqsLfa/epqdGE/NlophpsucCYaqjeUZy0GbFBCBtfp+/TVNza/QEmidpY8yXa7QGXzKOAgZG00RY+iuE6Q5x8BkPIDDtYiwlcIoWjBvNpA0sesqoSCIQqBQCM9tNE9rarIi/QhscXgnN1TMlnD/vLhSvMWd/2OCiSPgC+4CUdx5ACEssQZjOfRbFmBNhQpDBjuKI+IoBkyDF+PClYlsRfsKXAnWFZBwvoTdEbyXeCSRIaCYJzMToeSICJfSKsg1FXX1/AflsDdyeYftYQE8zk33lTpieJ8WfO1PpJHXN5k3pRPWd5JiE6scn4GzA+CBiHNRMRpq+aqL0ZwgDzJe6vPffCKPOf+mEE1xYCcU1RiGRwHGI/BXm2JfA+3rxMNqQMPAx37OvapO3m6Ziq8l5nH+qln4Y4biL++kHuNezZ32o+ayv/ipFqfQA4jeICd9tFLc9ZJzj5bjd9p0Z+37irrd0UE//Lnc/XIsyHo0ci3JhYf0sPe/RQc+TANYnYhXtOZyyCa7GShgJVR0arck4UmZUtQ0I18pwZ1RpADzroa90EeNhMNhwYr6OIGJ8N5hj/woC1JVRvBC9nEa7xkrHA7XjcXWWozGU9sx5dNTaJriV+okPC/q2nDuldPVtBTuImrOnwv7VNjuxqQbdbCxNVPxNcAgFrUwF+QRdo6NOAY2Kyspp+GH0sak7W93YZVtceeLC9+ExIjp28rNbkApROCQ+YYn46kB19pDnnCxcO5wK+GEQZQs0JxTzSmasjR4H7iUtRCKFpkNgtv/8WDkudnMaYy4VJ6of3cX+YOrdoiuJY+Ah3gFXsqrm3iSqWPrV6qZUkjZXoITknx6jS+/SQ/8sjW16DePdjpHrxXC/eSN9O5S7RtpKQg7vvqdBocx2Qe6/QmB3dHq/VRNMqiZbGjQzc+KB0ZUafYgiFiQ6n71JLfxu2r3U7fRBTLtJ/d2l6yLelmjMKx506FhKiGJpNgJpCny3MQf+Oucyh2qLUhezMsP7VcZVxIL64I9LW5k3QomcGbKOhSu3WrjNDrNt49ExjJsdiJEMJXjpBKjlunOZPrv3YDga3W+5hjwuXrx6+xt6Wkgicvaf5yAkoSZEwSh6xejgrcQ0wDxAF4zLtuMMihTpYn4SRpTRgUhJqIlWG3gO2jwFTm82LFe6zZwplo5AIWBO85BpetG5HVZVltxs+eJoK6yWoq/P43LsNdjRUij00J10Rm62l9y+K2FiVUZRTkdwP08WOoXH6HTy4P7Doj/Z1Np/jO6PHj16VL6rW+VSrdaHyaSPJqPJ5ONR4q7P4y95fPqDRF2NKOTirjbKBrSuNb5l5LVRPzUkLI0m4/b8hXHhrlr91jKy6awHOOF5exMCXO05Tz3cydaUcYF2o7X8Jl8bqeYdKjIZ9ZkemaaYglMtioixD9l5KwuUwe3XnR/1hrAlQoquptNv3j3rer3pvflgqvSOpTC+eV/9eAoj9dVbk8W/6t78rPh+SpirBmW00xvZLZoh++pZ+lWrNmjpZJprGXTpVbXFpYd4QkWmJgoBNsES7gPqRhAtAnSCnNcLFsHBTvfRt+hFiFrREN2OZtN97KM2BuaOoa6xPZVJW34YDR59/MfJIrD/asmQOn4EamEX1kp9Kgl3EIVq2nuetWQyjJvumSVUrDAHZJ9LUJ7W00OuhApIY0AphUKyHkaZZh9EmJI4CY0b7Ny8tuduFM1B38JWzrN5uQzh3DHv6QwI7TvzhPrp3rJv3XwitQ1VLr0AoHV+vaN1Btmx6EHO/fu6odnxHnfP27FUO/thZls2pDVoqj89Rh01WeWEhkOzC1NmvlKGYTrC7zXL0LWAzmpp0S4UfZnMp3v1+uL+uN1ndq5BPeW7WDIU6ds+e172U9rhf16+Qfabl+oboQDCMQ1YtEU4XDJO5CpCicJZOwLLlFKYTV2cOVarl3nTtZirt2+HmTfLfxl3ata+SDCokEmnp0Svq5reO7Ez9xoSXe49ao9xPqzIzUtClVQYGKKnbgpaixC5QidKiWvznX0hoFV4Cj2ZWCZGJgPEz4jrk01Nq/y+QJ0A0Wo48jDpqXk9wKFIKPrQmRimfBz6iuiw00ediRl+9htTGTMW5r8FweMhyJUuZXypS+dsBRHBtNB0zuScbYcBrM1PGQ3L1PzT4s97Bao+HiputhHPW9ChkBzLJBqKMBGrwucBFqtKFwGWmgyhS1UuJJZC0QBs66HSCcjVAPNhsMF8UaClKwTBLVXjIcWUFYahq5Kmb5KGL+ZNDPi0sWLSUANNvUNT75BM6itWVw2k/ogbSPGmznnSxK9YNlQkYgBYyIaeEjHYQEttI9EGPiqyBEmL9Km5BLkCrsQp7zmpomWBhaG73X0yBbwig1dKQ7GrHV1mRZpuxChwVuhYl28jrgspyLSQ0GWEAwoiR7P5euhDwIk/8DkRMaGVWq0VBprcgrs1C0z4SoEsHYktV2VrnIQ6U7ZQAZyVFIqpCUokNNViE+YDpi5S7dDzZkkoiWYzpx5PNNvEcKH/faWGA9u8iSosT7cpbBqNruVxiKnM1kyXWetaoaQKYrMQsRkccRaSb4cJVRwoe5aVimWFK7FskTdxtStIlWAhG6ivyypciIogW6qDOp2c1tVq07SyDh1pXSj9xrpt1Fz3CRrrquXViZc8sbDyfV1glUXLFCaiDrK2tDC5SkfUtZxUWm5gnoERElO02QyVxf+D8UL3unxbKTLQHQSJ9FdDGuryunnbRrzJ5OiqOt2uKmqVvqmoUfqqola3q4pmfWxrG/RxnZjqtayqHlW8pDIcJlfZ71p6qmAQ412BYAZ2vsQSClUKLxUiaxbKgV5jt+mnVOVavVInqZ+AL4ueUiLZ0Do4ucAMmzWj6ilVVvZIY9j5+CXPffO09jZ/37xydCyv/8YvGY0Lt0SLnv8bCLEEc8J7Xf8/30CKRMRmzOac94Dt47kNrZb8fW750WSKOwtGTWn7BZjCQ9TOFRazydD3U3wWlm/I7I0C1XZ424su+m7O48eo8+7pRaf2xZ39V2EKHOndyunp6Wkf3Tt7MBp9vPt7MD/YieztMpavqR2qx7HZpXPFgepPL6ZZy+veNr/Jk2htSsS9+/qWLSQKCb1CPgcNWLYGXrqV2nhhRuyi/FsN7erHoj23w6VQ37sbM2BhcJGmIg9saq0TUYDNxeEv1ltahzzYq3lsSI20ZFpuxmZnKs1Nk4ABF43P4Bx0jdZSv4ubtP1KhWH4+75ja1BxHX3gvhucysS3cKG2lCrm3qgNg1nMQSc+vgR/hSkREXovADGOwmAo2DCrZgFZ7PcZnGOpF+ezizfPXrx+cn7y4nz24vnPb568+d/ZxZN3v6U3ygZqkoCjFWNXKEr776vOMXXTuDacSFA2X4tXkTHtSihtINnxDp4ygBffymrNJCHCXsvF6fiQMz6jp2zwXOdDhMFMVczWmJdeAnu3gjIJn9EFWSbcKFI9kG71FQBzJBbppbrZodjB74MppXDt5wOaD6izU7M/8BrX+j6FRBTdNMIyxPNS4wNmWH3bqTzy1f60gbmEUZG+UucrnPpY+qXNN3hT+8JiWqk4Gd1KIWd/KqHC2Q+vfGuy0L9CvvnZg3vV3NySQ+XknP+GeXBTh0qplhXmwX6fqn1L9luBxjW9qrv3p9Ih/r9D9fdzqNIdwZcC9U2eJBmPxme1iTwXkmM/z+N5L8wlmAM8pdh8mV7z0Gk5d5lTk19GL3as4zk1L4jUPyJ0m6N80/HNT/KP8ghVfsX4y+WI3Z9M2kTrtP6RCsJmCVdC8zoG6wKetydGivwbln5jryxuwN5ZzJ45CExDfZ+QEklwiNhcAF/blyyAExb0EaGBlji6RDGTQHXDhOJErhgnn/RL5ZKsidxzt7huYBmTTjJml8U+C+BxJTBnynvG49XO1OPRVrXq1jlg/yx+nFdMUWfU6aUXl6sO8rDkIDfxcG3pJ0wP+lri/4P9tZ5b7bcLNyLS8fS9VJi+Tp5z65Nyk8JjAc+24Kvtasya4aszkR3o6rB4INCaYA1U511oHEREyxDawFzvpbsXLFCdvI7Nx09fv3r17Om7XissHa5c9e/+LZ4spbRUb8rsM3R51XsB/DldMP2PpoSewvcnRboHXDF8KAbZ+Ou3b78Tqq/sX3rFWbn07jI5ucgU+Hcuf/dHLXn2k8IdeH2GsGB8o3Yje+RQjXuQtr2NPKpOfzF0riWWNcz+uOJZnaRDpfSGf1fA4S09zrjzg8xHOubx+f8CAAD//yL/bv13ewAA + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEUCIQDeOYMo8cQISCUwEBrEKZCbzCJUFHk2X2NI9pbbkmDhNAIgWCOLounkEFiFE3bWKpjZ6Ka1x4CKd9hH9hwgHIDrF4g= + signature.kubescape.io/timestamp: "1790037740" + labels: + signature.kubescape.io/fragment-class: base + name: ct-base-rules +spec: + rules: + - agentVersionRequirement: "" + description: Detects unexpected process launches that are not in the baseline + enabled: true + expressions: + message: '''Unexpected process launched: '' + event.comm + '' with PID '' + + string(event.pid)' + ruleExpression: + - eventType: exec + expression: '!cp.was_executed(event.containerId, parse.get_exec_path(event.args, + event.comm, event.exepath))' + uniqueId: event.comm + '_' + event.exepath + id: R0001 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Unexpected process launched + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: all + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 0 + severity: 1 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - process + - exec + - applicationprofile + - agentVersionRequirement: "" + description: Detects unexpected file access that is not in the baseline + enabled: false + expressions: + message: '''Unexpected file access detected: '' + event.comm + '' with PID '' + + string(event.pid) + '' to '' + event.path' + ruleExpression: + - eventType: open + expression: | + (event.path.startsWith('/etc/') || event.path.startsWith('/var/log/') || event.path.startsWith('/var/run/') || event.path.startsWith('/run/') || event.path.startsWith('/var/spool/cron/') || event.path.startsWith('/var/www/') || event.path.startsWith('/var/lib/') || event.path.startsWith('/opt/') || event.path.startsWith('/usr/local/') || event.path.startsWith('/app/') || event.path == '/.dockerenv' || event.path == '/proc/self/environ') && !(event.path.startsWith('/run/secrets/kubernetes.io/serviceaccount') || + event.path.startsWith('/var/run/secrets/kubernetes.io/serviceaccount') || + event.path.startsWith('/tmp')) + && !cp.was_path_opened(event.containerId, event.path) + uniqueId: event.comm + '_' + event.path + id: R0002 + isTriggerAlert: false + mitreTactic: TA0009 + mitreTechnique: T1005 + name: Files Access Anomalies in container + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: + - prefix: /etc/ + - prefix: /var/log/ + - prefix: /var/run/ + - prefix: /run/ + - prefix: /var/spool/cron/ + - prefix: /var/www/ + - prefix: /var/lib/ + - prefix: /opt/ + - prefix: /usr/local/ + - prefix: /app/ + - exact: /.dockerenv + - exact: /proc/self/environ + syscalls: null + profileDependency: 0 + severity: 1 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - file + - open + - applicationprofile + - agentVersionRequirement: "" + description: Detects unexpected system calls that are not whitelisted by application + profile + enabled: true + expressions: + message: '''Unexpected system call detected: '' + event.syscallName + '' with + PID '' + string(event.pid)' + ruleExpression: + - eventType: syscall + expression: '!cp.was_syscall_used(event.containerId, event.syscallName)' + uniqueId: event.syscallName + id: R0003 + isTriggerAlert: false + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Syscalls Anomalies in container + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: all + profileDependency: 0 + severity: 1 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - syscall + - applicationprofile + - agentVersionRequirement: "" + description: Detects unexpected capabilities that are not whitelisted by application + profile + enabled: true + expressions: + message: '''Unexpected capability used: '' + event.capName + '' in syscall '' + + event.syscallName + '' with PID '' + string(event.pid)' + ruleExpression: + - eventType: capabilities + expression: '!cp.was_capability_used(event.containerId, event.capName)' + uniqueId: event.comm + '_' + event.capName + id: R0004 + isTriggerAlert: false + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Linux Capabilities Anomalies in container + profileDataRequired: + capabilities: all + egressAddresses: null + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 0 + severity: 1 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - capabilities + - applicationprofile + - agentVersionRequirement: "" + description: Detecting unexpected domain requests that are not whitelisted by + application profile. + enabled: true + expressions: + message: '''Unexpected domain communication: '' + event.name + '' from: '' + + event.containerName' + ruleExpression: + - eventType: dns + expression: '!event.name.endsWith(''.svc.cluster.local.'') && !cp.is_domain_in_egress(event.containerId, + event.name)' + uniqueId: event.comm + '_' + event.name + id: R0005 + isTriggerAlert: false + mitreTactic: TA0011 + mitreTechnique: T1071.004 + name: DNS Anomalies in container + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: all + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 0 + severity: 1 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - dns + - anomaly + - networkprofile + - agentVersionRequirement: "" + description: Detecting unexpected access to service account token. + enabled: true + expressions: + message: '''Unexpected access to service account token: '' + event.path + '' + with flags: '' + event.flags.join('','')' + ruleExpression: + - eventType: open + expression: "((event.path.startsWith('/run/secrets/kubernetes.io/serviceaccount') + && event.path.endsWith('/token')) || \n (event.path.startsWith('/var/run/secrets/kubernetes.io/serviceaccount') + && event.path.endsWith('/token')) ||\n (event.path.startsWith('/run/secrets/eks.amazonaws.com/serviceaccount') + && event.path.endsWith('/token')) ||\n (event.path.startsWith('/var/run/secrets/eks.amazonaws.com/serviceaccount') + && event.path.endsWith('/token'))) &&\n!cp.was_path_opened_with_suffix(event.containerId, + '/token')\n" + uniqueId: event.comm + id: R0006 + isTriggerAlert: true + mitreTactic: TA0006 + mitreTechnique: T1528 + name: Unexpected service account token access + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: + - suffix: /token + syscalls: null + profileDependency: 0 + severity: 5 + state: + includePrefixes: + - /run/secrets + - /var/run/secrets + supportPolicy: false + tags: + - context:kubernetes + - anomaly + - serviceaccount + - applicationprofile + - agentVersionRequirement: "" + description: Detecting execution of kubernetes client + enabled: true + expressions: + message: 'eventType == ''exec'' ? ''Kubernetes client ('' + event.comm + '') + was executed with PID '' + string(event.pid) : ''Network connection to Kubernetes + API server from container '' + event.containerName' + ruleExpression: + - eventType: exec + expression: (event.comm == 'kubectl' || event.exepath.endsWith('/kubectl')) + && !cp.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, + event.exepath)) + - eventType: network + expression: event.pktType == 'OUTGOING' && k8s.is_api_server_address(event.dstAddr, + event.dstPort) && !cp.was_address_in_egress(event.containerId, event.dstAddr) + uniqueId: 'eventType == ''exec'' ? ''exec_'' + event.comm : ''network_'' + event.dstAddr' + id: R0007 + isTriggerAlert: false + mitreTactic: TA0008 + mitreTechnique: T1210 + name: Workload uses Kubernetes API unexpectedly + profileDataRequired: + capabilities: null + egressAddresses: all + egressDomains: null + endpoints: null + execs: all + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 0 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - exec + - network + - anomaly + - applicationprofile + - agentVersionRequirement: "" + description: Detecting reading environment variables from procfs. + enabled: true + expressions: + message: '''Reading environment variables from procfs: '' + event.path + '' + by process '' + event.comm' + ruleExpression: + - eventType: open + expression: | + event.path.startsWith('/proc/') && event.path.endsWith('/environ') && !cp.was_path_opened_with_suffix(event.containerId, '/environ') + uniqueId: event.comm + id: R0008 + isTriggerAlert: true + mitreTactic: TA0006 + mitreTechnique: T1552.001 + name: Read Environment Variables from procfs + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: + - suffix: /environ + syscalls: null + profileDependency: 0 + severity: 5 + state: + includePrefixes: + - /proc + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - procfs + - environment + - applicationprofile + - agentVersionRequirement: "" + description: Detecting eBPF program load. + enabled: true + expressions: + message: '''bpf program load system call (bpf) was called by process ('' + event.comm + + '') with command (BPF_PROG_LOAD)''' + ruleExpression: + - eventType: bpf + expression: event.cmd == uint(5) && !cp.was_syscall_used(event.containerId, + 'bpf') + uniqueId: event.comm + '_' + 'bpf' + '_' + string(event.cmd) + id: R0009 + isTriggerAlert: true + mitreTactic: TA0005 + mitreTechnique: T1218 + name: eBPF Program Load + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: + - exact: bpf + profileDependency: 1 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - bpf + - ebpf + - applicationprofile + - agentVersionRequirement: "" + description: Detecting access to sensitive files. + enabled: true + expressions: + message: '''Unexpected sensitive file access: '' + event.path + '' by process + '' + event.comm' + ruleExpression: + - eventType: open + expression: event.path.startsWith('/etc/shadow') && !cp.was_path_opened(event.containerId, + event.path) + uniqueId: event.comm + '_' + event.path + id: R0010 + isTriggerAlert: true + mitreTactic: TA0006 + mitreTechnique: T1005 + name: Unexpected Sensitive File Access + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: + - prefix: /etc/shadow + syscalls: null + profileDependency: 1 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - context:host + - files + - anomaly + - applicationprofile + - agentVersionRequirement: "" + description: Detecting unexpected egress network traffic that is not whitelisted + by application profile. + enabled: true + expressions: + message: '''Unexpected egress network communication to: '' + event.dstAddr + + '':'' + string(event.dstPort) + '' using '' + event.proto + '' from: '' + + event.containerName' + ruleExpression: + - eventType: network + expression: event.pktType == 'OUTGOING' && !net.is_private_ip(event.dstAddr) + && !cp.was_address_in_egress(event.containerId, event.dstAddr) + uniqueId: event.dstAddr + '_' + string(event.dstPort) + '_' + event.proto + id: R0011 + isTriggerAlert: false + mitreTactic: TA0010 + mitreTechnique: T1041 + name: Unexpected Egress Network Traffic + profileDataRequired: + capabilities: null + egressAddresses: all + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 0 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - whitelisted + - network + - anomaly + - networkprofile + - agentVersionRequirement: "" + description: Detects an exec event whose path IS in the application profile but + whose argv vector does not match any recorded argv pattern for that path. Consumes + cp.was_executed_with_args, which walks the ExecsByPath projection surface and + delegates argv comparison to dynamicpathdetector.MatchExecArgs (storage). Stays + silent when the path is unknown (R0001 covers that case) and when the argv vector + matches any recorded pattern (including the trailing zero-or-more form and the + single-arg form); a '*' in a recorded arg is a literal character, not a wildcard. + enabled: true + expressions: + message: '''Unexpected process arguments: '' + event.comm + '' with PID '' + + string(event.pid) + '' argv='' + event.args.map(a, string(a)).join('' '')' + ruleExpression: + - eventType: exec + expression: cp.was_executed(event.containerId, parse.get_exec_path(event.args, + event.comm, event.exepath)) && !cp.was_executed_with_args(event.containerId, + parse.get_exec_path(event.args, event.comm, event.exepath), event.args) + uniqueId: event.comm + '_' + event.exepath + '_' + event.args.map(a, string(a)).join(' + ') + id: R0040 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Unexpected process arguments + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: all + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 0 + severity: 3 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - process + - exec + - applicationprofile + - agentVersionRequirement: "" + description: Detecting exec calls whose executable path or working directory is + under /dev/shm, a world-writable memory-backed (tmpfs) directory. + enabled: true + expressions: + message: '''Process executed from /dev/shm: '' + event.exepath + '' in directory + '' + event.cwd' + ruleExpression: + - eventType: exec + expression: | + (event.exepath == '/dev/shm' || event.exepath.startsWith('/dev/shm/')) || (event.cwd == '/dev/shm' || event.cwd.startsWith('/dev/shm/')) || (event.args.size() > 0 && (event.args[0] == '/dev/shm' || event.args[0].startsWith('/dev/shm/'))) + uniqueId: event.comm + '_' + event.exepath + '_' + event.pcomm + id: R1000 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Process Executed from /dev/shm + profileDependency: 2 + severity: 8 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - exec + - signature + - malicious + - agentVersionRequirement: "" + description: Detecting exec calls of binaries that are not included in the base + image + enabled: true + expressions: + message: '''Process ('' + event.comm + '') was executed and is not part of the + image''' + ruleExpression: + - eventType: exec + expression: | + (event.upperlayer == true || + event.pupperlayer == true) && + !cp.was_executed(event.containerId, (event.exepath != "" ? event.exepath : parse.get_exec_path(event.args, event.comm))) + uniqueId: event.comm + '_' + event.exepath + '_' + event.pcomm + id: R1001 + isTriggerAlert: true + mitreTactic: TA0005 + mitreTechnique: T1036 + name: Drifted process executed + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: all + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 1 + severity: 8 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - exec + - malicious + - binary + - base image + - applicationprofile + - agentVersionRequirement: "" + description: Detecting Kernel Module Load. + enabled: true + expressions: + message: '''Kernel module ('' + event.module + '') loading attempt with syscall + ('' + event.syscallName + '') was called by process ('' + event.comm + '')''' + ruleExpression: + - eventType: kmod + expression: event.syscallName == 'init_module' || event.syscallName == 'finit_module' + uniqueId: event.comm + '_' + event.syscallName + '_' + event.module + id: R1002 + isTriggerAlert: true + mitreTactic: TA0005 + mitreTechnique: T1547.006 + name: Process tries to load a kernel module + profileDependency: 2 + severity: 10 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - kmod + - kernel + - module + - load + - agentVersionRequirement: "" + description: Detecting an SSH connection to a non-standard port where the destination + address is not in the container's learned egress baseline. + enabled: false + expressions: + message: '''SSH connection to unexpected destination on non-standard port: '' + + event.dstIp + '':'' + string(dyn(event.dstPort))' + ruleExpression: + - eventType: ssh + expression: dyn(event.srcPort) >= 32768 && dyn(event.srcPort) <= 60999 && + !(dyn(event.dstPort) in [22, 2022]) && !cp.was_address_in_egress(event.containerId, + event.dstIp) + uniqueId: event.comm + '_' + event.dstIp + '_' + string(dyn(event.dstPort)) + id: R1003 + isTriggerAlert: true + mitreTactic: TA0008 + mitreTechnique: T1021.001 + name: SSH Connection to Unexpected Destination on Non-Standard Port + profileDataRequired: + capabilities: null + egressAddresses: all + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 1 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - ssh + - connection + - port + - malicious + - networkprofile + - agentVersionRequirement: "" + description: Detecting exec calls from mounted paths. + enabled: true + expressions: + message: '''Process ('' + event.comm + '') was executed from a mounted path''' + ruleExpression: + - eventType: exec + expression: '!cp.was_executed(event.containerId, parse.get_exec_path(event.args, + event.comm, event.exepath)) && k8s.get_container_mount_paths(event.namespace, + event.podName, event.containerName).exists(mount, event.exepath.startsWith(mount) + || parse.get_exec_path(event.args, event.comm, event.exepath).startsWith(mount))' + uniqueId: event.comm + id: R1004 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Process executed from mount + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: all + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 1 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - exec + - mount + - applicationprofile + - agentVersionRequirement: "" + description: Detecting Fileless Execution + enabled: true + expressions: + message: '''Fileless execution detected: exec call "'' + event.comm + ''" runs + from a memory-backed source (memfd / /proc/self/fd)''' + ruleExpression: + - eventType: exec + expression: event.exepath.contains('memfd') || event.exepath.startsWith('/proc/self/fd') + || event.exepath.matches('/proc/[0-9]+/fd/[0-9]+') + uniqueId: event.comm + '_' + event.exepath + '_' + event.pcomm + id: R1005 + isTriggerAlert: true + mitreTactic: TA0005 + mitreTechnique: T1055 + name: Fileless execution detected + profileDependency: 2 + severity: 8 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - fileless + - execution + - malicious + - agentVersionRequirement: "" + description: Detecting use of the unshare system call (a namespace-manipulation + capability that can be used to escape a container) by a non-runc process, where + it was not seen in the container's application-profile baseline. + enabled: true + expressions: + message: '''Unshare system call (unshare) was called by process ('' + event.comm + + '')''' + ruleExpression: + - eventType: unshare + expression: event.pcomm != 'runc' && !cp.was_syscall_used(event.containerId, + 'unshare') + uniqueId: event.comm + '_' + 'unshare' + id: R1006 + isTriggerAlert: true + mitreTactic: TA0004 + mitreTechnique: T1611 + name: Unexpected unshare Syscall in Container + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: + - exact: unshare + profileDependency: 1 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - unshare + - escape + - unshare + - anomaly + - applicationprofile + - agentVersionRequirement: "" + description: Detecting XMR Crypto Miners by randomx algorithm usage. + enabled: true + expressions: + message: '''XMR Crypto Miner process: ('' + event.exepath + '') executed''' + ruleExpression: + - eventType: randomx + expression: "true" + uniqueId: event.exepath + '_' + event.comm + id: R1007 + isTriggerAlert: true + mitreTactic: TA0040 + mitreTechnique: T1496 + name: Crypto miner launched + profileDependency: 2 + severity: 10 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - crypto + - miners + - malicious + - agentVersionRequirement: "" + description: Detecting Crypto miners communication by domain + enabled: true + expressions: + message: '''Communication with a known crypto mining domain: '' + event.name' + ruleExpression: + - eventType: dns + expression: event.name in ['2cryptocalc.com.', '2miners.com.', 'antpool.com.', + 'asia1.ethpool.org.', 'bohemianpool.com.', 'botbox.dev.', 'btm.antpool.com.', + 'c3pool.com.', 'c4pool.org.', 'ca.minexmr.com.', 'cn.stratum.slushpool.com.', + 'dash.antpool.com.', 'data.miningpoolstats.stream.', 'de.minexmr.com.', + 'eth-ar.dwarfpool.com.', 'eth-asia.dwarfpool.com.', 'eth-asia1.nanopool.org.', + 'eth-au.dwarfpool.com.', 'eth-au1.nanopool.org.', 'eth-br.dwarfpool.com.', + 'eth-cn.dwarfpool.com.', 'eth-cn2.dwarfpool.com.', 'eth-eu.dwarfpool.com.', + 'eth-eu1.nanopool.org.', 'eth-eu2.nanopool.org.', 'eth-hk.dwarfpool.com.', + 'eth-jp1.nanopool.org.', 'eth-ru.dwarfpool.com.', 'eth-ru2.dwarfpool.com.', + 'eth-sg.dwarfpool.com.', 'eth-us-east1.nanopool.org.', 'eth-us-west1.nanopool.org.', + 'eth-us.dwarfpool.com.', 'eth-us2.dwarfpool.com.', 'eth.antpool.com.', 'eu.stratum.slushpool.com.', + 'eu1.ethermine.org.', 'eu1.ethpool.org.', 'fastpool.xyz.', 'fr.minexmr.com.', + 'kriptokyng.com.', 'mine.moneropool.com.', 'mine.xmrpool.net.', 'miningmadness.com.', + 'monero.cedric-crispin.com.', 'monero.crypto-pool.fr.', 'monero.fairhash.org.', + 'monero.hashvault.pro.', 'monero.herominers.com.', 'monerod.org.', 'monerohash.com.', + 'moneroocean.stream.', 'monerop.com.', 'multi-pools.com.', 'p2pool.io.', + 'pool.kryptex.com.', 'pool.minexmr.com.', 'pool.monero.hashvault.pro.', + 'pool.rplant.xyz.', 'pool.supportxmr.com.', 'pool.xmr.pt.', 'prohashing.com.', + 'rx.unmineable.com.', 'sg.minexmr.com.', 'sg.stratum.slushpool.com.', 'skypool.org.', + 'solo-xmr.2miners.com.', 'ss.antpool.com.', 'stratum-btm.antpool.com.', + 'stratum-dash.antpool.com.', 'stratum-eth.antpool.com.', 'stratum-ltc.antpool.com.', + 'stratum-xmc.antpool.com.', 'stratum-zec.antpool.com.', 'stratum.antpool.com.', + 'supportxmr.com.', 'trustpool.cc.', 'us-east.stratum.slushpool.com.', 'us1.ethermine.org.', + 'us1.ethpool.org.', 'us2.ethermine.org.', 'us2.ethpool.org.', 'web.xmrpool.eu.', + 'www.domajorpool.com.', 'www.dxpool.com.', 'www.mining-dutch.nl.', 'xmc.antpool.com.', + 'xmr-asia1.nanopool.org.', 'xmr-au1.nanopool.org.', 'xmr-eu1.nanopool.org.', + 'xmr-eu2.nanopool.org.', 'xmr-jp1.nanopool.org.', 'xmr-us-east1.nanopool.org.', + 'xmr-us-west1.nanopool.org.', 'xmr.2miners.com.', 'xmr.crypto-pool.fr.', + 'xmr.gntl.uk.', 'xmr.nanopool.org.', 'xmr.pool-pay.com.', 'xmr.pool.minergate.com.', + 'xmr.solopool.org.', 'xmr.volt-mine.com.', 'xmr.zeropool.io.', 'zec.antpool.com.', + 'zergpool.com.', 'auto.c3pool.org.', 'us.monero.herominers.com.', 'xmr.kryptex.network.'] + uniqueId: event.name + '_' + event.comm + id: R1008 + isTriggerAlert: true + mitreTactic: TA0011 + mitreTechnique: T1071.004 + name: Crypto Mining Domain Communication + profileDependency: 2 + severity: 10 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - network + - crypto + - miners + - malicious + - dns + - agentVersionRequirement: "" + description: Detecting Crypto Miners by suspicious port usage. + enabled: true + expressions: + message: '''Detected crypto mining related port communication on port '' + string(event.dstPort) + + '' to '' + event.dstAddr + '' with protocol '' + event.proto' + ruleExpression: + - eventType: network + expression: event.proto == 'TCP' && event.pktType == 'OUTGOING' && event.dstPort + in [3333, 45700] && !cp.was_address_in_egress(event.containerId, event.dstAddr) + uniqueId: event.comm + '_' + string(event.dstPort) + id: R1009 + isTriggerAlert: false + mitreTactic: TA0011 + mitreTechnique: T1071 + name: Crypto Mining Related Port Communication + profileDataRequired: + capabilities: null + egressAddresses: all + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 1 + severity: 3 + state: + ports: + - 3333 + - 45700 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - network + - crypto + - miners + - malicious + - networkprofile + - agentVersionRequirement: "" + description: Detects symlink creation over sensitive files + enabled: true + expressions: + message: '''Symlink created over sensitive file: '' + event.oldPath + '' -> + '' + event.newPath' + ruleExpression: + - eventType: symlink + expression: (event.oldPath.startsWith('/etc/shadow') || event.oldPath.startsWith('/etc/sudoers')) + && !cp.was_path_opened(event.containerId, event.oldPath) + uniqueId: event.comm + '_' + event.oldPath + id: R1010 + isTriggerAlert: true + mitreTactic: TA0006 + mitreTechnique: T1005 + name: Soft link created over sensitive file + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: + - prefix: /etc/shadow + - prefix: /etc/sudoers + syscalls: null + profileDependency: 1 + severity: 5 + supportPolicy: true + tags: + - context:kubernetes + - context:host + - anomaly + - symlink + - applicationprofile + - agentVersionRequirement: "" + description: Detecting use of the LD_PRELOAD/LD_LIBRARY_PATH dynamic-linker hook + mechanism, or an unexpected write to /etc/ld.so.preload relative to the container's + application-profile baseline. + enabled: false + expressions: + message: 'eventType == ''exec'' ? ''Process ('' + event.comm + '') is using + a dynamic linker hook: '' + process.get_ld_hook_var(event.pid) : ''The dynamic + linker configuration file ('' + event.path + '') was modified by process ('' + + event.comm + '')''' + ruleExpression: + - eventType: exec + expression: event.comm != 'java' && event.containerName != 'matlab' && process.get_ld_hook_var(event.pid) + != '' + - eventType: open + expression: event.path == '/etc/ld.so.preload' && has(event.flagsRaw) && event.flagsRaw + != 0 + uniqueId: 'eventType == ''exec'' ? ''exec_'' + event.comm : ''open_'' + event.path' + id: R1011 + isTriggerAlert: true + mitreTactic: TA0005 + mitreTechnique: T1574.006 + name: ld_preload Mechanism Use or ld.so.preload Modification + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: + - exact: /etc/ld.so.preload + syscalls: null + profileDependency: 1 + severity: 5 + supportPolicy: true + tags: + - context:kubernetes + - exec + - malicious + - applicationprofile + - agentVersionRequirement: "" + description: Detecting hardlink creation over sensitive files. + enabled: true + expressions: + message: '''Hardlink created over sensitive file: '' + event.oldPath + '' - + '' + event.newPath' + ruleExpression: + - eventType: hardlink + expression: (event.oldPath.startsWith('/etc/shadow') || event.oldPath.startsWith('/etc/sudoers')) + && !cp.was_path_opened(event.containerId, event.oldPath) + uniqueId: event.comm + '_' + event.oldPath + id: R1012 + isTriggerAlert: true + mitreTactic: TA0006 + mitreTechnique: T1005 + name: Hard link created over sensitive file + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: + - prefix: /etc/shadow + - prefix: /etc/sudoers + syscalls: null + profileDependency: 1 + severity: 5 + supportPolicy: true + tags: + - context:kubernetes + - files + - malicious + - applicationprofile + - agentVersionRequirement: "" + description: Detecting use of the ptrace syscall that was not seen in the container's + application-profile baseline. + enabled: true + expressions: + message: '''Unexpected ptrace syscall usage from: '' + event.comm' + ruleExpression: + - eventType: ptrace + expression: "true" + uniqueId: event.exepath + '_' + event.comm + id: R1015 + isTriggerAlert: true + mitreTactic: TA0005 + mitreTechnique: T1622 + name: Unexpected Ptrace Syscall Usage + profileDependency: 2 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - process + - malicious + - agentVersionRequirement: "" + description: Detects io_uring operations that were not recorded during the initial + observation period, indicating potential unauthorized activity. + enabled: true + expressions: + message: '''Unexpected io_uring operation detected: (opcode='' + string(event.opcode) + + '') flags=0x'' + (has(event.flagsRaw) ? string(event.flagsRaw) : ''0'') + + '' in '' + event.comm + ''.''' + ruleExpression: + - eventType: iouring + expression: "true" + uniqueId: string(event.opcode) + '_' + event.comm + id: R1030 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1218 + name: Unexpected io_uring Operation Detected + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: all + profileDependency: 0 + severity: 5 + supportPolicy: true + tags: + - context:kubernetes + - context:container + - syscalls + - io_uring + - applicationprofile + - agentVersionRequirement: "" + description: Detects exec operations on pods via the Kubernetes admission webhook + (PodExecOptions CONNECT) + enabled: true + expressions: + message: '''Exec to pod: '' + event.Name + '' in namespace '' + event.Namespace + + '' by '' + event.UserInfo.Username' + ruleExpression: + - eventType: k8s-admission + expression: event.Kind == "PodExecOptions" + uniqueId: event.Namespace + '/' + event.Name + id: R2000 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1609 + name: Exec to pod + profileDependency: 2 + severity: 8 + supportPolicy: false + tags: + - context:kubernetes + - admission + - exec + - agentVersionRequirement: "" + description: Detects port-forward operations on pods via the Kubernetes admission + webhook (PodPortForwardOptions CONNECT) + enabled: true + expressions: + message: '''Port forward to pod: '' + event.Name + '' in namespace '' + event.Namespace + + '' by '' + event.UserInfo.Username' + ruleExpression: + - eventType: k8s-admission + expression: event.Kind == "PodPortForwardOptions" + uniqueId: event.Namespace + '/' + event.Name + id: R2001 + isTriggerAlert: true + mitreTactic: TA0011 + mitreTechnique: T1090 + name: Port forward to pod + profileDependency: 2 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - admission + - network diff --git a/tests/resources/signed/rules/cluster-baseline.yaml b/tests/resources/signed/rules/cluster-baseline.yaml new file mode 100644 index 0000000000..6655bc370b --- /dev/null +++ b/tests/resources/signed/rules/cluster-baseline.yaml @@ -0,0 +1,815 @@ +apiVersion: kubescape.io/v1 +kind: Rules +metadata: + labels: + signature.kubescape.io/fragment-class: base + name: ct-base-rules +spec: + rules: + - description: Detects unexpected process launches that are not in the baseline + enabled: true + expressions: + message: '''Unexpected process launched: '' + event.comm + '' with PID '' + string(event.pid)' + ruleExpression: + - eventType: exec + expression: '!cp.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))' + uniqueId: event.comm + '_' + event.exepath + id: R0001 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Unexpected process launched + profileDataRequired: + execs: all + profileDependency: 0 + severity: 1 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - process + - exec + - applicationprofile + - description: Detects unexpected file access that is not in the baseline + enabled: false + expressions: + message: '''Unexpected file access detected: '' + event.comm + '' with PID '' + string(event.pid) + + '' to '' + event.path' + ruleExpression: + - eventType: open + expression: "(event.path.startsWith('/etc/') || event.path.startsWith('/var/log/') || event.path.startsWith('/var/run/')\ + \ || event.path.startsWith('/run/') || event.path.startsWith('/var/spool/cron/') || event.path.startsWith('/var/www/')\ + \ || event.path.startsWith('/var/lib/') || event.path.startsWith('/opt/') || event.path.startsWith('/usr/local/')\ + \ || event.path.startsWith('/app/') || event.path == '/.dockerenv' || event.path == '/proc/self/environ')\ + \ && !(event.path.startsWith('/run/secrets/kubernetes.io/serviceaccount') ||\n event.path.startsWith('/var/run/secrets/kubernetes.io/serviceaccount')\ + \ ||\n event.path.startsWith('/tmp'))\n&& !cp.was_path_opened(event.containerId, event.path)\n" + uniqueId: event.comm + '_' + event.path + id: R0002 + isTriggerAlert: false + mitreTactic: TA0009 + mitreTechnique: T1005 + name: Files Access Anomalies in container + profileDataRequired: + opens: + - prefix: /etc/ + - prefix: /var/log/ + - prefix: /var/run/ + - prefix: /run/ + - prefix: /var/spool/cron/ + - prefix: /var/www/ + - prefix: /var/lib/ + - prefix: /opt/ + - prefix: /usr/local/ + - prefix: /app/ + - exact: /.dockerenv + - exact: /proc/self/environ + profileDependency: 0 + severity: 1 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - file + - open + - applicationprofile + - description: Detects unexpected system calls that are not whitelisted by application profile + enabled: true + expressions: + message: '''Unexpected system call detected: '' + event.syscallName + '' with PID '' + string(event.pid)' + ruleExpression: + - eventType: syscall + expression: '!cp.was_syscall_used(event.containerId, event.syscallName)' + uniqueId: event.syscallName + id: R0003 + isTriggerAlert: false + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Syscalls Anomalies in container + profileDataRequired: + syscalls: all + profileDependency: 0 + severity: 1 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - syscall + - applicationprofile + - description: Detects unexpected capabilities that are not whitelisted by application profile + enabled: true + expressions: + message: '''Unexpected capability used: '' + event.capName + '' in syscall '' + event.syscallName + + '' with PID '' + string(event.pid)' + ruleExpression: + - eventType: capabilities + expression: '!cp.was_capability_used(event.containerId, event.capName)' + uniqueId: event.comm + '_' + event.capName + id: R0004 + isTriggerAlert: false + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Linux Capabilities Anomalies in container + profileDataRequired: + capabilities: all + profileDependency: 0 + severity: 1 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - capabilities + - applicationprofile + - description: Detecting unexpected domain requests that are not whitelisted by application profile. + enabled: true + expressions: + message: '''Unexpected domain communication: '' + event.name + '' from: '' + event.containerName' + ruleExpression: + - eventType: dns + expression: '!event.name.endsWith(''.svc.cluster.local.'') && !cp.is_domain_in_egress(event.containerId, + event.name)' + uniqueId: event.comm + '_' + event.name + id: R0005 + isTriggerAlert: false + mitreTactic: TA0011 + mitreTechnique: T1071.004 + name: DNS Anomalies in container + profileDataRequired: + egressDomains: all + profileDependency: 0 + severity: 1 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - dns + - anomaly + - networkprofile + - description: Detecting unexpected access to service account token. + enabled: true + expressions: + message: '''Unexpected access to service account token: '' + event.path + '' with flags: '' + event.flags.join('','')' + ruleExpression: + - eventType: open + expression: "((event.path.startsWith('/run/secrets/kubernetes.io/serviceaccount') && event.path.endsWith('/token'))\ + \ || \n (event.path.startsWith('/var/run/secrets/kubernetes.io/serviceaccount') && event.path.endsWith('/token'))\ + \ ||\n (event.path.startsWith('/run/secrets/eks.amazonaws.com/serviceaccount') && event.path.endsWith('/token'))\ + \ ||\n (event.path.startsWith('/var/run/secrets/eks.amazonaws.com/serviceaccount') && event.path.endsWith('/token')))\ + \ &&\n!cp.was_path_opened_with_suffix(event.containerId, '/token')\n" + uniqueId: event.comm + id: R0006 + isTriggerAlert: true + mitreTactic: TA0006 + mitreTechnique: T1528 + name: Unexpected service account token access + profileDataRequired: + opens: + - suffix: /token + profileDependency: 0 + severity: 5 + state: + includePrefixes: + - /run/secrets + - /var/run/secrets + supportPolicy: false + tags: + - context:kubernetes + - anomaly + - serviceaccount + - applicationprofile + - description: Detecting execution of kubernetes client + enabled: true + expressions: + message: 'eventType == ''exec'' ? ''Kubernetes client ('' + event.comm + '') was executed with PID + '' + string(event.pid) : ''Network connection to Kubernetes API server from container '' + event.containerName' + ruleExpression: + - eventType: exec + expression: '(event.comm == ''kubectl'' || event.exepath.endsWith(''/kubectl'')) && !cp.was_executed(event.containerId, + parse.get_exec_path(event.args, event.comm, event.exepath))' + - eventType: network + expression: event.pktType == 'OUTGOING' && k8s.is_api_server_address(event.dstAddr, event.dstPort) && !cp.was_address_in_egress(event.containerId, + event.dstAddr) + uniqueId: 'eventType == ''exec'' ? ''exec_'' + event.comm : ''network_'' + event.dstAddr' + id: R0007 + isTriggerAlert: false + mitreTactic: TA0008 + mitreTechnique: T1210 + name: Workload uses Kubernetes API unexpectedly + profileDataRequired: + egressAddresses: all + execs: all + profileDependency: 0 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - exec + - network + - anomaly + - applicationprofile + - description: Detecting reading environment variables from procfs. + enabled: true + expressions: + message: '''Reading environment variables from procfs: '' + event.path + '' by process '' + event.comm' + ruleExpression: + - eventType: open + expression: 'event.path.startsWith(''/proc/'') && event.path.endsWith(''/environ'') && !cp.was_path_opened_with_suffix(event.containerId, + ''/environ'') + + ' + uniqueId: event.comm + id: R0008 + isTriggerAlert: true + mitreTactic: TA0006 + mitreTechnique: T1552.001 + name: Read Environment Variables from procfs + profileDataRequired: + opens: + - suffix: /environ + profileDependency: 0 + severity: 5 + state: + includePrefixes: + - /proc + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - procfs + - environment + - applicationprofile + - description: Detecting eBPF program load. + enabled: true + expressions: + message: '''bpf program load system call (bpf) was called by process ('' + event.comm + '') with + command (BPF_PROG_LOAD)''' + ruleExpression: + - eventType: bpf + expression: event.cmd == uint(5) && !cp.was_syscall_used(event.containerId, 'bpf') + uniqueId: event.comm + '_' + 'bpf' + '_' + string(event.cmd) + id: R0009 + isTriggerAlert: true + mitreTactic: TA0005 + mitreTechnique: T1218 + name: eBPF Program Load + profileDataRequired: + syscalls: + - exact: bpf + profileDependency: 1 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - bpf + - ebpf + - applicationprofile + - description: Detecting access to sensitive files. + enabled: true + expressions: + message: '''Unexpected sensitive file access: '' + event.path + '' by process '' + event.comm' + ruleExpression: + - eventType: open + expression: event.path.startsWith('/etc/shadow') && !cp.was_path_opened(event.containerId, event.path) + uniqueId: event.comm + '_' + event.path + id: R0010 + isTriggerAlert: true + mitreTactic: TA0006 + mitreTechnique: T1005 + name: Unexpected Sensitive File Access + profileDataRequired: + opens: + - prefix: /etc/shadow + profileDependency: 1 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - context:host + - files + - anomaly + - applicationprofile + - description: Detecting unexpected egress network traffic that is not whitelisted by application profile. + enabled: true + expressions: + message: '''Unexpected egress network communication to: '' + event.dstAddr + '':'' + string(event.dstPort) + + '' using '' + event.proto + '' from: '' + event.containerName' + ruleExpression: + - eventType: network + expression: event.pktType == 'OUTGOING' && !net.is_private_ip(event.dstAddr) && !cp.was_address_in_egress(event.containerId, + event.dstAddr) + uniqueId: event.dstAddr + '_' + string(event.dstPort) + '_' + event.proto + id: R0011 + isTriggerAlert: false + mitreTactic: TA0010 + mitreTechnique: T1041 + name: Unexpected Egress Network Traffic + profileDataRequired: + egressAddresses: all + profileDependency: 0 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - whitelisted + - network + - anomaly + - networkprofile + - description: Detects an exec event whose path IS in the application profile but whose argv vector + does not match any recorded argv pattern for that path. Consumes cp.was_executed_with_args, which + walks the ExecsByPath projection surface and delegates argv comparison to dynamicpathdetector.MatchExecArgs + (storage). Stays silent when the path is unknown (R0001 covers that case) and when the argv vector + matches any recorded pattern (including the trailing zero-or-more form and the single-arg form); + a '*' in a recorded arg is a literal character, not a wildcard. + enabled: true + expressions: + message: '''Unexpected process arguments: '' + event.comm + '' with PID '' + string(event.pid) + + '' argv='' + event.args.map(a, string(a)).join('' '')' + ruleExpression: + - eventType: exec + expression: cp.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) + && !cp.was_executed_with_args(event.containerId, parse.get_exec_path(event.args, event.comm, + event.exepath), event.args) + uniqueId: event.comm + '_' + event.exepath + '_' + event.args.map(a, string(a)).join(' ') + id: R0040 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Unexpected process arguments + profileDataRequired: + execs: all + profileDependency: 0 + severity: 3 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - process + - exec + - applicationprofile + - description: Detecting exec calls whose executable path or working directory is under /dev/shm, a + world-writable memory-backed (tmpfs) directory. + enabled: true + expressions: + message: '''Process executed from /dev/shm: '' + event.exepath + '' in directory '' + event.cwd' + ruleExpression: + - eventType: exec + expression: '(event.exepath == ''/dev/shm'' || event.exepath.startsWith(''/dev/shm/'')) || (event.cwd + == ''/dev/shm'' || event.cwd.startsWith(''/dev/shm/'')) || (event.args.size() > 0 && (event.args[0] + == ''/dev/shm'' || event.args[0].startsWith(''/dev/shm/''))) + + ' + uniqueId: event.comm + '_' + event.exepath + '_' + event.pcomm + id: R1000 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Process Executed from /dev/shm + profileDependency: 2 + severity: 8 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - exec + - signature + - malicious + - description: Detecting exec calls of binaries that are not included in the base image + enabled: true + expressions: + message: '''Process ('' + event.comm + '') was executed and is not part of the image''' + ruleExpression: + - eventType: exec + expression: "(event.upperlayer == true ||\n event.pupperlayer == true) &&\n!cp.was_executed(event.containerId,\ + \ (event.exepath != \"\" ? event.exepath : parse.get_exec_path(event.args, event.comm)))\n" + uniqueId: event.comm + '_' + event.exepath + '_' + event.pcomm + id: R1001 + isTriggerAlert: true + mitreTactic: TA0005 + mitreTechnique: T1036 + name: Drifted process executed + profileDataRequired: + execs: all + profileDependency: 1 + severity: 8 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - exec + - malicious + - binary + - base image + - applicationprofile + - description: Detecting Kernel Module Load. + enabled: true + expressions: + message: '''Kernel module ('' + event.module + '') loading attempt with syscall ('' + event.syscallName + + '') was called by process ('' + event.comm + '')''' + ruleExpression: + - eventType: kmod + expression: event.syscallName == 'init_module' || event.syscallName == 'finit_module' + uniqueId: event.comm + '_' + event.syscallName + '_' + event.module + id: R1002 + isTriggerAlert: true + mitreTactic: TA0005 + mitreTechnique: T1547.006 + name: Process tries to load a kernel module + profileDependency: 2 + severity: 10 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - kmod + - kernel + - module + - load + - description: Detecting an SSH connection to a non-standard port where the destination address is not + in the container's learned egress baseline. + enabled: false + expressions: + message: '''SSH connection to unexpected destination on non-standard port: '' + event.dstIp + '':'' + + string(dyn(event.dstPort))' + ruleExpression: + - eventType: ssh + expression: dyn(event.srcPort) >= 32768 && dyn(event.srcPort) <= 60999 && !(dyn(event.dstPort) + in [22, 2022]) && !cp.was_address_in_egress(event.containerId, event.dstIp) + uniqueId: event.comm + '_' + event.dstIp + '_' + string(dyn(event.dstPort)) + id: R1003 + isTriggerAlert: true + mitreTactic: TA0008 + mitreTechnique: T1021.001 + name: SSH Connection to Unexpected Destination on Non-Standard Port + profileDataRequired: + egressAddresses: all + profileDependency: 1 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - ssh + - connection + - port + - malicious + - networkprofile + - description: Detecting exec calls from mounted paths. + enabled: true + expressions: + message: '''Process ('' + event.comm + '') was executed from a mounted path''' + ruleExpression: + - eventType: exec + expression: '!cp.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) && k8s.get_container_mount_paths(event.namespace, event.podName, event.containerName).exists(mount, + event.exepath.startsWith(mount) || parse.get_exec_path(event.args, event.comm, event.exepath).startsWith(mount))' + uniqueId: event.comm + id: R1004 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Process executed from mount + profileDataRequired: + execs: all + profileDependency: 1 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - exec + - mount + - applicationprofile + - description: Detecting Fileless Execution + enabled: true + expressions: + message: '''Fileless execution detected: exec call "'' + event.comm + ''" runs from a memory-backed + source (memfd / /proc/self/fd)''' + ruleExpression: + - eventType: exec + expression: event.exepath.contains('memfd') || event.exepath.startsWith('/proc/self/fd') || event.exepath.matches('/proc/[0-9]+/fd/[0-9]+') + uniqueId: event.comm + '_' + event.exepath + '_' + event.pcomm + id: R1005 + isTriggerAlert: true + mitreTactic: TA0005 + mitreTechnique: T1055 + name: Fileless execution detected + profileDependency: 2 + severity: 8 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - fileless + - execution + - malicious + - description: Detecting use of the unshare system call (a namespace-manipulation capability that can + be used to escape a container) by a non-runc process, where it was not seen in the container's application-profile + baseline. + enabled: true + expressions: + message: '''Unshare system call (unshare) was called by process ('' + event.comm + '')''' + ruleExpression: + - eventType: unshare + expression: event.pcomm != 'runc' && !cp.was_syscall_used(event.containerId, 'unshare') + uniqueId: event.comm + '_' + 'unshare' + id: R1006 + isTriggerAlert: true + mitreTactic: TA0004 + mitreTechnique: T1611 + name: Unexpected unshare Syscall in Container + profileDataRequired: + syscalls: + - exact: unshare + profileDependency: 1 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - unshare + - escape + - unshare + - anomaly + - applicationprofile + - description: Detecting XMR Crypto Miners by randomx algorithm usage. + enabled: true + expressions: + message: '''XMR Crypto Miner process: ('' + event.exepath + '') executed''' + ruleExpression: + - eventType: randomx + expression: 'true' + uniqueId: event.exepath + '_' + event.comm + id: R1007 + isTriggerAlert: true + mitreTactic: TA0040 + mitreTechnique: T1496 + name: Crypto miner launched + profileDependency: 2 + severity: 10 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - crypto + - miners + - malicious + - description: Detecting Crypto miners communication by domain + enabled: true + expressions: + message: '''Communication with a known crypto mining domain: '' + event.name' + ruleExpression: + - eventType: dns + expression: event.name in ['2cryptocalc.com.', '2miners.com.', 'antpool.com.', 'asia1.ethpool.org.', + 'bohemianpool.com.', 'botbox.dev.', 'btm.antpool.com.', 'c3pool.com.', 'c4pool.org.', 'ca.minexmr.com.', + 'cn.stratum.slushpool.com.', 'dash.antpool.com.', 'data.miningpoolstats.stream.', 'de.minexmr.com.', + 'eth-ar.dwarfpool.com.', 'eth-asia.dwarfpool.com.', 'eth-asia1.nanopool.org.', 'eth-au.dwarfpool.com.', + 'eth-au1.nanopool.org.', 'eth-br.dwarfpool.com.', 'eth-cn.dwarfpool.com.', 'eth-cn2.dwarfpool.com.', + 'eth-eu.dwarfpool.com.', 'eth-eu1.nanopool.org.', 'eth-eu2.nanopool.org.', 'eth-hk.dwarfpool.com.', + 'eth-jp1.nanopool.org.', 'eth-ru.dwarfpool.com.', 'eth-ru2.dwarfpool.com.', 'eth-sg.dwarfpool.com.', + 'eth-us-east1.nanopool.org.', 'eth-us-west1.nanopool.org.', 'eth-us.dwarfpool.com.', 'eth-us2.dwarfpool.com.', + 'eth.antpool.com.', 'eu.stratum.slushpool.com.', 'eu1.ethermine.org.', 'eu1.ethpool.org.', 'fastpool.xyz.', + 'fr.minexmr.com.', 'kriptokyng.com.', 'mine.moneropool.com.', 'mine.xmrpool.net.', 'miningmadness.com.', + 'monero.cedric-crispin.com.', 'monero.crypto-pool.fr.', 'monero.fairhash.org.', 'monero.hashvault.pro.', + 'monero.herominers.com.', 'monerod.org.', 'monerohash.com.', 'moneroocean.stream.', 'monerop.com.', + 'multi-pools.com.', 'p2pool.io.', 'pool.kryptex.com.', 'pool.minexmr.com.', 'pool.monero.hashvault.pro.', + 'pool.rplant.xyz.', 'pool.supportxmr.com.', 'pool.xmr.pt.', 'prohashing.com.', 'rx.unmineable.com.', + 'sg.minexmr.com.', 'sg.stratum.slushpool.com.', 'skypool.org.', 'solo-xmr.2miners.com.', 'ss.antpool.com.', + 'stratum-btm.antpool.com.', 'stratum-dash.antpool.com.', 'stratum-eth.antpool.com.', 'stratum-ltc.antpool.com.', + 'stratum-xmc.antpool.com.', 'stratum-zec.antpool.com.', 'stratum.antpool.com.', 'supportxmr.com.', + 'trustpool.cc.', 'us-east.stratum.slushpool.com.', 'us1.ethermine.org.', 'us1.ethpool.org.', + 'us2.ethermine.org.', 'us2.ethpool.org.', 'web.xmrpool.eu.', 'www.domajorpool.com.', 'www.dxpool.com.', + 'www.mining-dutch.nl.', 'xmc.antpool.com.', 'xmr-asia1.nanopool.org.', 'xmr-au1.nanopool.org.', + 'xmr-eu1.nanopool.org.', 'xmr-eu2.nanopool.org.', 'xmr-jp1.nanopool.org.', 'xmr-us-east1.nanopool.org.', + 'xmr-us-west1.nanopool.org.', 'xmr.2miners.com.', 'xmr.crypto-pool.fr.', 'xmr.gntl.uk.', 'xmr.nanopool.org.', + 'xmr.pool-pay.com.', 'xmr.pool.minergate.com.', 'xmr.solopool.org.', 'xmr.volt-mine.com.', 'xmr.zeropool.io.', + 'zec.antpool.com.', 'zergpool.com.', 'auto.c3pool.org.', 'us.monero.herominers.com.', 'xmr.kryptex.network.'] + uniqueId: event.name + '_' + event.comm + id: R1008 + isTriggerAlert: true + mitreTactic: TA0011 + mitreTechnique: T1071.004 + name: Crypto Mining Domain Communication + profileDependency: 2 + severity: 10 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - network + - crypto + - miners + - malicious + - dns + - description: Detecting Crypto Miners by suspicious port usage. + enabled: true + expressions: + message: '''Detected crypto mining related port communication on port '' + string(event.dstPort) + + '' to '' + event.dstAddr + '' with protocol '' + event.proto' + ruleExpression: + - eventType: network + expression: event.proto == 'TCP' && event.pktType == 'OUTGOING' && event.dstPort in [3333, 45700] + && !cp.was_address_in_egress(event.containerId, event.dstAddr) + uniqueId: event.comm + '_' + string(event.dstPort) + id: R1009 + isTriggerAlert: false + mitreTactic: TA0011 + mitreTechnique: T1071 + name: Crypto Mining Related Port Communication + profileDataRequired: + egressAddresses: all + profileDependency: 1 + severity: 3 + state: + ports: + - 3333 + - 45700 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - network + - crypto + - miners + - malicious + - networkprofile + - description: Detects symlink creation over sensitive files + enabled: true + expressions: + message: '''Symlink created over sensitive file: '' + event.oldPath + '' -> '' + event.newPath' + ruleExpression: + - eventType: symlink + expression: (event.oldPath.startsWith('/etc/shadow') || event.oldPath.startsWith('/etc/sudoers')) + && !cp.was_path_opened(event.containerId, event.oldPath) + uniqueId: event.comm + '_' + event.oldPath + id: R1010 + isTriggerAlert: true + mitreTactic: TA0006 + mitreTechnique: T1005 + name: Soft link created over sensitive file + profileDataRequired: + opens: + - prefix: /etc/shadow + - prefix: /etc/sudoers + profileDependency: 1 + severity: 5 + supportPolicy: true + tags: + - context:kubernetes + - context:host + - anomaly + - symlink + - applicationprofile + - description: Detecting use of the LD_PRELOAD/LD_LIBRARY_PATH dynamic-linker hook mechanism, or an + unexpected write to /etc/ld.so.preload relative to the container's application-profile baseline. + enabled: false + expressions: + message: 'eventType == ''exec'' ? ''Process ('' + event.comm + '') is using a dynamic linker hook: + '' + process.get_ld_hook_var(event.pid) : ''The dynamic linker configuration file ('' + event.path + + '') was modified by process ('' + event.comm + '')''' + ruleExpression: + - eventType: exec + expression: event.comm != 'java' && event.containerName != 'matlab' && process.get_ld_hook_var(event.pid) + != '' + - eventType: open + expression: event.path == '/etc/ld.so.preload' && has(event.flagsRaw) && event.flagsRaw != 0 + uniqueId: 'eventType == ''exec'' ? ''exec_'' + event.comm : ''open_'' + event.path' + id: R1011 + isTriggerAlert: true + mitreTactic: TA0005 + mitreTechnique: T1574.006 + name: ld_preload Mechanism Use or ld.so.preload Modification + profileDataRequired: + opens: + - exact: /etc/ld.so.preload + profileDependency: 1 + severity: 5 + supportPolicy: true + tags: + - context:kubernetes + - exec + - malicious + - applicationprofile + - description: Detecting hardlink creation over sensitive files. + enabled: true + expressions: + message: '''Hardlink created over sensitive file: '' + event.oldPath + '' - '' + event.newPath' + ruleExpression: + - eventType: hardlink + expression: (event.oldPath.startsWith('/etc/shadow') || event.oldPath.startsWith('/etc/sudoers')) + && !cp.was_path_opened(event.containerId, event.oldPath) + uniqueId: event.comm + '_' + event.oldPath + id: R1012 + isTriggerAlert: true + mitreTactic: TA0006 + mitreTechnique: T1005 + name: Hard link created over sensitive file + profileDataRequired: + opens: + - prefix: /etc/shadow + - prefix: /etc/sudoers + profileDependency: 1 + severity: 5 + supportPolicy: true + tags: + - context:kubernetes + - files + - malicious + - applicationprofile + - description: Detecting use of the ptrace syscall that was not seen in the container's application-profile + baseline. + enabled: true + expressions: + message: '''Unexpected ptrace syscall usage from: '' + event.comm' + ruleExpression: + - eventType: ptrace + expression: 'true' + uniqueId: event.exepath + '_' + event.comm + id: R1015 + isTriggerAlert: true + mitreTactic: TA0005 + mitreTechnique: T1622 + name: Unexpected Ptrace Syscall Usage + profileDependency: 2 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - context:host + - process + - malicious + - description: Detects io_uring operations that were not recorded during the initial observation period, + indicating potential unauthorized activity. + enabled: true + expressions: + message: '''Unexpected io_uring operation detected: (opcode='' + string(event.opcode) + '') flags=0x'' + + (has(event.flagsRaw) ? string(event.flagsRaw) : ''0'') + '' in '' + event.comm + ''.''' + ruleExpression: + - eventType: iouring + expression: 'true' + uniqueId: string(event.opcode) + '_' + event.comm + id: R1030 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1218 + name: Unexpected io_uring Operation Detected + profileDataRequired: + syscalls: all + profileDependency: 0 + severity: 5 + supportPolicy: true + tags: + - context:kubernetes + - context:container + - syscalls + - io_uring + - applicationprofile + - description: Detects exec operations on pods via the Kubernetes admission webhook (PodExecOptions + CONNECT) + enabled: true + expressions: + message: '''Exec to pod: '' + event.Name + '' in namespace '' + event.Namespace + '' by '' + event.UserInfo.Username' + ruleExpression: + - eventType: k8s-admission + expression: event.Kind == "PodExecOptions" + uniqueId: event.Namespace + '/' + event.Name + id: R2000 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1609 + name: Exec to pod + profileDependency: 2 + severity: 8 + supportPolicy: false + tags: + - context:kubernetes + - admission + - exec + - description: Detects port-forward operations on pods via the Kubernetes admission webhook (PodPortForwardOptions + CONNECT) + enabled: true + expressions: + message: '''Port forward to pod: '' + event.Name + '' in namespace '' + event.Namespace + '' by + '' + event.UserInfo.Username' + ruleExpression: + - eventType: k8s-admission + expression: event.Kind == "PodPortForwardOptions" + uniqueId: event.Namespace + '/' + event.Name + id: R2001 + isTriggerAlert: true + mitreTactic: TA0011 + mitreTechnique: T1090 + name: Port forward to pod + profileDependency: 2 + severity: 5 + supportPolicy: false + tags: + - context:kubernetes + - admission + - network diff --git a/tests/resources/signed/rules/mixed-overlay-rejected-signed.yaml b/tests/resources/signed/rules/mixed-overlay-rejected-signed.yaml new file mode 100644 index 0000000000..7340cc2e25 --- /dev/null +++ b/tests/resources/signed/rules/mixed-overlay-rejected-signed.yaml @@ -0,0 +1,85 @@ +apiVersion: kubescape.io/v1 +kind: Rules +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJYVENDQVFLZ0F3SUJBZ0lSQU8yeTliYUFZRHFWOXdyYXo5TDZDcnN3Q2dZSUtvWkl6ajBFQXdJd0ZERVMKTUJBR0ExVUVBeE1KYkc5allXd3RhMlY1TUI0WERUSTJNRGt5TURFNE5EWXpPRm9YRFRJM01Ea3lNREU0TkRZegpPRm93RkRFU01CQUdBMVVFQXhNSmJHOWpZV3d0YTJWNU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEClFnQUUzR0lRd0tCK1I0RmJTU0liQXYwMUdKcHJNekxwa1RSUVRhellEYWc0YWlFcDQ3bDhmQ2FqUkIwTTUzWE0KRkpkK0xIdzE1WHpjTG5Sc1dDckd1Rlh4WEtNMU1ETXdEZ1lEVlIwUEFRSC9CQVFEQWdlQU1CTUdBMVVkSlFRTQpNQW9HQ0NzR0FRVUZCd01ETUF3R0ExVWRFd0VCL3dRQ01BQXdDZ1lJS29aSXpqMEVBd0lEU1FBd1JnSWhBTDJsCnJxdlhPdjVwdEJmRHVOaHkxdm9QTFIxdHMyajdhcVgrRjk5aFVDbUhBaUVBd3BCMkhrcEZPUVdkeW5KUWFlTGoKenF6bWtlMmt2RlZudEhZcWtCRXN6WlE9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + signature.kubescape.io/content: H4sIAAAAAAAA/8yUb2sjNxDGv8p03qTHKe66fzi6cC8MTlND71KCry2UI4yl8VpEK+mk2cQmGPoh+gn7SYrWu3GTJi2FK9wrr0ezo2eemd/eIUX7E6dsg8car7sVZ02RJzZ8cTNFhdfWG6zxsnOcUWHLQoaEsL5DRyt2uTxl23iSLvHkQYFV541jrPHw8NUrVM+lrhM1LXs51Y5yxhrDDSdHO9wr9NSWIlpOxzqnrd2yOU29qL3CHFkXHYdA/esdsqeVY4O1pI4V2r6HqqpKS0O9d563kbWwgZiC5pzBUef1hg0qNJx1slEOvsycC7dsYENuDWENBL0CGGXDH7/9Dm2XBTzfcAIyrRVYsaYuM8iGIduVs76BIhEWc7AZQifZmsPx0C707QOV65zNggp5GxPnMp/e6pZzpqboP3mz+OVsDt9dzs7fnL1dQt9eDSfwEviGvUx0aFtU2Hn7oeNFceAYh5dwcnVM5S1Hkg2q3sKz+ysPXpaU5S6WS3nL+oEorPEzHSe3lK/KWSdsPh+v8ULWc1oYBZFS5knD0mddlcuGNEpNVn9RrB5KevEC9+/3CmMKa+t4zpG9Ya93WFfHKAld8ofOpjLyOwyRi1u+c071kstGkXOoUFOklXVWLN9n5F3W5NzxDW9isF6OgaZ0Ow8tWf8oODOm/ByLWf9U8hB9lF1Wt+yLlR3WrxTmLsaQ5MfgbGlwTS6zQqGmLDUWQ3krdQEneZYeyDF47zYqJB9acjvs/SmLjWocHMXorKay1oN3+F4hNexl+AoMPpatxhpRoc3LZJuG08xxkpGo1kriJWmxGmtczqqq+hLHMOtNv3TlZFp98y3u1bNITp9G8uAueJbbkK5BEq3XVv8NzLnNNLA5ElZQ7OsWxnwQsP6fEFOQQ3/+8/cXP5wdie5pXjEkXne5/yT8FxKnD0g0Wcrkn4LxePQv5A1OPIbvUCVe92nw+jWcXLxbnl8s3p6ffCRwDn/+R24GMj8pcEa3PxYc0+oZOL6eljHt/wwAAP//uo+OkYgHAAA= + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEQCIBZMmz5NKlHFBXZw4EPvyfmMUVbK/5pM7t90beomJ09PAiB+oqTwF/iMofWfd6Ctak4sN/FRAcr9dzkH/8LyNJKKwA== + signature.kubescape.io/timestamp: "1789929998" + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: overlay + name: ct-bundle37-mixed-rules +spec: + rules: + - agentVersionRequirement: "" + description: Allowed half of a mixed fragment — must never admit because the sibling + rule ID is outside the overlay class allowlist + enabled: true + expressions: + message: '''MIXED FRAGMENT R0001: '' + event.comm' + ruleExpression: + - eventType: exec + expression: '!cp.was_executed(event.containerId, parse.get_exec_path(event.args, + event.comm, event.exepath))' + uniqueId: event.comm + '_' + event.exepath + id: R0001 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Unexpected process launched + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: all + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 0 + severity: 7 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - process + - exec + - applicationprofile + - agentVersionRequirement: "" + description: Disallowed sibling — R0011 is not in the overlay class allowlist, + so the WHOLE fragment must be refused + enabled: true + expressions: + message: '''MIXED FRAGMENT R0011: '' + event.dstAddr' + ruleExpression: + - eventType: network + expression: event.pktType == 'OUTGOING' + uniqueId: event.dstAddr + id: R0011 + isTriggerAlert: true + mitreTactic: TA0010 + mitreTechnique: T1041 + name: Unexpected egress network traffic + profileDataRequired: + capabilities: null + egressAddresses: all + egressDomains: null + endpoints: null + execs: null + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 0 + severity: 7 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - network diff --git a/tests/resources/signed/rules/mixed-overlay-rejected.yaml b/tests/resources/signed/rules/mixed-overlay-rejected.yaml new file mode 100644 index 0000000000..3ba028c5ec --- /dev/null +++ b/tests/resources/signed/rules/mixed-overlay-rejected.yaml @@ -0,0 +1,47 @@ +apiVersion: kubescape.io/v1 +kind: Rules +metadata: + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: overlay + name: ct-bundle37-mixed-rules +spec: + rules: + - description: Allowed half of a mixed fragment — must never admit because the sibling rule ID is outside the overlay class allowlist + enabled: true + expressions: + message: '''MIXED FRAGMENT R0001: '' + event.comm' + ruleExpression: + - eventType: exec + expression: '!cp.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))' + uniqueId: event.comm + '_' + event.exepath + id: R0001 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Unexpected process launched + profileDataRequired: + execs: all + profileDependency: 0 + severity: 7 + supportPolicy: false + tags: ["context:kubernetes", "context:container", "anomaly", "process", "exec", "applicationprofile"] + - description: Disallowed sibling — R0011 is not in the overlay class allowlist, so the WHOLE fragment must be refused + enabled: true + expressions: + message: '''MIXED FRAGMENT R0011: '' + event.dstAddr' + ruleExpression: + - eventType: network + expression: 'event.pktType == ''OUTGOING''' + uniqueId: event.dstAddr + id: R0011 + isTriggerAlert: true + mitreTactic: TA0010 + mitreTechnique: T1041 + name: Unexpected egress network traffic + profileDataRequired: + egressAddresses: all + profileDependency: 0 + severity: 7 + supportPolicy: false + tags: ["context:kubernetes", "context:container", "anomaly", "network"] diff --git a/tests/resources/signed/rules/namespace-override-signed.yaml b/tests/resources/signed/rules/namespace-override-signed.yaml new file mode 100644 index 0000000000..27e575cf96 --- /dev/null +++ b/tests/resources/signed/rules/namespace-override-signed.yaml @@ -0,0 +1,52 @@ +apiVersion: kubescape.io/v1 +kind: Rules +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJYRENDQVFLZ0F3SUJBZ0lSQUtwVDVTMXlNM3czZS9DQmZjZzJLOGd3Q2dZSUtvWkl6ajBFQXdJd0ZERVMKTUJBR0ExVUVBeE1KYkc5allXd3RhMlY1TUI0WERUSTJNRGt5TURFNE5EWXpOMW9YRFRJM01Ea3lNREU0TkRZegpOMW93RkRFU01CQUdBMVVFQXhNSmJHOWpZV3d0YTJWNU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEClFnQUUzR0lRd0tCK1I0RmJTU0liQXYwMUdKcHJNekxwa1RSUVRhellEYWc0YWlFcDQ3bDhmQ2FqUkIwTTUzWE0KRkpkK0xIdzE1WHpjTG5Sc1dDckd1Rlh4WEtNMU1ETXdEZ1lEVlIwUEFRSC9CQVFEQWdlQU1CTUdBMVVkSlFRTQpNQW9HQ0NzR0FRVUZCd01ETUF3R0ExVWRFd0VCL3dRQ01BQXdDZ1lJS29aSXpqMEVBd0lEU0FBd1JRSWdkZmNCCmx1VUpCYVBaVGF5cW9oYlM1Y0w1UFRyNXBZZjZZNlhWdE9RRFBRNENJUUM5MC9lSEJQNUhyVkhIT2l2VXJhNlYKK2RhbjhzWG5VWTlldW1pL2FXZ1lKZz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + signature.kubescape.io/content: H4sIAAAAAAAA/3RUQU8bPRD9K/7mAggn39KqqvAtKByQUItQQKoqhCb2ZGPhtY09GxJF+e+Vdzekoe1prZnnN2+eZ3YLGO0jpWyDBwUv7ZyyxkhjG/5fXYCEF+sNKLhvHWWQ0BCjQUZQW3A4J5fLKdvaI7eJxkcE89YbR6CgP3z+CvJf0EXCuiHPI+0wZ1AQVpQcbmAnwWNTSDSP9jyjITtKnaydhBxJFyV9QP3cAnmcOzKgOLUkwXZdVFVVmhoYHzytI2kmI2IKmnIWDluvl2RAgqGsk43cO3PVlR5lHSIZMdQXYSE6TrEISfCSRGmPTK9L6NDE4MmzYMoMEmgdE+XidWdbQzljXZScXD18m95ei++P1/e3kx89qRLtnwJPxLmgFXke69A04lyciDfLS3F3M+1ymZP19WkPidacgYTW29eWbooDx1efD2y0poi8BNlZeP0utPeyQGabWKTSmvRRK6DgPx3Hb5ifS65lMqf7Mp7Reko3RoqIKdO4Ju5Qz6XYAMNUZ/lbU/JY0tkZ7J52EmIKC+toSpG8Ia83oKpDFBnv6bW1qTz5FkKk4rFvnZOd5DJT6BxI0Bhxbp1lS++IvMkanTvc8CYG6/kQqEu309Cg9R+CE2PK50Bm/d/AQ/QDuowurShZ3oC6lJDbGEPiu+BsaXCBLpMExroMNRRDac2qrE7yxN1K7oPvboME9KFBt4HOnzI3IPcPhzE6q7GM9eAdPEnAmjwP/4HBx7KOoAAk2DxLtq4pTRwl3m9UYznRDDVbDQpmk6qqPsE+THrZDV3JXFRfLssT7n4FAAD//7Re1C9tBAAA + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEUCIGqd03utgAauNMqivLYI/rQgRHMEVXQkfNsR+PUZ79xBAiEA5Q/RxhlomVRANo5lMar1sWs9aUS0hFJzeBHddptFdGY= + signature.kubescape.io/timestamp: "1789929997" + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: overlay + name: ct-bundle37-overlay-rules +spec: + rules: + - agentVersionRequirement: "" + description: Bundle-scoped overlay of R0001 for the signed-rules component test + enabled: true + expressions: + message: '''BUNDLE OVERLAY R0001: unexpected process '' + event.comm + '' with + PID '' + string(event.pid)' + ruleExpression: + - eventType: exec + expression: '!cp.was_executed(event.containerId, parse.get_exec_path(event.args, + event.comm, event.exepath))' + uniqueId: event.comm + '_' + event.exepath + id: R0001 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Unexpected process launched + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: all + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 0 + severity: 9 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - process + - exec + - applicationprofile diff --git a/tests/resources/signed/rules/namespace-override-v2-signed.yaml b/tests/resources/signed/rules/namespace-override-v2-signed.yaml new file mode 100644 index 0000000000..2e6b685f26 --- /dev/null +++ b/tests/resources/signed/rules/namespace-override-v2-signed.yaml @@ -0,0 +1,53 @@ +apiVersion: kubescape.io/v1 +kind: Rules +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJXakNDQVFHZ0F3SUJBZ0lRVmcvbHJ2QWQxU2UwM2s3NjF1M1d2REFLQmdncWhrak9QUVFEQWpBVU1SSXcKRUFZRFZRUURFd2xzYjJOaGJDMXJaWGt3SGhjTk1qWXdPVEl3TVRnME5qTTRXaGNOTWpjd09USXdNVGcwTmpNNApXakFVTVJJd0VBWURWUVFERXdsc2IyTmhiQzFyWlhrd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DCkFBVGNZaERBb0g1SGdWdEpJaHNDL1RVWW1tc3pNdW1STkZCTnJOZ05xRGhxSVNuanVYeDhKcU5FSFF6bmRjd1UKbDM0c2ZEWGxmTnd1ZEd4WUtzYTRWZkZjb3pVd016QU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WURWUjBsQkF3dwpDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQUtCZ2dxaGtqT1BRUURBZ05IQURCRUFpQldRZnVDCmI3R1RRaFEwT0hYeURzNzRVRGQ3TE4rY3ovVTlGaDlBZDNRaU5nSWdXWk95RzZDbXJsUFFSa0k1WG53NXRJK3gKRkJEZ0t5RVJoYnEwSWJpekRsaz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + signature.kubescape.io/content: H4sIAAAAAAAA/3RU0U4rNxD9FXdeAOGkC1XVdt+CwgMSahEKSFWF0MSebEZ4bWPPhkRR/r3yZkNuuPc+rTVzPHPm+MxuASM/U8ocPNTw1s0pG4w05vDr6go0vLG3UMNj5yiDhpYELQpCvQWHc3K5nDI3HqVLND4pMO+8dQQ17A+//QH6Z9BFwqYlLyPjMGeoIawoOdzAToPHthQxMjrUGQ3ZUepp7TTkSKYw2Qfq/7ZAHueOLNSSOtLA/RRVVZWhhopPntaRjJBVMQVDOSuHnTdLsqDBUjaJo+yVuelbj7IJkawa+quwUH1NrTKZ4K1KtOKipVaLkJQsSWVB88a+USa0MXjyooSygAZax0S5oHsRW8oZm8Lr7Obp7+n9rfrn+fbxfvKver7ed6lV9z3jM3WpaEVexia0rbpUZ+qDZake7qZ9Lkti35zvIZHtBWjoPL93dFckOb36eqxGa4ooS9C9prefXPfiFshsEwtbWpM5mQZq+MXE8Qfm15LrhOz5oY0XZE/pzmoVMWUaNyQ96rU0G2CYmqy/GUqfUrq4gN3LTkNMYcGOphTJW/JmA3V1jKLgI713nIoHthAiFZl955zuKReToXOgwWDEOTsWpk9E3mSDzh1veBsDezkGmjLtNLTI/ktwYm35HIux/xF4iH5BFy/TihLLBuo/NeQuxpDkITguAy7QZdIg2BSXQxGU1lKXXUqepN/RQ/BTbdCAPrToNtDrU3wD+vBwGKNjg8Xng3bwogEb8jL8GAYdy35CDaCB8yxx01CaOEpyWLGWJdEMjbCBGmaTqqqu4RAms+xNVzJX1e9/lSfc/R8AAP//sgLHdX4EAAA= + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEQCIHWRBt1rfDCCVxxp/O7E7HpC28zLApJu7S5ViUMaka3FAiBFiuruI/abZiFo6UM83CXHPtFVbMVxTEtuzaC7UQWGsQ== + signature.kubescape.io/timestamp: "1789929998" + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: overlay + name: ct-bundle37-overlay-rules +spec: + rules: + - agentVersionRequirement: "" + description: Bundle-scoped overlay of R0001, second revision, for the stacking + component test + enabled: true + expressions: + message: '''BUNDLE OVERLAY V2 R0001: unexpected process '' + event.comm + '' + with PID '' + string(event.pid)' + ruleExpression: + - eventType: exec + expression: '!cp.was_executed(event.containerId, parse.get_exec_path(event.args, + event.comm, event.exepath))' + uniqueId: event.comm + '_' + event.exepath + id: R0001 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Unexpected process launched + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: all + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 0 + severity: 8 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - process + - exec + - applicationprofile diff --git a/tests/resources/signed/rules/namespace-override-v2.yaml b/tests/resources/signed/rules/namespace-override-v2.yaml new file mode 100644 index 0000000000..a710369ec3 --- /dev/null +++ b/tests/resources/signed/rules/namespace-override-v2.yaml @@ -0,0 +1,34 @@ +apiVersion: kubescape.io/v1 +kind: Rules +metadata: + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: overlay + name: ct-bundle37-overlay-rules +spec: + rules: + - description: Bundle-scoped overlay of R0001, second revision, for the stacking component test + enabled: true + expressions: + message: '''BUNDLE OVERLAY V2 R0001: unexpected process '' + event.comm + '' with PID '' + string(event.pid)' + ruleExpression: + - eventType: exec + expression: '!cp.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))' + uniqueId: event.comm + '_' + event.exepath + id: R0001 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Unexpected process launched + profileDataRequired: + execs: all + profileDependency: 0 + severity: 8 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - process + - exec + - applicationprofile diff --git a/tests/resources/signed/rules/namespace-override.yaml b/tests/resources/signed/rules/namespace-override.yaml new file mode 100644 index 0000000000..ef40df9562 --- /dev/null +++ b/tests/resources/signed/rules/namespace-override.yaml @@ -0,0 +1,34 @@ +apiVersion: kubescape.io/v1 +kind: Rules +metadata: + labels: + signature.kubescape.io/bundle: bundle37 + signature.kubescape.io/fragment-class: overlay + name: ct-bundle37-overlay-rules +spec: + rules: + - description: Bundle-scoped overlay of R0001 for the signed-rules component test + enabled: true + expressions: + message: '''BUNDLE OVERLAY R0001: unexpected process '' + event.comm + '' with PID '' + string(event.pid)' + ruleExpression: + - eventType: exec + expression: '!cp.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))' + uniqueId: event.comm + '_' + event.exepath + id: R0001 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Unexpected process launched + profileDataRequired: + execs: all + profileDependency: 0 + severity: 9 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - process + - exec + - applicationprofile diff --git a/tests/resources/signed/rules/untrusted-signer-signed.yaml b/tests/resources/signed/rules/untrusted-signer-signed.yaml new file mode 100644 index 0000000000..882d25e4d4 --- /dev/null +++ b/tests/resources/signed/rules/untrusted-signer-signed.yaml @@ -0,0 +1,51 @@ +apiVersion: kubescape.io/v1 +kind: Rules +metadata: + annotations: + signature.kubescape.io/certificate: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJYRENDQVFLZ0F3SUJBZ0lSQUovekRoK3oxTVBmSm1nd0IydktQcUl3Q2dZSUtvWkl6ajBFQXdJd0ZERVMKTUJBR0ExVUVBeE1KYkc5allXd3RhMlY1TUI0WERUSTJNRGt5TURFNE5EWXpOMW9YRFRJM01Ea3lNREU0TkRZegpOMW93RkRFU01CQUdBMVVFQXhNSmJHOWpZV3d0YTJWNU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEClFnQUVGblVsNkx0cTQycERKOVQ0ekFqV1V1dng5T0lzVktkQnFjNEJ5clZvbUJXQkxtbkFYemY1S0FsYjBDdk0KUTArajl3YnpLdTRvK25FZDBwR2txNE45bWFNMU1ETXdEZ1lEVlIwUEFRSC9CQVFEQWdlQU1CTUdBMVVkSlFRTQpNQW9HQ0NzR0FRVUZCd01ETUF3R0ExVWRFd0VCL3dRQ01BQXdDZ1lJS29aSXpqMEVBd0lEU0FBd1JRSWdLSGlGClZsRSttQlVFVXdPd0F0RVpFRlNteUxSY2pIOUV1MmsrT3d1aTZ6Z0NJUURTeWZnK3dlSkFrRGtvcVpQdEdodW4KQmRTS2wvWm5IbmVXTGxyOXZ6bEZRUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + signature.kubescape.io/content: H4sIAAAAAAAA/2xTwU4jORT8Fe+7AMJkm5VWq+05IYFGnAZFYS4jhF7sSsfC7Tb2MySK8u8jdxIyYeaUqFxdrleutyGO7jtSdkOgll7KHNlwxMQNf79dk6YXFyy1NC0emTT1ELYsTO2GPM/hc/2XXRdYSsLkRGCRuOsR5Mp4zplamnMGbTUF7kEtGblKQ1dwlUb1raYcYargDmh/bAiB5x6WWkkFmtxopmma6m0v8xiwijACq2IaDHJWnkswS1jSZJFNclF2A07rfSpBSoAaFmqUUtU/rJqvFQdVgqSSq9oL1l9UX7KogDckNYdi2zuRURirmJBrcGMGPXLmrvo5m377+ni3k25V+d3dmbpUeEOQiRn6Xl2qM/XuZKke7m/HsyzJhe58R4nOXpCmEtxrwX0d//TT56MaVogsS9Jjfncf/nZBVspsHatDrGBOJqCW/jJx8s75uZ4VgT0/XBOEXUC6t1pFThmTDjKynutlexqnLutfhtKnli4uaPu01RTTsHAet4gIFsGsqW2OKAtP8Vpcqu+9oSGiRhuK93q0XCvE3pMmw5Hnzjtx+GDkdTbs/fGLYOPgghyBrk57O/Tswifwxtr6cxRz4U/kPfqJXXtb++FkTe1/mnKJcUjyMHhXB1ywz9Ak3NVGUw0UK2nrpqQAGdfqAH6kTZo4DD37NY351N6QPjwcx+id4drpfXb0pIk7BNnv8j7Hun3UEmlyeZZc1yHdeCQ5rFPvJGHGRpyhlmY3TdP8QwcYZjmWrp5cN//+X59w+zMAAP///4Na6TEEAAA= + signature.kubescape.io/identity: local-key + signature.kubescape.io/issuer: local + signature.kubescape.io/signature: MEYCIQDg1kOEwETX+7ajKNbzJ3cN+GjwcA6K1cIC/aUBrFcnRAIhAInigOJkBI5nfK/z7/EZopgUPrm7K/qNaW3WMjmYsVv0 + signature.kubescape.io/timestamp: "1789929997" + labels: + signature.kubescape.io/fragment-class: base + name: ct-rogue-rules +spec: + rules: + - agentVersionRequirement: "" + description: Rogue retune of R0001 signed by an untrusted key; must never be admitted + enabled: true + expressions: + message: '''ROGUE R0001: unexpected process '' + event.comm + '' with PID '' + + string(event.pid)' + ruleExpression: + - eventType: exec + expression: '!cp.was_executed(event.containerId, parse.get_exec_path(event.args, + event.comm, event.exepath))' + uniqueId: event.comm + '_' + event.exepath + id: R0001 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Unexpected process launched + profileDataRequired: + capabilities: null + egressAddresses: null + egressDomains: null + endpoints: null + execs: all + ingressAddresses: null + ingressDomains: null + opens: null + syscalls: null + profileDependency: 0 + severity: 7 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - process + - exec + - applicationprofile diff --git a/tests/resources/signed/rules/untrusted-signer.yaml b/tests/resources/signed/rules/untrusted-signer.yaml new file mode 100644 index 0000000000..1730ce7acc --- /dev/null +++ b/tests/resources/signed/rules/untrusted-signer.yaml @@ -0,0 +1,33 @@ +apiVersion: kubescape.io/v1 +kind: Rules +metadata: + labels: + signature.kubescape.io/fragment-class: base + name: ct-rogue-rules +spec: + rules: + - description: Rogue retune of R0001 signed by an untrusted key; must never be admitted + enabled: true + expressions: + message: '''ROGUE R0001: unexpected process '' + event.comm + '' with PID '' + string(event.pid)' + ruleExpression: + - eventType: exec + expression: '!cp.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))' + uniqueId: event.comm + '_' + event.exepath + id: R0001 + isTriggerAlert: true + mitreTactic: TA0002 + mitreTechnique: T1059 + name: Unexpected process launched + profileDataRequired: + execs: all + profileDependency: 0 + severity: 7 + supportPolicy: false + tags: + - context:kubernetes + - context:container + - anomaly + - process + - exec + - applicationprofile diff --git a/tests/resources/signed/trust-policy.badsigner.signed.json b/tests/resources/signed/trust-policy.badsigner.signed.json new file mode 100644 index 0000000000..7c5b80cc92 --- /dev/null +++ b/tests/resources/signed/trust-policy.badsigner.signed.json @@ -0,0 +1,68 @@ +{ + "policy": { + "classes": { + "admission": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedSpecPaths": [ + "ingress", + "egress" + ] + }, + "base": { + "signers": [ + "key:162a08d59135652ee095ad2e0d927104c9e4eefe94f4ae844e7387ba1a7352d2" + ], + "allowedSpecPaths": [ + "architectures", + "capabilities", + "execs", + "opens", + "syscalls", + "seccompProfile", + "endpoints", + "imageID", + "imageTag", + "rulePolicies", + "identifiedCallStacks", + "labelSelector" + ] + }, + "overlay": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedSpecPaths": [ + "execs", + "opens" + ] + } + }, + "ruleClasses": { + "base": { + "signers": [ + "key:162a08d59135652ee095ad2e0d927104c9e4eefe94f4ae844e7387ba1a7352d2" + ], + "allowedRuleIDs": [ + "*" + ] + }, + "overlay": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedRuleIDs": [ + "R0001" + ] + } + } + }, + "annotations": { + "signature.kubescape.io/certificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJYRENDQVFLZ0F3SUJBZ0lSQUpucjQyaFFHMFNJQkpjQml6aTRiaWd3Q2dZSUtvWkl6ajBFQXdJd0ZERVMKTUJBR0ExVUVBeE1KYkc5allXd3RhMlY1TUI0WERUSTJNRGd4TVRFM016a3dORm9YRFRJM01EZ3hNVEUzTXprdwpORm93RkRFU01CQUdBMVVFQXhNSmJHOWpZV3d0YTJWNU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEClFnQUUwWUpGQnd6emYwRW1wZmMwSTdlQytBeSt4ckswV0lodnZ0Wk5TOGFKOU1SajU5TkY5RkJnVlY4SjBnUFAKeU9SNU9QMkp1cmdwV3NkQ3lUbHhmYUVWTzZNMU1ETXdEZ1lEVlIwUEFRSC9CQVFEQWdlQU1CTUdBMVVkSlFRTQpNQW9HQ0NzR0FRVUZCd01ETUF3R0ExVWRFd0VCL3dRQ01BQXdDZ1lJS29aSXpqMEVBd0lEU0FBd1JRSWdOQU9UCjRHK0d3ak5QMW5acEdjMnoxQjQ4VGUxQ3JYTzczUjF1WkJndUF1a0NJUUQweVdpMHcrTkRBeVkrUzZyOWpacjAKc1Q2T3Yvc0s2T3RaM2hGQm5XbXVkQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K", + "signature.kubescape.io/identity": "local-key", + "signature.kubescape.io/issuer": "local", + "signature.kubescape.io/signature": "MEQCIF+63xVtEgk79gE5kBCrr77xa849iFZtSwj9JndvuNeGAiANkRuMDs5CEPXkVOX2VnUaLxkdhpLUxDmws+P323ULkg==", + "signature.kubescape.io/timestamp": "1786469944" + } +} \ No newline at end of file diff --git a/tests/resources/signed/trust-policy.enforce.signed.json b/tests/resources/signed/trust-policy.enforce.signed.json new file mode 100644 index 0000000000..440b536525 --- /dev/null +++ b/tests/resources/signed/trust-policy.enforce.signed.json @@ -0,0 +1,52 @@ +{ + "policy": { + "mode": "enforce", + "classes": { + "admission": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedSpecPaths": [ + "ingress", + "egress" + ] + }, + "base": { + "signers": [ + "key:162a08d59135652ee095ad2e0d927104c9e4eefe94f4ae844e7387ba1a7352d2" + ], + "allowedSpecPaths": [ + "architectures", + "capabilities", + "execs", + "opens", + "syscalls", + "seccompProfile", + "endpoints", + "imageID", + "imageTag", + "rulePolicies", + "identifiedCallStacks", + "labelSelector" + ] + }, + "overlay": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedSpecPaths": [ + "execs", + "opens" + ] + } + }, + "policyVersion": 2 + }, + "annotations": { + "signature.kubescape.io/certificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJYRENDQVFHZ0F3SUJBZ0lRZnJxRC96V0p1UjIwWkxQRWdDYUxlakFLQmdncWhrak9QUVFEQWpBVU1SSXcKRUFZRFZRUURFd2xzYjJOaGJDMXJaWGt3SGhjTk1qWXdPREkyTVRjek1qUXhXaGNOTWpjd09ESTJNVGN6TWpReApXakFVTVJJd0VBWURWUVFERXdsc2IyTmhiQzFyWlhrd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DCkFBUXc3a1NsNUFOdEh2VHFnV0gzaFB3cjdJMHJTbUlrUlNtd3BRUGUzYThQZ3dTMWxsMnVtMVRWUTQwV21KYkgKTGFmQVlMT1dsbVloZTFHdGNNRU5WbzE5b3pVd016QU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WURWUjBsQkF3dwpDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQUtCZ2dxaGtqT1BRUURBZ05KQURCR0FpRUF4SmRuCmV1alJDTnIrZlhqZzFVOTBZWTRLK05zYm1ERG80RUVvMGpGcCtoZ0NJUUQ4OHlpMlNSM1NKZklaOW5wOEdmRE4KcHcvTlFYVU9weUxESUFmdXluTGlDUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K", + "signature.kubescape.io/identity": "local-key", + "signature.kubescape.io/issuer": "local", + "signature.kubescape.io/signature": "MEUCIQC3uV8iTk0cH0/feUJiMBvb47EOkrWzZY0sR+ldC/MB9wIgQK9fkzBNqIH6VYSKVsoMR/7CN8hUsNRp15oZFxoZe74=", + "signature.kubescape.io/timestamp": "1787765561" + } +} \ No newline at end of file diff --git a/tests/resources/signed/trust-policy.json b/tests/resources/signed/trust-policy.json new file mode 100644 index 0000000000..cabdd8a20c --- /dev/null +++ b/tests/resources/signed/trust-policy.json @@ -0,0 +1,59 @@ +{ + "classes": { + "base": { + "signers": [ + "key:162a08d59135652ee095ad2e0d927104c9e4eefe94f4ae844e7387ba1a7352d2" + ], + "allowedSpecPaths": [ + "architectures", + "capabilities", + "execs", + "opens", + "syscalls", + "seccompProfile", + "endpoints", + "imageID", + "imageTag", + "rulePolicies", + "identifiedCallStacks", + "labelSelector" + ] + }, + "admission": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedSpecPaths": [ + "ingress", + "egress" + ] + }, + "overlay": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedSpecPaths": [ + "execs", + "opens" + ] + } + }, + "ruleClasses": { + "base": { + "signers": [ + "key:162a08d59135652ee095ad2e0d927104c9e4eefe94f4ae844e7387ba1a7352d2" + ], + "allowedRuleIDs": [ + "*" + ] + }, + "overlay": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedRuleIDs": [ + "R0001" + ] + } + } +} diff --git a/tests/resources/signed/trust-policy.profiles-only.json b/tests/resources/signed/trust-policy.profiles-only.json new file mode 100644 index 0000000000..100ababc97 --- /dev/null +++ b/tests/resources/signed/trust-policy.profiles-only.json @@ -0,0 +1,41 @@ +{ + "classes": { + "base": { + "signers": [ + "key:162a08d59135652ee095ad2e0d927104c9e4eefe94f4ae844e7387ba1a7352d2" + ], + "allowedSpecPaths": [ + "architectures", + "capabilities", + "execs", + "opens", + "syscalls", + "seccompProfile", + "endpoints", + "imageID", + "imageTag", + "rulePolicies", + "identifiedCallStacks", + "labelSelector" + ] + }, + "admission": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedSpecPaths": [ + "ingress", + "egress" + ] + }, + "overlay": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedSpecPaths": [ + "execs", + "opens" + ] + } + } +} diff --git a/tests/resources/signed/trust-policy.profiles-only.signed.json b/tests/resources/signed/trust-policy.profiles-only.signed.json new file mode 100644 index 0000000000..5e55431dd2 --- /dev/null +++ b/tests/resources/signed/trust-policy.profiles-only.signed.json @@ -0,0 +1,50 @@ +{ + "policy": { + "classes": { + "admission": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedSpecPaths": [ + "ingress", + "egress" + ] + }, + "base": { + "signers": [ + "key:162a08d59135652ee095ad2e0d927104c9e4eefe94f4ae844e7387ba1a7352d2" + ], + "allowedSpecPaths": [ + "architectures", + "capabilities", + "execs", + "opens", + "syscalls", + "seccompProfile", + "endpoints", + "imageID", + "imageTag", + "rulePolicies", + "identifiedCallStacks", + "labelSelector" + ] + }, + "overlay": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedSpecPaths": [ + "execs", + "opens" + ] + } + } + }, + "annotations": { + "signature.kubescape.io/certificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJXekNDQVFHZ0F3SUJBZ0lRS2VKQVd2MllrOFFQN09sRUdOai9zakFLQmdncWhrak9QUVFEQWpBVU1SSXcKRUFZRFZRUURFd2xzYjJOaGJDMXJaWGt3SGhjTk1qWXdPREV4TVRjek9UQTBXaGNOTWpjd09ERXhNVGN6T1RBMApXakFVTVJJd0VBWURWUVFERXdsc2IyTmhiQzFyWlhrd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DCkFBVEFnaVE5TVBtc1k4Lzl0aWFORWZGNThDZ0lqV2JBaUpBeEVEdnpGMUxtdE5QZk9YVHJLQ2xqMFBnRFhOR3QKQ09yWUtRWGx5TEE3YVJJa1o3ZUhyaU1Ub3pVd016QU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WURWUjBsQkF3dwpDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQUtCZ2dxaGtqT1BRUURBZ05JQURCRkFpRUFoajRMCjVWZGVFaU1RSkJRV0NBZmFWcURKb1hiWnBwcWYvOGlDaDc3TXIxRUNJR2tZalJ2QUs5dzVzT1ZjQ282UDVEY3QKNzVIRVVGMXRqMUZTZ1V3NmFPS1oKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=", + "signature.kubescape.io/identity": "local-key", + "signature.kubescape.io/issuer": "local", + "signature.kubescape.io/signature": "MEQCICsn0SKro/Z1NFK9iTdWkcUAGyl+zEFkrJGZQG/R7inXAiBy6zigq/MkNmwgXZk7ec8kKKiiaO1LVwwikdQXVDOhOg==", + "signature.kubescape.io/timestamp": "1786469944" + } +} \ No newline at end of file diff --git a/tests/resources/signed/trust-policy.signed.json b/tests/resources/signed/trust-policy.signed.json new file mode 100644 index 0000000000..77f78f3c72 --- /dev/null +++ b/tests/resources/signed/trust-policy.signed.json @@ -0,0 +1,68 @@ +{ + "policy": { + "classes": { + "admission": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedSpecPaths": [ + "ingress", + "egress" + ] + }, + "base": { + "signers": [ + "key:162a08d59135652ee095ad2e0d927104c9e4eefe94f4ae844e7387ba1a7352d2" + ], + "allowedSpecPaths": [ + "architectures", + "capabilities", + "execs", + "opens", + "syscalls", + "seccompProfile", + "endpoints", + "imageID", + "imageTag", + "rulePolicies", + "identifiedCallStacks", + "labelSelector" + ] + }, + "overlay": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedSpecPaths": [ + "execs", + "opens" + ] + } + }, + "ruleClasses": { + "base": { + "signers": [ + "key:162a08d59135652ee095ad2e0d927104c9e4eefe94f4ae844e7387ba1a7352d2" + ], + "allowedRuleIDs": [ + "*" + ] + }, + "overlay": { + "signers": [ + "key:58fd31c363a8fac06d6eb596c2e8c63d5913b4dc4b971ee62579ec41fff78826" + ], + "allowedRuleIDs": [ + "R0001" + ] + } + } + }, + "annotations": { + "signature.kubescape.io/certificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJYRENDQVFHZ0F3SUJBZ0lRTGI3Y01hSmlKZExVMTR3bStDT0tnVEFLQmdncWhrak9QUVFEQWpBVU1SSXcKRUFZRFZRUURFd2xzYjJOaGJDMXJaWGt3SGhjTk1qWXdPREV4TVRjek9UQTBXaGNOTWpjd09ERXhNVGN6T1RBMApXakFVTVJJd0VBWURWUVFERXdsc2IyTmhiQzFyWlhrd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DCkFBVEFnaVE5TVBtc1k4Lzl0aWFORWZGNThDZ0lqV2JBaUpBeEVEdnpGMUxtdE5QZk9YVHJLQ2xqMFBnRFhOR3QKQ09yWUtRWGx5TEE3YVJJa1o3ZUhyaU1Ub3pVd016QU9CZ05WSFE4QkFmOEVCQU1DQjRBd0V3WURWUjBsQkF3dwpDZ1lJS3dZQkJRVUhBd013REFZRFZSMFRBUUgvQkFJd0FEQUtCZ2dxaGtqT1BRUURBZ05KQURCR0FpRUFwTUVKCkpaMW1FQ3grZlJMMno5ZzhSKzRhMXM1RWdLeEZSSU1jVnBPWWZib0NJUUNGSjljZ08xdktIL25GTlpuRjkyam0KTG0weE5rZktWdTYzdkI3bTJDK3UvUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K", + "signature.kubescape.io/identity": "local-key", + "signature.kubescape.io/issuer": "local", + "signature.kubescape.io/signature": "MEUCIQDOzZvUL9zRsqdDefi0jx7PksahnhwP/gF2noE4ro/FIAIgOg/P1bxLLzxCIUECp/abpSLFyPD/kUFvCq3xNjdKxKs=", + "signature.kubescape.io/timestamp": "1786469944" + } +} \ No newline at end of file