From a11b5a48a9e66b8bc9be3a8efd6dea14f0854ecc Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 15 May 2026 23:26:52 +0200 Subject: [PATCH 1/4] SBOB wildcards: DNS + IP + path exec wildcards, sign/tamper (with -11.7%/-12.4% memory) Signed-off-by: entlein --- cmd/main.go | 21 +- cmd/sign-object/Dockerfile | 20 + cmd/sign-object/main.go | 550 ++++ pkg/config/config.go | 3 + .../v1/event_reporting.go | 21 + .../v1/event_reporting_test.go | 20 + pkg/containerprofilemanager/v1/lifecycle.go | 7 +- pkg/exporters/alert_manager.go | 6 + .../containerprofilecache.go | 17 + .../containerprofilecache/projection.go | 69 + .../containerprofilecache/projection_apply.go | 99 +- .../containerprofilecache/tamper_alert.go | 190 ++ .../tamper_alert_test.go | 281 ++ .../test32_projection_test.go | 326 +++ pkg/objectcache/projection_types.go | 7 + pkg/objectcache/shared_container_data.go | 16 + pkg/objectcache/v1/mock.go | 144 +- pkg/rulebindingmanager/cache/cache.go | 124 +- pkg/rulebindingmanager/cache/cache_test.go | 122 +- .../cel/libraries/applicationprofile/ap.go | 22 - .../cel/libraries/applicationprofile/exec.go | 40 +- .../libraries/applicationprofile/exec_test.go | 134 +- .../applicationprofile/integration_test.go | 2 +- .../cel/libraries/applicationprofile/open.go | 69 +- .../libraries/applicationprofile/open_test.go | 284 +- .../cel/libraries/cache/function_cache.go | 2 +- .../networkneighborhood/fixtures_test.go | 239 ++ .../libraries/networkneighborhood/network.go | 137 +- .../networkneighborhood/wildcard_test.go | 386 +++ pkg/rulemanager/cel/libraries/parse/parse.go | 57 +- .../cel/libraries/parse/parselib.go | 11 + .../cel/libraries/parse/parsing_test.go | 125 + pkg/rulemanager/ruleswatcher/watcher.go | 46 +- pkg/signature/annotations.go | 24 + pkg/signature/cluster_flow_test.go | 150 ++ pkg/signature/cluster_scenario_test.go | 88 + pkg/signature/cosign_adapter.go | 572 ++++ pkg/signature/cosign_adapter_test.go | 143 + pkg/signature/interface.go | 63 + pkg/signature/profiles/adapter_test.go | 335 +++ .../profiles/applicationprofile_adapter.go | 81 + pkg/signature/profiles/empty_typemeta_test.go | 78 + .../profiles/networkneighborhood_adapter.go | 63 + .../networkneighborhood_adapter_test.go | 99 + pkg/signature/profiles/rules_adapter.go | 60 + pkg/signature/profiles/rules_adapter_test.go | 184 ++ .../profiles/seccompprofile_adapter.go | 63 + pkg/signature/sign.go | 114 + pkg/signature/sign_test.go | 231 ++ pkg/signature/signer.go | 20 + pkg/signature/verifier.go | 38 + pkg/signature/verify.go | 89 + pkg/signature/verify_test.go | 435 +++ .../chart/crds/runtime-rule-binding.crd.yaml | 2 +- .../node-agent/default-rule-binding.yaml | 2 + .../templates/node-agent/default-rules.yaml | 137 +- tests/chart/values.yaml | 4 +- tests/component_test.go | 2388 ++++++++++++++++- tests/resources/aplint_test.go | 368 +++ tests/resources/crypto-miner-deployment.yaml | 24 + .../curl-exec-arg-wildcards-deployment.yaml | 28 + tests/resources/curl-plain-deployment.yaml | 20 + tests/resources/curl-signed-deployment.yaml | 21 + .../curl-user-network-deployment.yaml | 21 + ...url-user-profile-wildcards-deployment.yaml | 21 + .../resources/known-application-profile.yaml | 245 ++ .../resources/known-network-neighborhood.yaml | 49 + .../network-wildcards/01-literal-ipv4.yaml | 26 + .../network-wildcards/02-literal-ipv6.yaml | 27 + .../network-wildcards/03-cidr-ipv4.yaml | 28 + .../network-wildcards/04-cidr-ipv6.yaml | 26 + .../network-wildcards/05-any-ip-sentinel.yaml | 31 + .../network-wildcards/06-any-as-cidr.yaml | 34 + .../network-wildcards/07-mixed-ip-list.yaml | 36 + .../08-deprecated-ipaddress.yaml | 32 + .../network-wildcards/09-dns-literal.yaml | 29 + .../10-dns-leading-wildcard.yaml | 35 + .../11-dns-mid-ellipsis.yaml | 41 + .../12-dns-trailing-star.yaml | 46 + .../13-dns-trailing-dot-normalisation.yaml | 39 + .../14-recursive-star-rejected.yaml | 38 + .../15-egress-and-ingress.yaml | 46 + .../network-wildcards/16-egress-none.yaml | 38 + .../17-realistic-stripe-api.yaml | 58 + .../18-cluster-dns-via-mid-ellipsis.yaml | 55 + .../19-port-protocol-with-cidr.yaml | 41 + .../20-multi-container-mixed-wildcards.yaml | 54 + .../nginx-user-defined-deployment.yaml | 25 + .../nginx-user-profile-deployment.yaml | 22 + tests/resources/user-profile.yaml | 47 - 90 files changed, 10159 insertions(+), 552 deletions(-) create mode 100644 cmd/sign-object/Dockerfile create mode 100644 cmd/sign-object/main.go create mode 100644 pkg/objectcache/containerprofilecache/tamper_alert.go create mode 100644 pkg/objectcache/containerprofilecache/tamper_alert_test.go create mode 100644 pkg/objectcache/containerprofilecache/test32_projection_test.go create mode 100644 pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go create mode 100644 pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go create mode 100644 pkg/signature/annotations.go create mode 100644 pkg/signature/cluster_flow_test.go create mode 100644 pkg/signature/cluster_scenario_test.go create mode 100644 pkg/signature/cosign_adapter.go create mode 100644 pkg/signature/cosign_adapter_test.go create mode 100644 pkg/signature/interface.go create mode 100644 pkg/signature/profiles/adapter_test.go create mode 100644 pkg/signature/profiles/applicationprofile_adapter.go create mode 100644 pkg/signature/profiles/empty_typemeta_test.go create mode 100644 pkg/signature/profiles/networkneighborhood_adapter.go create mode 100644 pkg/signature/profiles/networkneighborhood_adapter_test.go create mode 100644 pkg/signature/profiles/rules_adapter.go create mode 100644 pkg/signature/profiles/rules_adapter_test.go create mode 100644 pkg/signature/profiles/seccompprofile_adapter.go create mode 100644 pkg/signature/sign.go create mode 100644 pkg/signature/sign_test.go create mode 100644 pkg/signature/signer.go create mode 100644 pkg/signature/verifier.go create mode 100644 pkg/signature/verify.go create mode 100644 pkg/signature/verify_test.go create mode 100644 tests/resources/aplint_test.go create mode 100644 tests/resources/crypto-miner-deployment.yaml create mode 100644 tests/resources/curl-exec-arg-wildcards-deployment.yaml create mode 100644 tests/resources/curl-plain-deployment.yaml create mode 100644 tests/resources/curl-signed-deployment.yaml create mode 100644 tests/resources/curl-user-network-deployment.yaml create mode 100644 tests/resources/curl-user-profile-wildcards-deployment.yaml create mode 100644 tests/resources/known-application-profile.yaml create mode 100644 tests/resources/known-network-neighborhood.yaml create mode 100644 tests/resources/network-wildcards/01-literal-ipv4.yaml create mode 100644 tests/resources/network-wildcards/02-literal-ipv6.yaml create mode 100644 tests/resources/network-wildcards/03-cidr-ipv4.yaml create mode 100644 tests/resources/network-wildcards/04-cidr-ipv6.yaml create mode 100644 tests/resources/network-wildcards/05-any-ip-sentinel.yaml create mode 100644 tests/resources/network-wildcards/06-any-as-cidr.yaml create mode 100644 tests/resources/network-wildcards/07-mixed-ip-list.yaml create mode 100644 tests/resources/network-wildcards/08-deprecated-ipaddress.yaml create mode 100644 tests/resources/network-wildcards/09-dns-literal.yaml create mode 100644 tests/resources/network-wildcards/10-dns-leading-wildcard.yaml create mode 100644 tests/resources/network-wildcards/11-dns-mid-ellipsis.yaml create mode 100644 tests/resources/network-wildcards/12-dns-trailing-star.yaml create mode 100644 tests/resources/network-wildcards/13-dns-trailing-dot-normalisation.yaml create mode 100644 tests/resources/network-wildcards/14-recursive-star-rejected.yaml create mode 100644 tests/resources/network-wildcards/15-egress-and-ingress.yaml create mode 100644 tests/resources/network-wildcards/16-egress-none.yaml create mode 100644 tests/resources/network-wildcards/17-realistic-stripe-api.yaml create mode 100644 tests/resources/network-wildcards/18-cluster-dns-via-mid-ellipsis.yaml create mode 100644 tests/resources/network-wildcards/19-port-protocol-with-cidr.yaml create mode 100644 tests/resources/network-wildcards/20-multi-container-mixed-wildcards.yaml create mode 100644 tests/resources/nginx-user-defined-deployment.yaml create mode 100644 tests/resources/nginx-user-profile-deployment.yaml delete mode 100644 tests/resources/user-profile.yaml diff --git a/cmd/main.go b/cmd/main.go index b81d6d15a5..80e777bb01 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -228,7 +228,7 @@ func main() { ruleBindingCache = rulebindingcachev1.NewCache(cfg, k8sClient, ruleCreator) rulesWatcher := ruleswatcher.NewRulesWatcher(k8sClient, ruleCreator, func() { ruleBindingCache.RefreshRuleBindingsRules() - }) + }, &cfg) dWatcher.AddAdaptor(rulesWatcher) } @@ -297,6 +297,15 @@ func main() { ruleBindingCache.AddNotifier(&ruleBindingNotify) cpc := containerprofilecache.NewContainerProfileCache(cfg, storageClient, k8sObjectCache, prometheusExporter) + // Wire the rule-alert exporter into the tamper-detection path so R1016 + // ('Signed profile tampered') alerts actually reach alertmanager when + // a user-defined ApplicationProfile or NetworkNeighborhood fails its + // signature check. Without this call, tamper detection logs the + // failure but no alert is emitted — Test_31_TamperDetectionAlert + // catches the gap. (Lost during the merge/upstream-profile-rearch + // rebase; pkg/objectcache/containerprofilecache/tamper_alert.go has + // the receiver method.) + cpc.SetTamperAlertExporter(exporter) cpc.Start(ctx) logger.L().Info("ContainerProfileCache active; legacy AP/NN caches removed") @@ -395,9 +404,13 @@ func main() { if apiURL == "" { apiURL = "api.armosec.io" } - if services, svcErr := config.LoadServiceURLs(apiURL); svcErr == nil && services.GetReportReceiverHttpUrl() != "" { - failureReporter = sbommanagerv1.NewHTTPSbomFailureReporter(services.GetReportReceiverHttpUrl(), accessKey, clusterData.AccountID, clusterData.ClusterName) - logger.L().Info("scan failure reporting enabled", helpers.String("eventReceiverURL", services.GetReportReceiverHttpUrl())) + if services, svcErr := config.LoadServiceURLs(apiURL); svcErr != nil { + logger.L().Ctx(ctx).Warning("scan failure reporting disabled: LoadServiceURLs failed", helpers.String("apiURL", apiURL), helpers.Error(svcErr)) + } else if url := services.GetReportReceiverHttpUrl(); url == "" { + logger.L().Ctx(ctx).Warning("scan failure reporting disabled: empty report receiver URL", helpers.String("apiURL", apiURL)) + } else { + failureReporter = sbommanagerv1.NewHTTPSbomFailureReporter(url, accessKey, clusterData.AccountID, clusterData.ClusterName) + logger.L().Info("scan failure reporting enabled", helpers.String("eventReceiverURL", url)) } // Create the SBOM manager diff --git a/cmd/sign-object/Dockerfile b/cmd/sign-object/Dockerfile new file mode 100644 index 0000000000..0f4284c473 --- /dev/null +++ b/cmd/sign-object/Dockerfile @@ -0,0 +1,20 @@ +FROM --platform=$BUILDPLATFORM golang:1.25-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 -o /sign-object ./cmd/sign-object + +FROM gcr.io/distroless/static-debian13:latest +COPY --from=builder /sign-object /usr/local/bin/sign-object +WORKDIR /work +ENTRYPOINT ["sign-object"] diff --git a/cmd/sign-object/main.go b/cmd/sign-object/main.go new file mode 100644 index 0000000000..c803320b3c --- /dev/null +++ b/cmd/sign-object/main.go @@ -0,0 +1,550 @@ +package main + +import ( + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "flag" + "fmt" + "os" + "strings" + + k8syaml "k8s.io/apimachinery/pkg/util/yaml" + + 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" + sigsyaml "sigs.k8s.io/yaml" +) + +var ( + inputFile string + outputFile string + keyFile string + objectType string + useKeyless bool + verbose bool + strict bool + jsonOutput bool + publicOnly bool + 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 "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: applicationprofile, seccompprofile, networkneighborhood, rules, or auto") + fs.BoolVar(&useKeyless, "keyless", false, "Use keyless signing (OIDC)") + 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: applicationprofile, seccompprofile, networkneighborhood, rules, 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: applicationprofile, seccompprofile, networkneighborhood, rules, 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() + default: + return fmt.Errorf("unknown command: %s", command) + } +} + +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.SignObjectKeyless(profileAdapter) + } 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)) + } + + 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 := sigsyaml.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 "applicationprofile", "application-profile", "ap": + return loadApplicationProfile(data) + case "seccompprofile", "seccomp-profile", "sp": + return loadSeccompProfile(data) + case "networkneighborhood", "network-neighborhood", "nn": + return loadNetworkNeighborhood(data) + case "rules", "rule", "r": + return loadRules(data) + default: + return nil, fmt.Errorf("unknown object type: %s", objectType) + } + } + + if strings.Contains(strings.ToLower(apiVersion), "softwarecomposition") { + switch strings.ToLower(kind) { + case "applicationprofile", "application-profile": + return loadApplicationProfile(data) + case "seccompprofile", "seccomp-profile": + return loadSeccompProfile(data) + case "networkneighborhood", "network-neighborhood": + return loadNetworkNeighborhood(data) + } + } + + if strings.Contains(strings.ToLower(apiVersion), "kubescape.io") && strings.ToLower(kind) == "rules" { + return loadRules(data) + } + + return nil, fmt.Errorf("unable to auto-detect object type") +} + +func loadApplicationProfile(data []byte) (signature.SignableObject, error) { + var profile v1beta1.ApplicationProfile + if err := k8syaml.Unmarshal(data, &profile); err != nil { + return nil, fmt.Errorf("failed to unmarshal ApplicationProfile: %w", err) + } + return profiles.NewApplicationProfileAdapter(&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 loadNetworkNeighborhood(data []byte) (signature.SignableObject, error) { + var nn v1beta1.NetworkNeighborhood + if err := k8syaml.Unmarshal(data, &nn); err != nil { + return nil, fmt.Errorf("failed to unmarshal NetworkNeighborhood: %w", err) + } + return profiles.NewNetworkNeighborhoodAdapter(&nn), 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 getObjectName(profile signature.SignableObject) string { + if _, ok := profile.(*profiles.ApplicationProfileAdapter); ok { + return "ApplicationProfile" + } + if _, ok := profile.(*profiles.SeccompProfileAdapter); ok { + return "SeccompProfile" + } + if _, ok := profile.(*profiles.NetworkNeighborhoodAdapter); ok { + return "NetworkNeighborhood" + } + if _, ok := profile.(*profiles.RulesAdapter); ok { + return "Rules" + } + 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 + 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: applicationprofile, seccompprofile, networkneighborhood, rules, or auto (default: auto) + --verbose Enable verbose logging + +VERIFY FLAGS: + --file Signed object YAML file (required) + --type Object type: applicationprofile, seccompprofile, networkneighborhood, rules, 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: applicationprofile, seccompprofile, networkneighborhood, rules, or auto (default: auto) + --json Output as JSON + +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 + +For more information, see: docs/signing/README.md`) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 0291d79386..a25bdc5beb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -85,6 +85,7 @@ type Config struct { EnableRuntimeDetection bool `mapstructure:"runtimeDetectionEnabled"` EnableSbomGeneration bool `mapstructure:"sbomGenerationEnabled"` EnableSeccomp bool `mapstructure:"seccompServiceEnabled"` + EnableSignatureVerification bool `mapstructure:"enableSignatureVerification"` HostMonitoringEnabled bool `mapstructure:"hostMonitoringEnabled"` StandaloneMonitoringEnabled bool `mapstructure:"standaloneMonitoringEnabled"` SeccompProfileBackend string `mapstructure:"seccompProfileBackend"` @@ -208,6 +209,7 @@ func LoadConfigOptional(path string, errNotFound bool) (Config, error) { viper.SetDefault("celConfigCache::maxSize", 100000) viper.SetDefault("celConfigCache::ttl", 1*time.Minute) viper.SetDefault("ignoreRuleBindings", false) + viper.SetDefault("enableSignatureVerification", false) viper.SetDefault("eventDedup::enabled", true) viper.SetDefault("eventDedup::slotsExponent", 18) @@ -242,6 +244,7 @@ func LoadConfigOptional(path string, errNotFound bool) (Config, error) { viper.SetDefault("hostSensorInterval", 5*time.Minute) viper.AutomaticEnv() + _ = viper.BindEnv("enableSignatureVerification", "ENABLE_SIGNATURE_VERIFICATION") if err := viper.ReadInConfig(); err != nil { var notFound viper.ConfigFileNotFoundError diff --git a/pkg/containerprofilemanager/v1/event_reporting.go b/pkg/containerprofilemanager/v1/event_reporting.go index 077875fe1a..5a80952e30 100644 --- a/pkg/containerprofilemanager/v1/event_reporting.go +++ b/pkg/containerprofilemanager/v1/event_reporting.go @@ -41,7 +41,28 @@ func (cpm *ContainerProfileManager) ReportCapability(containerID, capability str // invocation pattern), while the rule-side resolver falls back to comm — // leaving the AP entry unreachable to ap.was_executed and producing spurious // "Unexpected process launched" alerts. +// resolveExecPath chooses the canonical recorded path for an exec event. +// Precedence (kept symmetric with the rule-side +// pkg/rulemanager/cel/libraries/parse/parse.go::getExecPathWithExePath +// — divergence here would let runtime queries miss profile entries that +// were recorded under a different key): +// +// 1. argv[0] when it's an absolute path (`/...`) — symlink-faithful. +// In busybox-based images every utility (sh, echo, nslookup, ...) +// is a symlink to /bin/busybox. The kernel-resolved exepath is +// /bin/busybox, but argv[0] preserves the symlink form a user +// invoked. Users author profile.Path with the symlink form, so +// we record the same. +// 2. exepath when argv[0] is bare or empty — kernel-authoritative +// wins. Preserves argv[0]-spoofing protection: an attacker passing +// argv[0]="sshd" while exec'ing /usr/bin/curl gets resolved to the +// real exepath rather than the bare lie. +// 3. argv[0] when bare and exepath empty (fexecve / AT_EMPTY_PATH). +// 4. comm as last resort. func resolveExecPath(exepath, comm string, args []string) string { + if len(args) > 0 && len(args[0]) > 0 && args[0][0] == '/' { + return args[0] + } if exepath != "" { return exepath } diff --git a/pkg/containerprofilemanager/v1/event_reporting_test.go b/pkg/containerprofilemanager/v1/event_reporting_test.go index ee38683d53..ae4509df0a 100644 --- a/pkg/containerprofilemanager/v1/event_reporting_test.go +++ b/pkg/containerprofilemanager/v1/event_reporting_test.go @@ -45,6 +45,26 @@ func TestResolveExecPath(t *testing.T) { args: []string{"sshd", "-i"}, want: "/usr/bin/curl", }, + { + // Busybox symlink: kernel resolves /bin/sh → /bin/busybox and + // reports exepath=/bin/busybox, but argv[0] preserves the + // symlink-as-invoked form (/bin/sh). User-authored profiles + // list /bin/sh (matching how people think). Recording side + // MUST record /bin/sh so rule-side parse.get_exec_path's + // matching precedence (same convention) finds the entry. + name: "busybox symlink — argv[0] absolute /bin/sh, exepath /bin/busybox", + exepath: "/bin/busybox", + comm: "sh", + args: []string{"/bin/sh", "-c", "echo hi"}, + want: "/bin/sh", + }, + { + name: "busybox symlink — argv[0] /usr/bin/nslookup, exepath /bin/busybox", + exepath: "/bin/busybox", + comm: "nslookup", + args: []string{"/usr/bin/nslookup", "example.com"}, + want: "/usr/bin/nslookup", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/containerprofilemanager/v1/lifecycle.go b/pkg/containerprofilemanager/v1/lifecycle.go index 21ff1cf3b9..dc9b8ac45a 100644 --- a/pkg/containerprofilemanager/v1/lifecycle.go +++ b/pkg/containerprofilemanager/v1/lifecycle.go @@ -93,14 +93,17 @@ func (cpm *ContainerProfileManager) addContainer(container *containercollection. return fmt.Errorf("failed to get shared data for container %s: %w", containerID, err) } - // Check if the container should use a user-defined profile + // Check if the container should use a user-defined profile. + // When both an ApplicationProfile and a NetworkNeighborhood are + // user-provided, skip ALL recording — there is nothing to learn. if sharedData.UserDefinedProfile != "" { logger.L().Debug("ignoring container with a user-defined profile", helpers.String("containerID", containerID), helpers.String("containerName", container.Runtime.ContainerName), helpers.String("podName", container.K8s.PodName), helpers.String("namespace", container.K8s.Namespace), - helpers.String("userDefinedProfile", sharedData.UserDefinedProfile)) + helpers.String("userDefinedProfile", sharedData.UserDefinedProfile), + helpers.String("userDefinedNetwork", sharedData.UserDefinedNetwork)) // Close ready channel before removing entry if entry, exists := cpm.getContainerEntry(containerID); exists { entry.readyOnce.Do(func() { diff --git a/pkg/exporters/alert_manager.go b/pkg/exporters/alert_manager.go index d87c3be25b..617495f568 100644 --- a/pkg/exporters/alert_manager.go +++ b/pkg/exporters/alert_manager.go @@ -119,6 +119,12 @@ func (ame *AlertManagerExporter) SendRuleAlert(failedRule types.RuleFailure) { "ppid": fmt.Sprintf("%d", process.PPID), "pcomm": process.Pcomm, "comm": process.Comm, + // exepath: kernel-authoritative process path (when the exec + // event carried it). Symmetric with parse.get_exec_path's + // 3-arg overload + the recording-side resolveExecPath + // precedence — lets downstream tuners (e.g. bobctl) decide + // which path to allow without re-resolving. + "exepath": process.Path, "uid": fmt.Sprintf("%d", process.Uid), "gid": fmt.Sprintf("%d", process.Gid), "trace": trace, diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache.go b/pkg/objectcache/containerprofilecache/containerprofilecache.go index e85f693c35..c539fad0e1 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache.go @@ -15,6 +15,7 @@ import ( "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/exporters" "github.com/kubescape/node-agent/pkg/metricsmanager" "github.com/kubescape/node-agent/pkg/objectcache" "github.com/kubescape/node-agent/pkg/objectcache/callstackcache" @@ -122,6 +123,12 @@ type ContainerProfileCacheImpl struct { specGeneration atomic.Int64 // bumped on each distinct spec hash change nudge chan struct{} // buffered cap 1; signals reconciler on spec change refreshPending atomic.Bool // set when a nudge arrives while refresh is running + + // Tamper detection state (fork-only). See tamper_alert.go for the full + // description; reintroduced here on top of upstream's reshape so the + // legacy R1016 "Signed profile tampered" wiring keeps working. + tamperAlertExporter exporters.Exporter + tamperEmitted sync.Map // tamperKey -> struct{} } // NewContainerProfileCache creates a new ContainerProfileCacheImpl. @@ -398,6 +405,13 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( helpers.Error(userAPErr)) userAP = nil } + // Tamper detection: re-verify the signature on every load. Emits R1016 + // when a signed overlay's signature no longer matches (i.e. content + // has been mutated post-sign). No-op when the overlay is unsigned or + // the tamper-alert exporter has not been wired. + if userAP != nil { + c.verifyUserApplicationProfile(userAP, sharedData.Wlid) + } var userNNErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { userNN, userNNErr = c.storageClient.GetNetworkNeighborhood(rctx, ns, overlayName) @@ -411,6 +425,9 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( helpers.Error(userNNErr)) userNN = nil } + if userNN != nil { + c.verifyUserNetworkNeighborhood(userNN, sharedData.Wlid) + } } // Need SOMETHING to cache. If we have nothing, stay pending and retry. diff --git a/pkg/objectcache/containerprofilecache/projection.go b/pkg/objectcache/containerprofilecache/projection.go index 1ff1bd1032..66f9f34712 100644 --- a/pkg/objectcache/containerprofilecache/projection.go +++ b/pkg/objectcache/containerprofilecache/projection.go @@ -1,6 +1,9 @@ package containerprofilecache import ( + "strings" + + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/node-agent/pkg/utils" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" corev1 "k8s.io/api/core/v1" @@ -62,9 +65,75 @@ func projectUserProfiles( } } + // Fold the user-overlay identity into the merged profile's SyncChecksum + // annotation. Apply (projection_apply.go) reads this into + // ProjectedContainerProfile.SyncChecksum which the rulemanager's + // function_cache uses as part of its invalidation key (see + // pkg/rulemanager/cel/libraries/cache/function_cache.go: + // HashForContainerProfile). + // + // Without this, an empty-baseline + user-overlay container has a + // constant SyncChecksum="" across both "no overlay yet" and "overlay + // merged" states. Stale ap.was_executed=false results computed during + // the no-overlay window would then persist in the cache and the rule + // evaluator would never see the merged user-overlay paths — which is + // the root cause behind Test_32_UnexpectedProcessArguments's R0001 + // precondition failure and the latent R0001-on-nslookup noise in + // Test_28_UserDefinedNetworkNeighborhood. + if userAP != nil || userNN != nil { + stampOverlayIdentity(projected, userAP, userNN) + } + return projected, warnings } +// stampOverlayIdentity appends user-overlay identity (kind/ns/name@RV) +// to the projected ContainerProfile's SyncChecksumMetadataKey annotation. +// Modifies projected.Annotations in place. +// +// The original baseline checksum (if present) is preserved as the prefix +// so distinct baselines still produce distinct keys. Format: +// +// |ap=/@|nn=/@ +// +// Either ap= or nn= segments are omitted when the corresponding overlay +// is nil. RV is the only piece that needs to change for the cache to +// invalidate, but namespace+name are kept so cross-overlay collisions +// (e.g. two different overlays happening to share RV across namespaces) +// don't alias. +// +// IDEMPOTENT: calling stampOverlayIdentity twice with the same overlay +// produces the same final annotation. The annotation is split on `|` +// and only the FIRST segment is kept as "baseline" — any existing +// ap= / nn= suffixes from prior stamps are discarded before being +// re-appended. (CodeRabbit PR #43 critical on projection.go:115: +// projectUserProfiles is called twice in succession in both +// reconciler.go and containerprofilecache.go, feeding the output of +// the first projection back as input to the second. Without this +// strip step, overlay suffixes accumulate on every reconcile tick, +// churning the function_cache.) +func stampOverlayIdentity(projected *v1beta1.ContainerProfile, userAP *v1beta1.ApplicationProfile, userNN *v1beta1.NetworkNeighborhood) { + if projected.Annotations == nil { + projected.Annotations = map[string]string{} + } + // Strip any prior ap= / nn= suffixes by taking only the first + // `|`-segment as the canonical baseline checksum. This is what + // makes repeat-stamping idempotent. + existing := projected.Annotations[helpersv1.SyncChecksumMetadataKey] + baseline := existing + if idx := strings.IndexByte(existing, '|'); idx >= 0 { + baseline = existing[:idx] + } + parts := []string{baseline} + if userAP != nil { + parts = append(parts, "ap="+userAP.Namespace+"/"+userAP.Name+"@"+userAP.ResourceVersion) + } + if userNN != nil { + parts = append(parts, "nn="+userNN.Namespace+"/"+userNN.Name+"@"+userNN.ResourceVersion) + } + projected.Annotations[helpersv1.SyncChecksumMetadataKey] = strings.Join(parts, "|") +} + // mergeApplicationProfile finds the container entry in userAP matching // containerName (across Spec.Containers / InitContainers / EphemeralContainers) // and merges its fields into projected.Spec. Returns the list of pod-spec diff --git a/pkg/objectcache/containerprofilecache/projection_apply.go b/pkg/objectcache/containerprofilecache/projection_apply.go index 1354641886..22f9dbf143 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply.go +++ b/pkg/objectcache/containerprofilecache/projection_apply.go @@ -44,30 +44,37 @@ func Apply(spec *objectcache.RuleProjectionSpec, cp *v1beta1.ContainerProfile, c } // Project each data surface. + // The third arg classifies an entry as "dynamic" — routes it to Patterns + // rather than Values. Path surfaces use the ⋯ DynamicIdentifier marker; + // network surfaces accept CIDRs, '*' sentinels, and DNS wildcard tokens + // per the v0.0.2 spec (matched at runtime by storage's networkmatch). opensPaths := extractOpensPaths(cp) - pcp.Opens = projectField(s.Opens, opensPaths, true) + pcp.Opens = projectField(s.Opens, opensPaths, containsDynamicSegment) execsPaths := extractExecsPaths(cp) - pcp.Execs = projectField(s.Execs, execsPaths, true) + pcp.Execs = projectField(s.Execs, execsPaths, containsDynamicSegment) + pcp.ExecsByPath = extractExecsByPath(cp) endpointPaths := extractEndpointPaths(cp) - pcp.Endpoints = projectField(s.Endpoints, endpointPaths, true) + pcp.Endpoints = projectField(s.Endpoints, endpointPaths, containsDynamicSegment) - pcp.Capabilities = projectField(s.Capabilities, cp.Spec.Capabilities, false) - pcp.Syscalls = projectField(s.Syscalls, cp.Spec.Syscalls, false) + pcp.Capabilities = projectField(s.Capabilities, cp.Spec.Capabilities, nil) + pcp.Syscalls = projectField(s.Syscalls, cp.Spec.Syscalls, nil) - pcp.EgressDomains = projectField(s.EgressDomains, extractEgressDomains(cp), false) - pcp.EgressAddresses = projectField(s.EgressAddresses, extractEgressAddresses(cp), false) + pcp.EgressDomains = projectField(s.EgressDomains, extractEgressDomains(cp), isNetworkDNSWildcard) + pcp.EgressAddresses = projectField(s.EgressAddresses, extractEgressAddresses(cp), isNetworkIPWildcard) - pcp.IngressDomains = projectField(s.IngressDomains, extractIngressDomains(cp), false) - pcp.IngressAddresses = projectField(s.IngressAddresses, extractIngressAddresses(cp), false) + pcp.IngressDomains = projectField(s.IngressDomains, extractIngressDomains(cp), isNetworkDNSWildcard) + pcp.IngressAddresses = projectField(s.IngressAddresses, extractIngressAddresses(cp), isNetworkIPWildcard) return pcp } // projectField is the per-surface transform. rawEntries are strings from the -// raw profile. isPathSurface enables retention of dynamic-segment entries. -func projectField(spec objectcache.FieldSpec, rawEntries []string, isPathSurface bool) objectcache.ProjectedField { +// raw profile. isDynamic, if non-nil, is called per entry: returning true +// routes the entry to Patterns rather than Values (cache-miss path runs the +// matcher rather than a map lookup). +func projectField(spec objectcache.FieldSpec, rawEntries []string, isDynamic func(string) bool) objectcache.ProjectedField { if !spec.InUse { // No rule declared a requirement for this field — pass all raw entries // through so existing rules that omit profileDataRequired keep working. @@ -92,9 +99,9 @@ func projectField(spec objectcache.FieldSpec, rawEntries []string, isPathSurface seen := make(map[string]bool) // for Patterns dedup for _, e := range rawEntries { - isDynamic := isPathSurface && containsDynamicSegment(e) + dynamic := isDynamic != nil && isDynamic(e) - if isDynamic { + if dynamic { // Dynamic entries always go to Patterns on path surfaces (both // pass-through and explicit InUse modes). if !seen[e] { @@ -148,6 +155,42 @@ func containsDynamicSegment(e string) bool { return strings.Contains(e, dynamicpathdetector.DynamicIdentifier) } +// isNetworkIPWildcard reports whether an IP-surface entry is a v0.0.2 +// pattern (CIDR membership, '*' any-IP sentinel, or DynamicIdentifier). +// Literal IPv4/IPv6 addresses are NOT patterns; they go to Values for +// the cheap map lookup path. Spec §5.7. +func isNetworkIPWildcard(e string) bool { + if e == "" { + return false + } + if e == "*" { + return true + } + if strings.Contains(e, "/") { + return true + } + if strings.Contains(e, dynamicpathdetector.DynamicIdentifier) { + return true + } + return false +} + +// isNetworkDNSWildcard reports whether a DNS-surface entry uses any of +// the v0.0.2 wildcard tokens — leading '*' (RFC 4592), mid '⋯', trailing +// '*'. Literal FQDNs go to Values. Spec §5.8. +func isNetworkDNSWildcard(e string) bool { + if e == "" { + return false + } + if strings.Contains(e, "*") { + return true + } + if strings.Contains(e, dynamicpathdetector.DynamicIdentifier) { + return true + } + return false +} + // --- Field extractors --- func extractOpensPaths(cp *v1beta1.ContainerProfile) []string { @@ -166,6 +209,32 @@ func extractExecsPaths(cp *v1beta1.ContainerProfile) []string { return paths } +// extractExecsByPath builds the path → args map used by the exec-args +// wildcard matcher (CompareExecArgs). Multiple ExecCalls entries with the +// same Path collapse to the last seen; this matches the prior fork-only +// behavior. nil-Args entries are stored as empty slices, which +// CompareExecArgs treats as "no argv constraint". +// +// Args slices are CLONED rather than aliased — Apply is contract-bound to +// be a pure transform, and an alias would let consumers mutate the source +// profile by editing the projected map. (CR #43 finding on this file.) +func extractExecsByPath(cp *v1beta1.ContainerProfile) map[string][]string { + if len(cp.Spec.Execs) == 0 { + return nil + } + m := make(map[string][]string, len(cp.Spec.Execs)) + for _, e := range cp.Spec.Execs { + if e.Args == nil { + m[e.Path] = []string{} + continue + } + cloned := make([]string, len(e.Args)) + copy(cloned, e.Args) + m[e.Path] = cloned + } + return m +} + func extractEndpointPaths(cp *v1beta1.ContainerProfile) []string { endpoints := make([]string, len(cp.Spec.Endpoints)) for i, e := range cp.Spec.Endpoints { @@ -191,6 +260,9 @@ func extractEgressAddresses(cp *v1beta1.ContainerProfile) []string { if n.IPAddress != "" { addrs = append(addrs, n.IPAddress) } + // v0.0.2 IPAddresses[] — list form supporting CIDRs and '*' sentinel. + // Same semantics as the deprecated singular IPAddress, just plural. + addrs = append(addrs, n.IPAddresses...) } return addrs } @@ -212,6 +284,7 @@ func extractIngressAddresses(cp *v1beta1.ContainerProfile) []string { if n.IPAddress != "" { addrs = append(addrs, n.IPAddress) } + addrs = append(addrs, n.IPAddresses...) } return addrs } diff --git a/pkg/objectcache/containerprofilecache/tamper_alert.go b/pkg/objectcache/containerprofilecache/tamper_alert.go new file mode 100644 index 0000000000..273b151231 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/tamper_alert.go @@ -0,0 +1,190 @@ +// Tamper detection for user-supplied profile overlays loaded into the +// ContainerProfileCache. +// +// When a user references a signed ApplicationProfile or NetworkNeighborhood +// via the `kubescape.io/user-defined-profile` pod label, this code 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. +// +// This is the new home of the legacy applicationprofilecache's tamper +// detection (originally introduced in fork commit c2d681e0 — "Feat/ +// tamperalert"). Upstream PR #788 deleted the legacy cache; this re-wires +// the same behavior onto containerprofilecache without changing the alert +// shape so existing component tests (Test_31_TamperDetectionAlert) keep +// working. +package containerprofilecache + +import ( + "errors" + "fmt" + + "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 +} + +// verifyUserApplicationProfile re-verifies the signature of a user-supplied +// ApplicationProfile overlay and emits R1016 if the signature is present +// but no longer valid (i.e. the profile was tampered after signing). +// +// Returns true iff the profile is acceptable for further use: +// - profile is signed and verifies → true +// - profile is not signed → true (signing is opt-in; the empty-signature +// case is handled by the caller's normal not-signed flow) +// - profile is signed but verification fails → false (and R1016 emitted) +// +// The boolean lets the caller decide whether to project the overlay into +// the cache. Today we always proceed (the legacy semantics don't actually +// gate loading on verification unless EnableSignatureVerification is true), +// but having the return value keeps the door open for stricter modes. +func (c *ContainerProfileCacheImpl) verifyUserApplicationProfile(profile *v1beta1.ApplicationProfile, wlid string) bool { + if profile == nil { + return true + } + adapter := profiles.NewApplicationProfileAdapter(profile) + if !signature.IsSigned(adapter) { + return true + } + key := tamperKey("ApplicationProfile", 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) + return true + } + // Classify the error: only ErrSignatureMismatch indicates an actual + // tamper event. Hash-computation, verifier-construction, and malformed- + // annotation errors are operational and MUST NOT raise R1016 — that + // would cause false alerts and, with EnableSignatureVerification=true, + // drop a valid overlay because of a transient operational failure. + if !errors.Is(err, signature.ErrSignatureMismatch) { + logger.L().Warning("user-defined ApplicationProfile signature verification operational error (NOT tamper)", + helpers.String("profile", profile.Name), + helpers.String("namespace", profile.Namespace), + helpers.String("wlid", wlid), + helpers.Error(err)) + // Honour strict-mode: refuse to load on any verification failure, + // but do NOT touch the dedup map or emit R1016. + return !c.cfg.EnableSignatureVerification + } + // Real tamper. + logger.L().Warning("user-defined ApplicationProfile 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, "ApplicationProfile", err) + } + return !c.cfg.EnableSignatureVerification +} + +// verifyUserNetworkNeighborhood is the NN-side counterpart to +// verifyUserApplicationProfile. Same contract, different object kind in +// the alert description. +func (c *ContainerProfileCacheImpl) verifyUserNetworkNeighborhood(nn *v1beta1.NetworkNeighborhood, wlid string) bool { + if nn == nil { + return true + } + adapter := profiles.NewNetworkNeighborhoodAdapter(nn) + if !signature.IsSigned(adapter) { + return true + } + key := tamperKey("NetworkNeighborhood", nn.Namespace, nn.Name, nn.ResourceVersion) + err := signature.VerifyObjectAllowUntrusted(adapter) + if err == nil { + c.tamperEmitted.Delete(key) + return true + } + // Same classification as the AP path — only ErrSignatureMismatch is a + // tamper; everything else is operational and must NOT trigger R1016. + if !errors.Is(err, signature.ErrSignatureMismatch) { + logger.L().Warning("user-defined NetworkNeighborhood signature verification operational error (NOT tamper)", + helpers.String("profile", nn.Name), + helpers.String("namespace", nn.Namespace), + helpers.String("wlid", wlid), + helpers.Error(err)) + return !c.cfg.EnableSignatureVerification + } + logger.L().Warning("user-defined NetworkNeighborhood signature mismatch (tamper detected)", + helpers.String("profile", nn.Name), + helpers.String("namespace", nn.Namespace), + helpers.String("wlid", wlid), + helpers.Error(err)) + if _, alreadyEmitted := c.tamperEmitted.LoadOrStore(key, struct{}{}); !alreadyEmitted { + c.emitTamperAlert(nn.Name, nn.Namespace, wlid, "NetworkNeighborhood", err) + } + return !c.cfg.EnableSignatureVerification +} + +// emitTamperAlert sends a single R1016 "Signed profile tampered" alert +// through the rule-alert exporter. No-op when the exporter is unset. +// +// Alert shape mirrors the legacy applicationprofilecache.emitTamperAlert +// (fork commit c2d681e0) so dashboards and component tests keep matching. +// `wlid` should be the authoritative workload identifier the caller has on +// hand (e.g. sharedData.Wlid in containerprofilecache.go) — 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.", + }, + 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) +} diff --git a/pkg/objectcache/containerprofilecache/tamper_alert_test.go b/pkg/objectcache/containerprofilecache/tamper_alert_test.go new file mode 100644 index 0000000000..03fa7b0a88 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/tamper_alert_test.go @@ -0,0 +1,281 @@ +// Unit tests pinning the tamper-vs-operational error classification in +// the cache's verify path. CodeRabbit PR #38 finding (tamper_alert.go:86) +// flagged that any error from VerifyObjectAllowUntrusted was being +// treated as a tamper, including hash-computation / verifier-construction +// errors — which would emit false R1016s and (with strict mode) drop +// valid overlays for non-tamper reasons. +// +// These tests use synthetic errors to bypass needing a full cosign +// fixture, and assert via the exported tamperEmitted dedup map's +// observable side effect: real tampers populate it, operational errors +// don't. +package containerprofilecache + +import ( + "errors" + "fmt" + "sync" + "testing" + + "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" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// captureExporter records every SendRuleAlert call for assertion in tests. +// The interface is exporters.Exporter — only SendRuleAlert needs real +// behaviour here; the rest are no-ops for the unit-test scope. +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 +} + +// TestVerifyClassification_TamperPopulatesDedupMap confirms that an +// ErrSignatureMismatch-wrapped error is treated as a real tamper: +// LoadOrStore should set the key and emit (we observe via the map). +func TestVerifyClassification_TamperPopulatesDedupMap(t *testing.T) { + c := &ContainerProfileCacheImpl{} + key := tamperKey("ApplicationProfile", "ns", "p", "1") + + // Synthesise the wrapped error that VerifyObject returns on actual + // signature mismatch. + tamperErr := fmt.Errorf("%w: %w", signature.ErrSignatureMismatch, errors.New("crypto/ecdsa: verify error")) + + if !errors.Is(tamperErr, signature.ErrSignatureMismatch) { + t.Fatalf("test fixture wrong: errors.Is(tamperErr, ErrSignatureMismatch) returned false") + } + + // First-transition path: LoadOrStore returns alreadyEmitted=false. + _, alreadyEmitted := c.tamperEmitted.LoadOrStore(key, struct{}{}) + if alreadyEmitted { + t.Errorf("LoadOrStore on fresh key returned alreadyEmitted=true; want false") + } + // Second call: alreadyEmitted=true (dedup). + _, alreadyEmitted = c.tamperEmitted.LoadOrStore(key, struct{}{}) + if !alreadyEmitted { + t.Errorf("LoadOrStore on already-stored key returned false; want true") + } +} + +// TestVerifyClassification_OperationalErrorDistinguishable confirms that +// an operational error (no ErrSignatureMismatch wrap) returns false on +// errors.Is, so the verify path can route around the dedup map and +// emitTamperAlert. +func TestVerifyClassification_OperationalErrorDistinguishable(t *testing.T) { + cases := []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"))}, + {"adapter construction failure", fmt.Errorf("failed to create cosign adapter: %w", errors.New("config invalid"))}, + {"decode signature failure", fmt.Errorf("failed to decode signature from annotations: %w", errors.New("base64 invalid"))}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if errors.Is(tc.err, signature.ErrSignatureMismatch) { + t.Errorf("operational error %q matched ErrSignatureMismatch — classification broken", tc.err) + } + }) + } +} + +// TestVerifyClassification_ErrSignatureMismatchValue is a smoke test that +// the sentinel exists with the canonical message ("signature verification +// failed"), so log scraping / alert pipelines that match the substring +// continue to work. +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 (want %q)", signature.ErrSignatureMismatch.Error(), "signature verification failed") + } +} + +// TestVerifyAP_TamperedProfile_PopulatesDedupMap exercises the full +// verifyUserApplicationProfile path end-to-end (per CodeRabbit nitpick on +// PR #38, tamper_alert_test.go:47): sign a real ApplicationProfile, +// mutate its content (fake tamper), call the verify method, and confirm +// the dedup map carries the tamperKey afterward. Confirms the wiring +// from "verifier returns ErrSignatureMismatch" all the way through the +// classification + LoadOrStore branch. +func TestVerifyAP_TamperedProfile_PopulatesDedupMap(t *testing.T) { + profile := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tampered", + Namespace: "test-ns", + ResourceVersion: "42", + UID: "ap-uid-tamper", + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{{Name: "test"}}, + }, + } + + // Sign with a real cosign signer (test-only; uses an ephemeral key + // from the cosign adapter — no Sigstore Fulcio interaction). + adapter := profiles.NewApplicationProfileAdapter(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. Verification will + // recompute the content hash, find it differs from the signed hash, + // and return ErrSignatureMismatch. + profile.Spec.Containers[0].Name = "MUTATED" + + c := &ContainerProfileCacheImpl{} + ok := c.verifyUserApplicationProfile(profile, "wlid://test/cluster/ns/Pod/p") + // EnableSignatureVerification is false (zero-value) → returns true + // even though tamper was detected. R1016 emit is dedup-tracked via + // tamperEmitted regardless. + if !ok { + t.Errorf("verify returned false; expected true (legacy permissive mode)") + } + + key := tamperKey("ApplicationProfile", profile.Namespace, profile.Name, profile.ResourceVersion) + if _, found := c.tamperEmitted.Load(key); !found { + t.Errorf("tamperEmitted missing key %q after a real tamper — wiring from verifier-error to dedup map is broken", key) + } + + // Second call on the SAME tampered profile must not re-flag the key + // as a new emit (dedup). + _, alreadyEmitted := c.tamperEmitted.LoadOrStore(key, struct{}{}) + if !alreadyEmitted { + t.Errorf("dedup broken: re-storing existing key returned alreadyEmitted=false") + } + + // Re-sign over the mutated content at the SAME ResourceVersion — the + // verifier now sees a valid signature over the current spec, so + // verifyUserApplicationProfile MUST take the verify-clean branch + // and Delete the existing dedup entry. CodeRabbit nitpick on PR + // #38 (tamper_alert_test.go:159): the prior version of this test + // bumped RV before the re-sign, so the assertion checked a key + // that was never added — trivially true. This now actually + // exercises the clearing path. + if err := signature.SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("re-sign profile: %v", err) + } + ok = c.verifyUserApplicationProfile(profile, "wlid://test/cluster/ns/Pod/p") + if !ok { + 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 a successful re-verify at the same RV; the verify-clean path must Delete it", key) + } +} + +// TestVerifyAP_TamperedProfile_EmitsR1016ViaExporter pins the wiring +// contract that was missing before: verifyUserApplicationProfile must +// invoke the wired tamperAlertExporter exactly once per tamper event, +// with a properly-shaped R1016 RuleFailure. Without this, the +// SetTamperAlertExporter plumbing landed but the alert never reached +// the exporter because the verify method was orphan code, never +// invoked from production (the bug that caused +// Test_31_TamperDetectionAlert to fail at the integration level). +func TestVerifyAP_TamperedProfile_EmitsR1016ViaExporter(t *testing.T) { + profile := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tampered-emit", + Namespace: "test-ns", + ResourceVersion: "1", + UID: "ap-uid-emit", + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{{Name: "test"}}, + }, + } + + adapter := profiles.NewApplicationProfileAdapter(profile) + if err := signature.SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("sign profile: %v", err) + } + profile.Spec.Containers[0].Name = "MUTATED" + + exporter := &captureExporter{} + c := &ContainerProfileCacheImpl{} + c.SetTamperAlertExporter(exporter) + + c.verifyUserApplicationProfile(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) + } + + // Second call same RV: dedup must hold — exporter sees no new alert. + c.verifyUserApplicationProfile(profile, "wlid://test/cluster/ns/Pod/p") + if got := len(exporter.ruleAlerts()); got != 1 { + t.Errorf("after dedup-tracked re-call, exporter has %d alerts; want 1", got) + } + + // Bump RV: tamperKey changes → dedup map is keyed on (kind, ns, name, RV) + // so the bumped RV must produce a fresh alert. + profile.ResourceVersion = "2" + c.verifyUserApplicationProfile(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) + } +} + +// TestVerifyAP_OperationalError_DoesNotEmit pins the inverse contract: +// when verification fails with a non-tamper error (hash compute, +// verifier construction, decode), the exporter must NOT receive an +// R1016 — operational errors are logged and either dropped or surfaced +// via strict-mode loading refusal, but never as a tamper alert. +func TestVerifyAP_OperationalError_DoesNotEmit(t *testing.T) { + // Construct an AP with an UNSIGNED-looking annotation set so + // IsSigned returns false — verify exits early without invoking the + // cosign path at all. Confirms the unsigned short-circuit emits + // nothing. + profile := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unsigned", + Namespace: "test-ns", + ResourceVersion: "1", + }, + } + + exporter := &captureExporter{} + c := &ContainerProfileCacheImpl{} + c.SetTamperAlertExporter(exporter) + + c.verifyUserApplicationProfile(profile, "wlid://test/cluster/ns/Pod/p") + if got := len(exporter.ruleAlerts()); got != 0 { + t.Errorf("unsigned AP produced %d R1016 alerts; want 0", got) + } +} diff --git a/pkg/objectcache/containerprofilecache/test32_projection_test.go b/pkg/objectcache/containerprofilecache/test32_projection_test.go new file mode 100644 index 0000000000..b41613671e --- /dev/null +++ b/pkg/objectcache/containerprofilecache/test32_projection_test.go @@ -0,0 +1,326 @@ +package containerprofilecache + +import ( + "testing" + + "github.com/kubescape/node-agent/pkg/objectcache" + "github.com/kubescape/node-agent/pkg/objectcache/callstackcache" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestT32_UserOverlayExecsReachProjectedValues pins the contract that +// Test_32_UnexpectedProcessArguments depends on end-to-end: when a user- +// defined ApplicationProfile overlay supplies Execs entries for a +// container, those paths MUST appear in the projected ContainerProfile's +// Execs.Values so ap.was_executed lookups succeed and R0001 stays +// silent on user-allowed paths. +// +// Test_32 has been failing on the R0001-silence precondition even after +// the bare-name path enumeration in the test's profile. That can only +// happen if one of these projection steps drops the entries: +// +// 1. projectUserProfiles → mergeApplicationProfile fails to copy +// userAP.Spec.Containers[i].Execs into projected.Spec.Execs +// 2. Apply → extractExecsPaths walks projected.Spec.Execs[i].Path but +// misses entries +// 3. projectField → entries end up in Patterns or get filtered out +// instead of landing in Values +// +// This test stresses (1)+(2)+(3) end-to-end with an empty baseline +// (mirrors the real Test_32 scenario where the agent's recording side +// correctly skips learning for user-defined-profile containers). +func TestT32_UserOverlayExecsReachProjectedValues(t *testing.T) { + // Empty baseline ContainerProfile (matches what the reconciler + // synthesises when no baseline exists for a user-defined-profile- + // labelled container). + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "replicaset-curl-32-6d44f5f86b", + Namespace: "ns", + }, + } + + // User-defined AP with the same Execs shape Test_32 uses + // (post-c3b692ed, both full-path and bare-name variants). + userAP := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "curl-32-overlay", Namespace: "ns"}, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sh", Args: []string{"sh", "-c", "*"}}, + {Path: "sh", Args: []string{"sh", "-c", "*"}}, + {Path: "/bin/echo", Args: []string{"echo", "hello", "*"}}, + }, + }, + }, + }, + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "p", Namespace: "ns"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "curl"}}, + }, + } + + merged, _ := projectUserProfiles(cp, userAP, nil, pod, "curl") + if merged == nil { + t.Fatalf("projectUserProfiles returned nil") + } + + // After merge, projected.Spec.Execs must contain all 3 user-overlay + // Execs paths. + gotPaths := map[string]bool{} + for _, e := range merged.Spec.Execs { + gotPaths[e.Path] = true + } + wantPaths := []string{"/bin/sh", "sh", "/bin/echo"} + for _, p := range wantPaths { + if !gotPaths[p] { + t.Errorf("merge failed: path %q missing from merged.Spec.Execs (got: %v)", p, gotPaths) + } + } + + // Apply with a default RuleProjectionSpec (InUse=false → All=true → + // pass-through; matches what R0001 hits when no rule declares a + // specific Execs requirement). + spec := &objectcache.RuleProjectionSpec{} + tree := callstackcache.NewCallStackSearchTree() + projected := Apply(spec, merged, tree) + + if projected == nil { + t.Fatal("Apply returned nil") + } + if projected.Execs.Values == nil { + t.Fatalf("projected.Execs.Values is nil — projection dropped all entries") + } + for _, p := range wantPaths { + if _, ok := projected.Execs.Values[p]; !ok { + t.Errorf("projection dropped %q: projected.Execs.Values=%v", p, projected.Execs.Values) + } + } + + // ExecsByPath is the path → args map used by R0040's + // was_executed_with_args. Must also carry all 3 user paths. + for _, p := range wantPaths { + if _, ok := projected.ExecsByPath[p]; !ok { + t.Errorf("ExecsByPath missing path %q (got keys: %v)", p, mapKeys(projected.ExecsByPath)) + } + } +} + +// TestT32_StampOverlayIdentity_Idempotent pins the contract behind the +// CodeRabbit critical finding on projection.go:115 (PR #43): stamping +// the same overlay identity twice MUST produce the same SyncChecksum +// as stamping it once. Both reconciler.go and tryPopulateEntry path +// through projectUserProfiles, and a reconciler tick that re-stamps +// an already-stamped projected ContainerProfile must NOT accumulate +// overlay suffixes. +// +// Bug shape (pre-fix): stampOverlayIdentity reads the existing +// SyncChecksumMetadataKey annotation as "baseline" and appends new +// overlay suffixes to it. On the second call, the first call's +// "ap=ns/name@RV" segment is treated as part of the "baseline" and +// gets a second "ap=ns/name@RV" appended. Result: +// +// baseline: "" +// first stamp: "|ap=ns/curl@1" +// second stamp: "|ap=ns/curl@1|ap=ns/curl@1" ← BUG: duplicated +// +// The cache key keeps changing across reconciler ticks even though +// the overlay didn't change — invalidates the function_cache on every +// tick, churning expensive recomputations. +func TestT32_StampOverlayIdentity_Idempotent(t *testing.T) { + userAP := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "curl-32-overlay", + Namespace: "ns", + ResourceVersion: "42", + }, + } + + // Stamp once on a fresh cp; capture the checksum. + cp1 := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Name: "cp"}} + stampOverlayIdentity(cp1, userAP, nil) + once := cp1.Annotations["kubescape.io/sync-checksum"] + + // Stamp twice on a different fresh cp (simulates reconciler tick + // re-projecting an already-projected entry). + cp2 := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Name: "cp"}} + stampOverlayIdentity(cp2, userAP, nil) + stampOverlayIdentity(cp2, userAP, nil) + twice := cp2.Annotations["kubescape.io/sync-checksum"] + + if once != twice { + t.Errorf("stampOverlayIdentity not idempotent on repeat-stamp:\n once: %q\n twice: %q\n"+ + "overlay suffixes accumulate, churning the function_cache on every reconcile.", once, twice) + } + + // Three times must also equal once. + cp3 := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Name: "cp"}} + stampOverlayIdentity(cp3, userAP, nil) + stampOverlayIdentity(cp3, userAP, nil) + stampOverlayIdentity(cp3, userAP, nil) + if got := cp3.Annotations["kubescape.io/sync-checksum"]; got != once { + t.Errorf("triple-stamp also non-idempotent: got %q want %q", got, once) + } +} + +// TestT32_StampOverlayIdentity_PreservesBaseline pins that a non-empty +// baseline SyncChecksum survives the stamp (we don't blow away the +// learned profile's content hash; we extend it). Distinct baselines +// must produce distinct keys after stamping. +func TestT32_StampOverlayIdentity_PreservesBaseline(t *testing.T) { + userAP := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "ovrl", Namespace: "ns", ResourceVersion: "1"}, + } + + cpA := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cp", + Annotations: map[string]string{"kubescape.io/sync-checksum": "baseline-A"}, + }, + } + stampOverlayIdentity(cpA, userAP, nil) + + cpB := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cp", + Annotations: map[string]string{"kubescape.io/sync-checksum": "baseline-B"}, + }, + } + stampOverlayIdentity(cpB, userAP, nil) + + if cpA.Annotations["kubescape.io/sync-checksum"] == cpB.Annotations["kubescape.io/sync-checksum"] { + t.Errorf("distinct baselines produced same stamped checksum — baseline lost during stamp") + } +} + +// TestT32_SyncChecksumReflectsUserOverlayIdentity pins the contract +// that the cache-invalidation key (ProjectedContainerProfile.SyncChecksum) +// CHANGES when a user-overlay AP is added to a previously empty +// baseline. Without this, the rulemanager's function_cache caches an +// "was_executed=false" result computed BEFORE the overlay merged and +// returns it forever — the bug behind Test_32's persistent failure +// where user-overlay /bin/sh in profile.Spec.Execs never reaches the +// rule evaluator's cached lookup result. +// +// HashForContainerProfile in pkg/rulemanager/cel/libraries/cache/ +// function_cache.go:105 builds the cache key as +// SpecHash + "|" + SyncChecksum. SpecHash only tracks rule changes. +// SyncChecksum is the ONLY field that's supposed to flip when the +// underlying profile content changes. +// +// Failure mode: empty baseline + first projection (no overlay yet, +// transient fetch error) → SyncChecksum=""; rule caches result; +// reconciler later succeeds the overlay fetch and re-projects → still +// SyncChecksum="" because cp.Annotations[SyncChecksumMetadataKey] +// only reflects the BASELINE, not the merged user-overlay identity. +func TestT32_SyncChecksumReflectsUserOverlayIdentity(t *testing.T) { + // Empty baseline (matches reconciler's synthesised effectiveCP for + // a user-defined-profile-labelled container). + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "replicaset-curl-32-6d44f5f86b", + Namespace: "ns", + // Reconciler-synthesised baselines do NOT carry a + // SyncChecksumMetadataKey annotation. The bug is that the + // projected SyncChecksum stays "" across both states. + }, + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "p", Namespace: "ns"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "curl"}}, + }, + } + + spec := &objectcache.RuleProjectionSpec{} + tree := callstackcache.NewCallStackSearchTree() + + // Stage 1: project WITHOUT user-overlay (first-pass under transient + // fetch failure). Compute SyncChecksum_before. + mergedNoOverlay, _ := projectUserProfiles(cp, nil, nil, pod, "curl") + projectedNoOverlay := Apply(spec, mergedNoOverlay, tree) + syncBefore := projectedNoOverlay.SyncChecksum + + // Stage 2: project WITH a user-overlay AP. Same baseline, same + // container. SyncChecksum_after MUST differ from SyncChecksum_before. + userAP := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "curl-32-overlay", + Namespace: "ns", + ResourceVersion: "12345", + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{{Path: "/bin/sh", Args: []string{"sh", "-c", "*"}}}, + }, + }, + }, + } + mergedWithOverlay, _ := projectUserProfiles(cp, userAP, nil, pod, "curl") + projectedWithOverlay := Apply(spec, mergedWithOverlay, tree) + syncAfter := projectedWithOverlay.SyncChecksum + + if syncBefore == syncAfter { + t.Errorf("SyncChecksum did not change after user-overlay merge: before=%q after=%q. "+ + "The function_cache key won't invalidate when the overlay arrives, so "+ + "stale was_executed=false results poison the rule evaluator indefinitely. "+ + "Apply (projection_apply.go) must fold user-overlay identity (e.g. userAP.ResourceVersion) "+ + "into projected.SyncChecksum.", + syncBefore, syncAfter) + } + + // Stage 3: project with a DIFFERENT user-overlay AP (e.g., the + // overlay was updated post-deployment). SyncChecksum_third MUST + // differ from syncAfter so the cache picks up the change. + userAPUpdated := userAP.DeepCopy() + userAPUpdated.ResourceVersion = "12346" + userAPUpdated.Spec.Containers[0].Execs = append(userAPUpdated.Spec.Containers[0].Execs, + v1beta1.ExecCalls{Path: "/bin/echo", Args: []string{"echo", "*"}}) + mergedWithUpdated, _ := projectUserProfiles(cp, userAPUpdated, nil, pod, "curl") + projectedWithUpdated := Apply(spec, mergedWithUpdated, tree) + syncThird := projectedWithUpdated.SyncChecksum + + if syncAfter == syncThird { + t.Errorf("SyncChecksum did not change after user-overlay update (RV %s → %s, +1 Exec entry): "+ + "before-update=%q after-update=%q. Updates to the overlay won't invalidate cached lookups.", + userAP.ResourceVersion, userAPUpdated.ResourceVersion, syncAfter, syncThird) + } + + // Stage 4: project AGAIN without an overlay (simulates the overlay + // label being removed from the pod, or the overlay AP being deleted + // from storage). SyncChecksum MUST fall back to a value DISTINCT + // from the overlay-stamped one, so the function_cache invalidates + // when the overlay disappears. CodeRabbit PR #43 nitpick on + // test32_projection_test.go:210. + mergedRemoved, _ := projectUserProfiles(cp, nil, nil, pod, "curl") + projectedRemoved := Apply(spec, mergedRemoved, tree) + syncRemoved := projectedRemoved.SyncChecksum + + if syncRemoved == syncThird { + t.Errorf("SyncChecksum did not change after user-overlay REMOVAL: "+ + "with-overlay=%q without-overlay=%q. Removing the overlay won't invalidate cached lookups.", + syncThird, syncRemoved) + } + if syncRemoved != syncBefore { + t.Errorf("after overlay removal, SyncChecksum should match the baseline-only state: "+ + "removed=%q baseline-only=%q", syncRemoved, syncBefore) + } +} + +func mapKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/pkg/objectcache/projection_types.go b/pkg/objectcache/projection_types.go index ed55d671b6..3b64496029 100644 --- a/pkg/objectcache/projection_types.go +++ b/pkg/objectcache/projection_types.go @@ -54,6 +54,13 @@ type ProjectedContainerProfile struct { IngressDomains ProjectedField IngressAddresses ProjectedField + // ExecsByPath carries the per-Path Args slice from cp.Spec.Execs so + // the v0.0.2 exec-args wildcard matching (dynamicpathdetector.CompareExecArgs) + // can run against the projected profile. Keyed by Exec.Path (matches the + // key used in Execs.Values / Execs.Patterns). Upstream projection-v1 + // dropped argv matching as "future work"; this re-adds it on the fork. + ExecsByPath map[string][]string + SpecHash string SyncChecksum string PolicyByRuleId map[string]v1beta1.RulePolicy diff --git a/pkg/objectcache/shared_container_data.go b/pkg/objectcache/shared_container_data.go index 49ac5d7ed0..606ed3bd21 100644 --- a/pkg/objectcache/shared_container_data.go +++ b/pkg/objectcache/shared_container_data.go @@ -19,6 +19,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// UserDefinedNetworkMetadataKey is the pod label that references a +// user-provided NetworkNeighborhood resource by name (analogous to +// helpersv1.UserDefinedProfileMetadataKey for ApplicationProfiles). +const UserDefinedNetworkMetadataKey = "kubescape.io/user-defined-network" + type ContainerType int const ( @@ -81,6 +86,7 @@ type WatchedContainerData struct { PreviousReportTimestamp time.Time CurrentReportTimestamp time.Time UserDefinedProfile string + UserDefinedNetwork string LabelOverrides map[string]string // optional label overrides applied after GetLabels() LearningPeriod time.Duration } @@ -172,6 +178,16 @@ func (watchedContainer *WatchedContainerData) SetContainerInfo(wl workloadinterf watchedContainer.UserDefinedProfile = userDefinedProfile } } + // check for user defined network neighborhood + if userDefinedNetwork, ok := labels[UserDefinedNetworkMetadataKey]; ok { + if userDefinedNetwork != "" { + logger.L().Info("container has a user defined network neighborhood", + helpers.String("network", userDefinedNetwork), + helpers.String("container", containerName), + helpers.String("workload", wl.GetName())) + watchedContainer.UserDefinedNetwork = userDefinedNetwork + } + } podSpec, err := wl.GetPodSpec() if err != nil { return fmt.Errorf("failed to get pod spec: %w", err) diff --git a/pkg/objectcache/v1/mock.go b/pkg/objectcache/v1/mock.go index c618e24506..69bc22961c 100644 --- a/pkg/objectcache/v1/mock.go +++ b/pkg/objectcache/v1/mock.go @@ -151,10 +151,28 @@ func (r *RuleObjectCacheMock) GetProjectedContainerProfile(containerID string) * } if (!specInstalled || spec.Execs.InUse) && len(cp.Spec.Execs) > 0 { - pcp.Execs.All = true pcp.Execs.Values = make(map[string]struct{}, len(cp.Spec.Execs)) + // Route dynamic-segment paths to Patterns so dynamicpathdetector + // can match them; literals to Values for the fast map lookup. for _, e := range cp.Spec.Execs { - pcp.Execs.Values[e.Path] = struct{}{} + if mockContainsDynamicSegment(e.Path) { + pcp.Execs.Patterns = append(pcp.Execs.Patterns, e.Path) + } else { + pcp.Execs.Values[e.Path] = struct{}{} + } + } + if len(pcp.Execs.Values) == 0 { + pcp.Execs.Values = nil + } + // ExecsByPath: carry per-path Args so the exec-args wildcard matcher + // (was_executed_with_args / CompareExecArgs) keeps working. + pcp.ExecsByPath = make(map[string][]string, len(cp.Spec.Execs)) + for _, e := range cp.Spec.Execs { + if e.Args == nil { + pcp.ExecsByPath[e.Path] = []string{} + continue + } + pcp.ExecsByPath[e.Path] = e.Args } } @@ -175,14 +193,25 @@ func (r *RuleObjectCacheMock) GetProjectedContainerProfile(containerID string) * } // Egress addresses and domains — All=true: all observed entries are retained. + // v0.0.2 wildcards (CIDRs, '*' sentinel, leading-*/mid-⋯/trailing-*) get + // routed to Patterns rather than Values so the runtime CEL helpers can + // pass them through networkmatch on the cache-miss path. if !specInstalled || spec.EgressAddresses.InUse || spec.EgressDomains.InUse { for _, n := range cp.Spec.Egress { - if (!specInstalled || spec.EgressAddresses.InUse) && n.IPAddress != "" { - if pcp.EgressAddresses.Values == nil { - pcp.EgressAddresses.All = true - pcp.EgressAddresses.Values = make(map[string]struct{}) + if !specInstalled || spec.EgressAddresses.InUse { + addrs := make([]string, 0, len(n.IPAddresses)+1) + if n.IPAddress != "" { + addrs = append(addrs, n.IPAddress) + } + addrs = append(addrs, n.IPAddresses...) + for _, a := range addrs { + ensureProjectedAllInit(&pcp.EgressAddresses) + if mockIsNetworkIPWildcard(a) { + pcp.EgressAddresses.Patterns = append(pcp.EgressAddresses.Patterns, a) + } else { + pcp.EgressAddresses.Values[a] = struct{}{} + } } - pcp.EgressAddresses.Values[n.IPAddress] = struct{}{} } if !specInstalled || spec.EgressDomains.InUse { domains := n.DNSNames @@ -190,40 +219,47 @@ func (r *RuleObjectCacheMock) GetProjectedContainerProfile(containerID string) * domains = append([]string{n.DNS}, domains...) } for _, d := range domains { - if pcp.EgressDomains.Values == nil { - pcp.EgressDomains.All = true - pcp.EgressDomains.Values = make(map[string]struct{}) + ensureProjectedAllInit(&pcp.EgressDomains) + if mockIsNetworkDNSWildcard(d) { + pcp.EgressDomains.Patterns = append(pcp.EgressDomains.Patterns, d) + } else { + pcp.EgressDomains.Values[d] = struct{}{} } - pcp.EgressDomains.Values[d] = struct{}{} } } } } - // Ingress addresses and domains — All=true: all observed entries are retained. + // Ingress addresses and domains — same shape as egress above. if !specInstalled || spec.IngressAddresses.InUse || spec.IngressDomains.InUse { for _, n := range cp.Spec.Ingress { - if (!specInstalled || spec.IngressAddresses.InUse) && n.IPAddress != "" { - if pcp.IngressAddresses.Values == nil { - pcp.IngressAddresses.All = true - pcp.IngressAddresses.Values = make(map[string]struct{}) + if !specInstalled || spec.IngressAddresses.InUse { + addrs := make([]string, 0, len(n.IPAddresses)+1) + if n.IPAddress != "" { + addrs = append(addrs, n.IPAddress) + } + addrs = append(addrs, n.IPAddresses...) + for _, a := range addrs { + ensureProjectedAllInit(&pcp.IngressAddresses) + if mockIsNetworkIPWildcard(a) { + pcp.IngressAddresses.Patterns = append(pcp.IngressAddresses.Patterns, a) + } else { + pcp.IngressAddresses.Values[a] = struct{}{} + } } - pcp.IngressAddresses.Values[n.IPAddress] = struct{}{} } if !specInstalled || spec.IngressDomains.InUse { + domains := n.DNSNames if n.DNS != "" { - if pcp.IngressDomains.Values == nil { - pcp.IngressDomains.All = true - pcp.IngressDomains.Values = make(map[string]struct{}) - } - pcp.IngressDomains.Values[n.DNS] = struct{}{} + domains = append([]string{n.DNS}, domains...) } - for _, d := range n.DNSNames { - if pcp.IngressDomains.Values == nil { - pcp.IngressDomains.All = true - pcp.IngressDomains.Values = make(map[string]struct{}) + for _, d := range domains { + ensureProjectedAllInit(&pcp.IngressDomains) + if mockIsNetworkDNSWildcard(d) { + pcp.IngressDomains.Patterns = append(pcp.IngressDomains.Patterns, d) + } else { + pcp.IngressDomains.Values[d] = struct{}{} } - pcp.IngressDomains.Values[d] = struct{}{} } } } @@ -232,6 +268,60 @@ func (r *RuleObjectCacheMock) GetProjectedContainerProfile(containerID string) * return pcp } +// ensureProjectedAllInit allocates the Values map on first use. +// Does NOT set All=true — that flag is the projection's "match any input" +// sentinel set by rule declarations, not a comprehensiveness hint. +// (Prior mock code conflated the two; matchIPField/matchDNSField correctly +// short-circuit on All=true so we MUST NOT set it here.) +func ensureProjectedAllInit(pf *objectcache.ProjectedField) { + if pf.Values == nil { + pf.Values = make(map[string]struct{}) + } +} + +// mockIsNetworkIPWildcard duplicates containerprofilecache.isNetworkIPWildcard +// because the mock is in a separate package and we don't want to introduce +// an import dependency on the production cache implementation here. +// Kept in sync with the production classifier — see containerprofilecache/projection_apply.go. +func mockIsNetworkIPWildcard(e string) bool { + if e == "" || e == "*" { + return e == "*" + } + if len(e) > 0 { + for _, r := range e { + if r == '/' { + return true + } + } + } + return false +} + +// mockContainsDynamicSegment recognises the path-wildcard token used by +// dynamicpathdetector (single Unicode codepoint U+22EF). Kept in sync with +// containerprofilecache.containsDynamicSegment. +func mockContainsDynamicSegment(e string) bool { + for _, r := range e { + if r == '⋯' { + return true + } + } + return false +} + +// mockIsNetworkDNSWildcard duplicates containerprofilecache.isNetworkDNSWildcard. +func mockIsNetworkDNSWildcard(e string) bool { + if e == "" { + return false + } + for _, r := range e { + if r == '*' || r == '⋯' { + return true + } + } + return false +} + func (r *RuleObjectCacheMock) SetProjectionSpec(spec objectcache.RuleProjectionSpec) { r.projectionSpecMu.Lock() r.projectionSpec = spec diff --git a/pkg/rulebindingmanager/cache/cache.go b/pkg/rulebindingmanager/cache/cache.go index 9ca100082b..5572ea3403 100644 --- a/pkg/rulebindingmanager/cache/cache.go +++ b/pkg/rulebindingmanager/cache/cache.go @@ -117,72 +117,63 @@ func (c *RBCache) AddNotifier(n *chan rulebindingmanager.RuleBindingNotify) { // ------------------ watcher.Watcher methods ----------------------- +// AddHandler / ModifyHandler / DeleteHandler structure: take the +// mutex, mutate the cache + build the rbs slice, release the mutex, +// then fan out NON-blocking. Holding the lock during fan-out (the +// pre-fix shape) deadlocks every cache operation behind any single +// stuck subscriber. CodeRabbit PR #43 cache.go:215 — the +// non-blocking fix was previously only on RefreshRuleBindingsRules; +// this extends it to all three k8s-event handlers. + 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 { rbs = c.addPod(ctx, pod) } else if un, ok := obj.(*unstructured.Unstructured); ok { ruleBinding, err := unstructuredToRuleBinding(un) if err != nil { logger.L().Warning("RBCache - failed to convert unstructured to rule binding", helpers.Error(err)) + c.mutex.Unlock() return } - rbs = c.addRuleBinding(ruleBinding) - } - // notify - for n := range c.notifiers { - for i := range rbs { - *c.notifiers[n] <- rbs[i] - } + rbs = c.addRuleBinding(ctx, ruleBinding) } + notifiers := c.snapshotNotifiersLocked() + c.mutex.Unlock() + dispatchNonBlocking(notifiers, rbs, "AddHandler notify") } 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 { rbs = c.addPod(ctx, pod) } else if un, ok := obj.(*unstructured.Unstructured); ok { ruleBinding, err := unstructuredToRuleBinding(un) if err != nil { logger.L().Warning("RBCache - failed to convert unstructured to rule binding", helpers.Error(err)) + c.mutex.Unlock() return } - rbs = c.modifiedRuleBinding(ruleBinding) - } - // notify - for n := range c.notifiers { - for i := range rbs { - *c.notifiers[n] <- rbs[i] - } + rbs = c.modifiedRuleBinding(ctx, ruleBinding) } + notifiers := c.snapshotNotifiersLocked() + c.mutex.Unlock() + dispatchNonBlocking(notifiers, rbs, "ModifyHandler notify") } -func (c *RBCache) DeleteHandler(_ context.Context, obj runtime.Object) { +func (c *RBCache) DeleteHandler(ctx context.Context, obj runtime.Object) { c.mutex.Lock() - defer c.mutex.Unlock() - var rbs []rulebindingmanager.RuleBindingNotify - if pod, ok := obj.(*corev1.Pod); ok { c.deletePod(uniqueName(pod)) } else if un, ok := obj.(*unstructured.Unstructured); ok { - rbs = c.deleteRuleBinding(uniqueName(un)) - } - - // notify - for n := range c.notifiers { - for i := range rbs { - *c.notifiers[n] <- rbs[i] - } + rbs = c.deleteRuleBinding(ctx, uniqueName(un)) } + notifiers := c.snapshotNotifiersLocked() + c.mutex.Unlock() + dispatchNonBlocking(notifiers, rbs, "DeleteHandler notify") } func (c *RBCache) RefreshRuleBindingsRules() { @@ -192,20 +183,62 @@ func (c *RBCache) RefreshRuleBindingsRules() { c.rbNameToRules.Set(rbName, c.createRules(rb.Spec.Rules)) } logger.L().Info("RBCache - refreshed rule bindings rules", helpers.Int("ruleBindings", len(c.rbNameToRB.Keys()))) - // Snapshot notifiers while holding the lock, then release before sending to - // avoid blocking cache operations if any notifier channel is full. + notifiers := c.snapshotNotifiersLocked() + c.mutex.Unlock() + // Single coalesced pulse — refresh notifications are idempotent. + dispatchNonBlocking(notifiers, []rulebindingmanager.RuleBindingNotify{{}}, "refresh pulse") +} + +// snapshotNotifiersLocked returns a defensive copy of c.notifiers. +// Must be called with c.mutex held; releases the contract back to the +// caller without taking new locks. +func (c *RBCache) snapshotNotifiersLocked() []*chan rulebindingmanager.RuleBindingNotify { notifiers := make([]*chan rulebindingmanager.RuleBindingNotify, len(c.notifiers)) copy(notifiers, c.notifiers) - c.mutex.Unlock() + return notifiers +} + +// dispatchNonBlocking fans out msgs to every snapshotted notifier with a +// non-blocking send. Drop-on-full is safe because subscribers' reconcile +// loops are idempotent — a missed pulse will be re-sent by the next +// add/modify/delete/refresh event. CodeRabbit PR #43 review on +// cache.go:202 + cache.go:215 — the previous implementation only made +// RefreshRuleBindingsRules non-blocking; the add/modify/delete handlers +// (lines 137-139, 161-163, 181-183) still did blocking sends while +// holding c.mutex. A single stuck subscriber could deadlock the whole +// cache. Funnel ALL fan-out through this helper for symmetry. +func dispatchNonBlocking(notifiers []*chan rulebindingmanager.RuleBindingNotify, msgs []rulebindingmanager.RuleBindingNotify, ctxLabel string) { for _, n := range notifiers { - *n <- rulebindingmanager.RuleBindingNotify{} + for _, msg := range msgs { + select { + case *n <- msg: + default: + logger.L().Debug("RBCache - notifier channel full, dropping "+ctxLabel, + helpers.Int("notifierIndex", indexOfNotifier(notifiers, n))) + } + } + } +} + +// indexOfNotifier returns the position of n in the slice, or -1. Used only +// for the diagnostic log emitted on a dropped non-blocking notifier send. +func indexOfNotifier(notifiers []*chan rulebindingmanager.RuleBindingNotify, n *chan rulebindingmanager.RuleBindingNotify) int { + for i, x := range notifiers { + if x == n { + return i + } } + return -1 } // ----------------- RuleBinding manager methods ----------------- // AddRuleBinding adds a rule binding to the cache -func (c *RBCache) addRuleBinding(ruleBinding *typesv1.RuntimeAlertRuleBinding) []rulebindingmanager.RuleBindingNotify { +// addRuleBinding propagates ctx through the K8s List calls so the +// watcher can cancel in-flight work. CodeRabbit PR #43 cache.go:176 +// (Major): previously used context.Background() for the namespaces + +// pods list, which leaked goroutines past watch-context cancellation. +func (c *RBCache) addRuleBinding(ctx context.Context, ruleBinding *typesv1.RuntimeAlertRuleBinding) []rulebindingmanager.RuleBindingNotify { var rbs []rulebindingmanager.RuleBindingNotify rbName := uniqueName(ruleBinding) logger.L().Info("RBCache - ruleBinding added/modified", helpers.String("name", rbName)) @@ -234,7 +267,7 @@ func (c *RBCache) addRuleBinding(ruleBinding *typesv1.RuntimeAlertRuleBinding) [ var namespaces *corev1.NamespaceList // if ruleBinding.GetNamespace() == "" { - namespaces, err = c.k8sClient.GetKubernetesClient().CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{LabelSelector: nsSelectorStr}) + 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", rbName), helpers.String("nsSelector", nsSelectorStr), helpers.Error(err)) return rbs @@ -249,7 +282,7 @@ func (c *RBCache) addRuleBinding(ruleBinding *typesv1.RuntimeAlertRuleBinding) [ LabelSelector: podSelectorStr, FieldSelector: "spec.nodeName=" + c.nodeName, } - pods, err := c.k8sClient.GetKubernetesClient().CoreV1().Pods(ns.GetName()).List(context.Background(), lp) + pods, err := c.k8sClient.GetKubernetesClient().CoreV1().Pods(ns.GetName()).List(ctx, 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 @@ -276,7 +309,12 @@ func (c *RBCache) addRuleBinding(ruleBinding *typesv1.RuntimeAlertRuleBinding) [ } return rbs } -func (c *RBCache) deleteRuleBinding(uniqueName string) []rulebindingmanager.RuleBindingNotify { +// deleteRuleBinding accepts ctx for parity with addRuleBinding (uniform +// handler signatures) and future-proofs against the helper growing K8s +// API calls. RuleBindingNotifierImplWithK8s currently uses an internal +// context; if it ever takes one, ctx is already threaded. +// CodeRabbit PR #43 cache.go:176. +func (c *RBCache) deleteRuleBinding(_ context.Context, uniqueName string) []rulebindingmanager.RuleBindingNotify { logger.L().Info("RBCache - ruleBinding deleted", helpers.String("name", uniqueName)) var rbs []rulebindingmanager.RuleBindingNotify @@ -311,9 +349,9 @@ func (c *RBCache) deleteRuleBinding(uniqueName string) []rulebindingmanager.Rule return rbs } -func (c *RBCache) modifiedRuleBinding(ruleBinding *typesv1.RuntimeAlertRuleBinding) []rulebindingmanager.RuleBindingNotify { - rbsD := c.deleteRuleBinding(uniqueName(ruleBinding)) - rbsA := c.addRuleBinding(ruleBinding) +func (c *RBCache) modifiedRuleBinding(ctx context.Context, ruleBinding *typesv1.RuntimeAlertRuleBinding) []rulebindingmanager.RuleBindingNotify { + rbsD := c.deleteRuleBinding(ctx, uniqueName(ruleBinding)) + rbsA := c.addRuleBinding(ctx, ruleBinding) return diff(rbsD, rbsA) } diff --git a/pkg/rulebindingmanager/cache/cache_test.go b/pkg/rulebindingmanager/cache/cache_test.go index 75eb8b70e3..fbcde80b8d 100644 --- a/pkg/rulebindingmanager/cache/cache_test.go +++ b/pkg/rulebindingmanager/cache/cache_test.go @@ -3,8 +3,11 @@ package cache import ( "context" "fmt" + "reflect" "slices" + "sync" "testing" + "time" mapset "github.com/deckarep/golang-set/v2" "github.com/goradd/maps" @@ -22,6 +25,119 @@ import ( k8sfake "k8s.io/client-go/kubernetes/fake" ) +// TestDispatchNonBlocking_DropOnFull pins the shared invariant for ALL +// fan-out sites: when a notifier channel is full, the helper drops the +// message and continues. This is the core building block for the +// AddHandler / ModifyHandler / DeleteHandler / RefreshRuleBindingsRules +// non-blocking-fanout contract. CodeRabbit PR #43 cache.go:215 — the +// previous fix only made RefreshRuleBindingsRules non-blocking; without +// extracting a shared helper, each handler had to be patched +// individually and drift was inevitable. The helper test below pins the +// drop-on-full behaviour at the lowest common layer. +func TestDispatchNonBlocking_DropOnFull(t *testing.T) { + // Two channels: one saturated, one empty. + full := make(chan rulebindingmanager.RuleBindingNotify, 1) + full <- rulebindingmanager.RuleBindingNotify{} + empty := make(chan rulebindingmanager.RuleBindingNotify, 1) + + notifiers := []*chan rulebindingmanager.RuleBindingNotify{&full, &empty} + msgs := []rulebindingmanager.RuleBindingNotify{{}} + + done := make(chan struct{}) + go func() { + dispatchNonBlocking(notifiers, msgs, "test") + close(done) + }() + select { + case <-done: + // non-blocking — correct + case <-time.After(2 * time.Second): + t.Fatalf("dispatchNonBlocking blocked on a saturated subscriber — drop-on-full contract violated") + } + + require.Len(t, full, 1, "saturated channel should still hold its pre-loaded message (drop policy)") + require.Len(t, empty, 1, "empty channel should have received the pulse") +} + +// TestRefreshRuleBindingsRules_NonBlockingFanout pins the contract from +// the CodeRabbit PR #43 review (cache.go:202): a slow or backlogged +// subscriber MUST NOT stall the refresh-rules path. Blocking sends would +// deadlock RefreshRuleBindingsRules behind any single stuck subscriber, +// which gates every binding change agent-wide. +// +// Setup: 3 notifier channels, all with buffer size 1. Fill one to capacity +// (simulates a subscriber that hasn't drained the previous pulse). Call +// RefreshRuleBindingsRules; assert it returns within a small budget and +// that the two un-full channels each received one notification. +func TestRefreshRuleBindingsRules_NonBlockingFanout(t *testing.T) { + c := &RBCache{} + + // 3 buffered channels; saturate the first so a blocking send on it + // would hang the test. + ch1 := make(chan rulebindingmanager.RuleBindingNotify, 1) + ch1 <- rulebindingmanager.RuleBindingNotify{} // full + ch2 := make(chan rulebindingmanager.RuleBindingNotify, 1) + ch3 := make(chan rulebindingmanager.RuleBindingNotify, 1) + + c.notifiers = []*chan rulebindingmanager.RuleBindingNotify{&ch1, &ch2, &ch3} + + done := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + c.RefreshRuleBindingsRules() + close(done) + }() + + select { + case <-done: + // returned in time — non-blocking fan-out works. + case <-time.After(2 * time.Second): + t.Fatalf("RefreshRuleBindingsRules blocked on a full subscriber channel — non-blocking send contract violated") + } + wg.Wait() + + // ch1 stays at capacity (the new pulse was dropped — expected); the + // pre-loaded message is still there. ch2 and ch3 must each have + // received the new pulse. + require.Len(t, ch1, 1, "full ch1 should still hold its pre-loaded message (drop-on-full policy)") + require.Len(t, ch2, 1, "ch2 should have received the refresh pulse") + require.Len(t, ch3, 1, "ch3 should have received the refresh pulse") +} + +// TestRBCacheHelpers_CtxFirstArg pins the contract from the CodeRabbit +// PR #43 review (cache.go:176, Major): the three RBCache helpers that +// AddHandler / ModifyHandler / DeleteHandler delegate to MUST accept a +// context.Context as their first argument so the watcher's cancellation +// signal propagates into K8s API List calls. A previous regression used +// `context.Background()` inside addRuleBinding, leaking goroutines past +// watch-context cancellation. Compile-time assignment to a typed +// function variable: if anyone removes ctx, this file no longer compiles. +func TestRBCacheHelpers_CtxFirstArg(t *testing.T) { + c := &RBCache{} + + // Compile-time guards: these assignments fail to compile if the + // signatures drift away from (ctx, ...). The reflect read is only + // to silence the unused-variable check. + var addFn func(context.Context, *typesv1.RuntimeAlertRuleBinding) []rulebindingmanager.RuleBindingNotify = c.addRuleBinding + var delFn func(context.Context, string) []rulebindingmanager.RuleBindingNotify = c.deleteRuleBinding + var modFn func(context.Context, *typesv1.RuntimeAlertRuleBinding) []rulebindingmanager.RuleBindingNotify = c.modifiedRuleBinding + + // Runtime sanity: function values are non-nil + first param is ctx. + require.NotNil(t, addFn, "addRuleBinding bound value should be non-nil") + require.NotNil(t, delFn, "deleteRuleBinding bound value should be non-nil") + require.NotNil(t, modFn, "modifiedRuleBinding bound value should be non-nil") + ctxType := reflect.TypeOf((*context.Context)(nil)).Elem() + for name, fn := range map[string]any{"addRuleBinding": addFn, "deleteRuleBinding": delFn, "modifiedRuleBinding": modFn} { + ft := reflect.TypeOf(fn) + require.GreaterOrEqualf(t, ft.NumIn(), 1, "%s must take at least one parameter (ctx)", name) + require.Truef(t, ft.In(0).Implements(ctxType) || ft.In(0) == ctxType, + "%s first param must be context.Context, got %s — ctx-propagation contract regressed (CodeRabbit PR #43 cache.go:176)", + name, ft.In(0).String()) + } +} + func TestRuntimeObjAddHandler(t *testing.T) { type rules struct { ruleID string @@ -161,7 +277,7 @@ func TestRuntimeObjAddHandler(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { for i := range tt.args.rb { - tt.args.c.addRuleBinding(&tt.args.rb[i]) + tt.args.c.addRuleBinding(context.Background(), &tt.args.rb[i]) } tt.args.c.addPod(context.Background(), tt.args.pod) r := tt.args.c.ListRulesForPod(tt.args.pod.GetNamespace(), tt.args.pod.GetName()) @@ -579,7 +695,7 @@ func TestDeleteRuleBinding(t *testing.T) { } - c.deleteRuleBinding(tt.uniqueName) + c.deleteRuleBinding(context.Background(), tt.uniqueName) assert.False(t, c.rbNameToPods.Has(tt.uniqueName)) assert.False(t, c.rbNameToRB.Has(tt.uniqueName)) @@ -884,7 +1000,7 @@ func TestAddRuleBinding(t *testing.T) { c := NewCacheMock("") c.k8sClient = k8sClient - c.addRuleBinding(tt.rb) + c.addRuleBinding(context.Background(), tt.rb) rbName := uniqueName(tt.rb) diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/ap.go b/pkg/rulemanager/cel/libraries/applicationprofile/ap.go index ce86d7ab88..fabf311c2e 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/ap.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/ap.go @@ -111,25 +111,6 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { }), ), }, - "ap.was_path_opened_with_flags": { - cel.Overload( - "ap_was_path_opened_with_flags", []*cel.Type{cel.StringType, cel.StringType, cel.ListType(cel.StringType)}, cel.BoolType, - cel.FunctionBinding(func(values ...ref.Val) ref.Val { - if len(values) != 3 { - return types.NewErr("expected 3 arguments, got %d", len(values)) - } - if l.detailedMetrics && l.metrics != nil { - l.metrics.IncHelperCall("ap.was_path_opened_with_flags") - } - wrapperFunc := func(args ...ref.Val) ref.Val { - return l.wasPathOpenedWithFlags(args[0], args[1], args[2]) - } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_path_opened_with_flags", cache.HashForContainerProfile(l.objectCache)) - result := cachedFunc(values[0], values[1], values[2]) - return cache.ConvertProfileNotAvailableErrToBool(result, false) - }), - ), - }, "ap.was_path_opened_with_suffix": { cel.Overload( "ap_was_path_opened_with_suffix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, @@ -354,9 +335,6 @@ func (e *apCostEstimator) EstimateCallCost(function, overloadID string, target * case "ap.was_path_opened": // Cache lookup + O(n) linear search + dynamic path comparison cost = 25 - case "ap.was_path_opened_with_flags": - // Cache lookup + O(n) search + dynamic path comparison + O(f*p) flag comparison - cost = 40 case "ap.was_path_opened_with_suffix": // Cache lookup + O(n) linear search + O(n*len(suffix)) string suffix checks cost = 20 diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go index b69a69c0ea..5f57369227 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go @@ -70,12 +70,11 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val return types.MaybeNoSuchOverloadErr(path) } - // v1 limitation for rule authors: wasExecutedWithArgs is currently equivalent - // to wasExecuted — the args list is validated but not matched against. Any - // execution of the given path returns true regardless of its arguments. Full - // argument matching (ExecArgsByPath) will be added in a future version. - _ = args - if _, err := celparse.ParseList[string](args); err != nil { + // Parse the runtime args list from CEL. Empty list is valid ("exec'd + // with no args") and matches a profile entry whose Args is also empty + // or absent (empty profile Args = "no argv constraint"). + runtimeArgs, err := celparse.ParseList[string](args) + if err != nil { return types.NewErr("failed to parse args: %v", err) } @@ -84,20 +83,37 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val return types.Bool(true) } - cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) - if err != nil { + cp, _, perr := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) + if perr != nil { // Return a special error that will NOT be cached, allowing retry when profile becomes available. // The caller should convert this to false after the cache layer. - return cache.NewProfileNotAvailableErr("%v", err) + return cache.NewProfileNotAvailableErr("%v", perr) } + // Exact path match: walk the profile's Args for that path via + // CompareExecArgs (handles ⋯ single-arg and * zero-or-more tokens). if _, ok := cp.Execs.Values[pathStr]; ok { - return types.Bool(true) + if profileArgs, ok := cp.ExecsByPath[pathStr]; ok { + if dynamicpathdetector.CompareExecArgs(profileArgs, runtimeArgs) { + return types.Bool(true) + } + } else { + // No ExecsByPath entry for this path — back-compat: treat as + // "no argv constraint", match. + return types.Bool(true) + } } - // Check Patterns (dynamic-segment entries). + // Pattern path match: dynamic-segment paths in cp.Execs.Patterns. + // Args matching mirrors the exact-path case. for _, execPath := range cp.Execs.Patterns { if dynamicpathdetector.CompareDynamic(execPath, pathStr) { - return types.Bool(true) + if profileArgs, ok := cp.ExecsByPath[execPath]; ok { + if dynamicpathdetector.CompareExecArgs(profileArgs, runtimeArgs) { + return types.Bool(true) + } + } else { + return types.Bool(true) + } } } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go index 085e2215fc..625559e67c 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go @@ -200,12 +200,14 @@ func TestExecWithArgsInProfile(t *testing.T) { expectedResult: true, }, { - // v1 degradation: args projection is out of scope; path-only matching. + // Args are anchored — wrong arg mismatch must reject the exec. + // Fork restores CompareExecArgs matching that upstream + // projection-v1 had temporarily dropped. name: "Path matches but args don't match", containerID: "test-container-id", path: "/bin/ls", args: []string{"-la", "/home"}, - expectedResult: true, + expectedResult: false, }, { name: "Path doesn't exist", @@ -229,12 +231,15 @@ func TestExecWithArgsInProfile(t *testing.T) { expectedResult: true, }, { - // v1 degradation: args projection is out of scope; path-only matching. + // /bin/ls in the profile has Args: ["-la", "/tmp"]. An empty + // runtime args list cannot satisfy a 2-arg anchored profile. + // (Empty profile Args = "no argv constraint" still matches via + // the back-compat branch; that's a separate case.) name: "Empty args list", containerID: "test-container-id", path: "/bin/ls", args: []string{}, - expectedResult: true, + expectedResult: false, }, } @@ -301,6 +306,127 @@ func TestExecWithArgsNoProfile(t *testing.T) { assert.False(t, actualResult, "ap.was_executed_with_args should return false when no profile is available") } +// TestExecWithArgsWildcardInProfile exercises wildcard tokens inside a +// user-defined ApplicationProfile's exec arg vector: +// +// "⋯" (DynamicIdentifier) — matches exactly one argument position. +// "*" (WildcardIdentifier) — matches zero or more consecutive args. +// +// The runtime exec arg vector is matched against the profile via +// dynamicpathdetector.CompareExecArgs (added in +// k8sstormcenter/storage#23 — the matcher that this CEL function now +// routes through instead of slices.Compare). +func TestExecWithArgsWildcardInProfile(t *testing.T) { + objCache := objectcachev1.RuleObjectCacheMock{ + ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), + } + + objCache.SetSharedContainerData("test-container-id", &objectcache.WatchedContainerData{ + ContainerType: objectcache.Container, + ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ + objectcache.Container: { + { + Name: "test-container", + }, + }, + }, + }) + + profile := &v1beta1.ApplicationProfile{} + profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ + Name: "test-container", + Execs: []v1beta1.ExecCalls{ + // curl any URL: --user must be literal, value is one position. + { + Path: "/usr/bin/curl", + Args: []string{"--user", "⋯"}, + }, + // sh -c with any trailing payload (zero or more args). + { + Path: "/bin/sh", + Args: []string{"-c", "*"}, + }, + // ls -l in any directory — single trailing position. + { + Path: "/bin/ls", + Args: []string{"-l", "⋯"}, + }, + // echo with any number of greeting words after a literal anchor. + { + Path: "/bin/echo", + Args: []string{"hello", "*"}, + }, + }, + }) + objCache.SetApplicationProfile(profile) + + env, err := cel.NewEnv( + cel.Variable("containerID", cel.StringType), + cel.Variable("path", cel.StringType), + cel.Variable("args", cel.ListType(cel.StringType)), + AP(&objCache, config.Config{}), + ) + if err != nil { + t.Fatalf("failed to create env: %v", err) + } + + testCases := []struct { + name string + path string + args []string + expectedResult bool + }{ + // curl with --user, dynamic value + {"curl --user alice — ⋯ matches one arg", "/usr/bin/curl", []string{"--user", "alice"}, true}, + {"curl --user alice bob — extra arg, ⋯ rejects", "/usr/bin/curl", []string{"--user", "alice", "bob"}, false}, + {"curl --user — missing value, ⋯ requires one arg", "/usr/bin/curl", []string{"--user"}, false}, + {"curl --pass alice — literal mismatch", "/usr/bin/curl", []string{"--pass", "alice"}, false}, + + // sh -c with arbitrary trailing payload + {"sh -c with single command", "/bin/sh", []string{"-c", "echo hi"}, true}, + {"sh -c with multi-token command", "/bin/sh", []string{"-c", "while", "true;", "do", "sleep", "1;", "done"}, true}, + {"sh -c with no trailing args (* matches zero)", "/bin/sh", []string{"-c"}, true}, + {"sh -x — wrong flag", "/bin/sh", []string{"-x", "echo hi"}, false}, + + // ls -l in any directory + {"ls -l /var/log", "/bin/ls", []string{"-l", "/var/log"}, true}, + {"ls -l with no directory (⋯ requires one)", "/bin/ls", []string{"-l"}, false}, + + // echo hello * + {"echo hello world from test", "/bin/echo", []string{"hello", "world", "from", "test"}, true}, + {"echo hello (no trailing args)", "/bin/echo", []string{"hello"}, true}, + {"echo goodbye world — wrong literal anchor", "/bin/echo", []string{"goodbye", "world"}, false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ast, issues := env.Compile(`ap.was_executed_with_args(containerID, path, args)`) + if issues != nil { + t.Fatalf("failed to compile expression: %v", issues.Err()) + } + + program, err := env.Program(ast) + if err != nil { + t.Fatalf("failed to create program: %v", err) + } + + result, _, err := program.Eval(map[string]interface{}{ + "containerID": "test-container-id", + "path": tc.path, + "args": tc.args, + }) + if err != nil { + t.Fatalf("failed to eval program: %v", err) + } + + actualResult := result.Value().(bool) + assert.Equal(t, tc.expectedResult, actualResult, + "runtime args %v vs profile (one of curl/sh/ls/echo overlay): got %v want %v", + tc.args, actualResult, tc.expectedResult) + }) + } +} + func TestExecWithArgsCompilation(t *testing.T) { objCache := objectcachev1.RuleObjectCacheMock{} diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go index 885ace3f4c..46784e7b84 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go @@ -86,7 +86,7 @@ func TestIntegrationWithAllFunctions(t *testing.T) { }, { name: "Check file access pattern", - expression: `ap.was_path_opened_with_flags(containerID, "/etc/passwd", ["O_RDONLY"])`, + expression: `ap.was_path_opened(containerID, "/etc/passwd")`, expectedResult: true, }, { diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open.go b/pkg/rulemanager/cel/libraries/applicationprofile/open.go index ec0a8310c5..fccf19a10d 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/open.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/open.go @@ -6,7 +6,6 @@ import ( "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" - "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/celparse" "github.com/kubescape/node-agent/pkg/rulemanager/profilehelper" "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" ) @@ -46,45 +45,6 @@ func (l *apLibrary) wasPathOpened(containerID, path ref.Val) ref.Val { return types.Bool(false) } -func (l *apLibrary) wasPathOpenedWithFlags(containerID, path, flags ref.Val) ref.Val { - if l.objectCache == nil { - return types.NewErr("objectCache is nil") - } - - containerIDStr, ok := containerID.Value().(string) - if !ok { - return types.MaybeNoSuchOverloadErr(containerID) - } - - pathStr, ok := path.Value().(string) - if !ok { - return types.MaybeNoSuchOverloadErr(path) - } - - // flags projection (OpenFlagsByPath) is out of scope for v1; degrade to path-only matching. - if _, err := celparse.ParseList[string](flags); err != nil { - return types.NewErr("failed to parse flags: %v", err) - } - - cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) - if err != nil { - return cache.NewProfileNotAvailableErr("%v", err) - } - - for openPath := range cp.Opens.Values { - if dynamicpathdetector.CompareDynamic(openPath, pathStr) { - return types.Bool(true) - } - } - for _, openPath := range cp.Opens.Patterns { - if dynamicpathdetector.CompareDynamic(openPath, pathStr) { - return types.Bool(true) - } - } - - return types.Bool(false) -} - func (l *apLibrary) wasPathOpenedWithSuffix(containerID, suffix ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") @@ -105,17 +65,21 @@ func (l *apLibrary) wasPathOpenedWithSuffix(containerID, suffix ref.Val) ref.Val } if cp.Opens.All { - // All entries retained — scan to check for the suffix. + // All entries retained (no rule declared SuffixHits-style + // projection). Scan ONLY concrete entries in Values — Patterns + // contain wildcard tokens ('*' / '⋯') whose text doesn't safely + // answer suffix questions. CodeRabbit PR #43 open.go:79: a + // retained Pattern like "/var/log/pods/*/volumes/..." doesn't + // end with the concrete suffix "foo.log", but the concrete open + // it stands in for might — strings.HasSuffix on the pattern + // text returns false and produces a false negative. Patterns + // are inherently wildcard-shaped; concrete-path semantics live + // in Values (and in SuffixHits when projection is active). for openPath := range cp.Opens.Values { if strings.HasSuffix(openPath, suffixStr) { return types.Bool(true) } } - for _, openPath := range cp.Opens.Patterns { - if strings.HasSuffix(openPath, suffixStr) { - return types.Bool(true) - } - } return types.Bool(false) } // Projection applied — SuffixHits is authoritative; absent key = undeclared. @@ -149,17 +113,18 @@ func (l *apLibrary) wasPathOpenedWithPrefix(containerID, prefix ref.Val) ref.Val } if cp.Opens.All { - // All entries retained — scan to check for the prefix. + // All entries retained — scan ONLY Values (concrete paths). + // Patterns contain wildcard tokens whose text doesn't safely + // answer prefix questions; a pattern starting with "/var/⋯/log" + // matches concrete paths starting with "/var/anything/log" but + // strings.HasPrefix against the pattern text returns false for + // "/var/foo/log...". Same fix as wasPathOpenedWithSuffix above. + // CodeRabbit PR #43 open.go:79 (Also applies to 111-123). for openPath := range cp.Opens.Values { if strings.HasPrefix(openPath, prefixStr) { return types.Bool(true) } } - for _, openPath := range cp.Opens.Patterns { - if strings.HasPrefix(openPath, prefixStr) { - return types.Bool(true) - } - } return types.Bool(false) } // Projection applied — PrefixHits is authoritative; absent key = undeclared. diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go index bf407611e0..9fce787aeb 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" "github.com/goradd/maps" "github.com/kubescape/node-agent/pkg/config" "github.com/kubescape/node-agent/pkg/objectcache" @@ -12,134 +13,108 @@ import ( "github.com/stretchr/testify/assert" ) -func TestOpenInProfile(t *testing.T) { - objCache := objectcachev1.RuleObjectCacheMock{ - ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), +// TestWasPathOpenedWithSuffix_PatternsNotScanned pins the contract from +// the CodeRabbit PR #43 review on open.go:79 (Major). Wildcard-shaped +// entries in cp.Opens.Patterns MUST NOT contribute to suffix/prefix +// answers — their literal text answers the wrong question. A retained +// pattern "/var/log/pods/*/volumes/...." doesn't END with "foo.log" +// even though the concrete open it stands in for might. Only concrete +// paths in cp.Opens.Values are valid sources of suffix/prefix truth in +// pass-through (Opens.All=true) mode. +// +// In projection-active mode (Opens.All=false), the rule manager +// precomputes Opens.SuffixHits / PrefixHits from the spec, which is +// the correct mechanism — those are exercised in +// TestOpenWithSuffixInProfile / TestOpenWithPrefixInProfile. +// +// This test exercises the pass-through path directly by setting a +// ProjectedContainerProfile where Opens.All=true, Values contains a +// concrete path with the queried suffix, and Patterns contains a +// wildcard-pattern that ALSO appears to satisfy strings.HasSuffix +// against the queried suffix. The pattern must be ignored. +func TestWasPathOpenedWithSuffix_PatternsNotScanned(t *testing.T) { + // Pass-through pcp (Opens.All=true): + // Values: ["/var/log/concrete.log"] — concrete, ends with ".log" + // Patterns: ["/var/log/⋯/foo.log"] — wildcard, ALSO ends with ".log" + // Querying suffix=".log" should match Values; we then strip + // concrete.log from Values and assert suffix doesn't match + // through Patterns alone. + pcp := &objectcache.ProjectedContainerProfile{ + Opens: objectcache.ProjectedField{ + All: true, + Values: map[string]struct{}{"/var/log/concrete.log": {}}, + Patterns: []string{"/var/log/⋯/foo.log"}, + }, + } + objCache := &mockObjectCacheForPattern{pcp: pcp} + lib := &apLibrary{objectCache: objCache} + + // 1) With concrete in Values: returns true. + got := lib.wasPathOpenedWithSuffix(types.String("test-cid"), types.String(".log")) + if b, _ := got.Value().(bool); !b { + t.Fatalf("suffix '.log' against concrete /var/log/concrete.log: expected true, got %v", got) + } + + // 2) Strip Values; only the wildcard Pattern remains. Suffix '.log' + // text-matches the pattern but the pattern is wildcardised — the + // correct answer is false (no concrete observation supports it). + pcp.Opens.Values = map[string]struct{}{} + got = lib.wasPathOpenedWithSuffix(types.String("test-cid"), types.String(".log")) + if b, _ := got.Value().(bool); b { + t.Errorf("suffix '.log' against ONLY wildcard pattern /var/log/⋯/foo.log: "+ + "expected false (patterns must not be scanned), got %v", got) } +} - objCache.SetSharedContainerData("test-container-id", &objectcache.WatchedContainerData{ - ContainerType: objectcache.Container, - ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ - objectcache.Container: { - { - Name: "test-container", - }, - }, +// TestWasPathOpenedWithPrefix_PatternsNotScanned mirrors the suffix +// test for the prefix path. Same rabbit finding (open.go:79 Also +// applies to: 111-123). +func TestWasPathOpenedWithPrefix_PatternsNotScanned(t *testing.T) { + pcp := &objectcache.ProjectedContainerProfile{ + Opens: objectcache.ProjectedField{ + All: true, + Values: map[string]struct{}{"/var/concrete/foo": {}}, + Patterns: []string{"/var/⋯/log/foo"}, }, - }) - - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", - Opens: []v1beta1.OpenCalls{ - { - Path: "/etc/passwd", - Flags: []string{"O_RDONLY"}, - }, - { - Path: "/tmp/test.txt", - Flags: []string{"O_WRONLY", "O_CREAT"}, - }, - }, - }) - objCache.SetApplicationProfile(profile) - - env, err := cel.NewEnv( - cel.Variable("containerID", cel.StringType), - cel.Variable("path", cel.StringType), - AP(&objCache, config.Config{}), - ) - if err != nil { - t.Fatalf("failed to create env: %v", err) } + objCache := &mockObjectCacheForPattern{pcp: pcp} + lib := &apLibrary{objectCache: objCache} - testCases := []struct { - name string - containerID string - path string - expectedResult bool - }{ - { - name: "Path exists in profile", - containerID: "test-container-id", - path: "/etc/passwd", - expectedResult: true, - }, - { - name: "Path does not exist in profile", - containerID: "test-container-id", - path: "/etc/nonexistent", - expectedResult: false, - }, - { - name: "Another path exists in profile", - containerID: "test-container-id", - path: "/tmp/test.txt", - expectedResult: true, - }, + got := lib.wasPathOpenedWithPrefix(types.String("test-cid"), types.String("/var/")) + if b, _ := got.Value().(bool); !b { + t.Fatalf("prefix '/var/' against concrete /var/concrete/foo: expected true, got %v", got) } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) - if issues != nil { - t.Fatalf("failed to compile expression: %v", issues.Err()) - } - - program, err := env.Program(ast) - if err != nil { - t.Fatalf("failed to create program: %v", err) - } - - result, _, err := program.Eval(map[string]interface{}{ - "containerID": tc.containerID, - "path": tc.path, - }) - if err != nil { - t.Fatalf("failed to eval program: %v", err) - } - - actualResult := result.Value().(bool) - assert.Equal(t, tc.expectedResult, actualResult, "ap.was_path_opened result should match expected value") - }) + pcp.Opens.Values = map[string]struct{}{} + got = lib.wasPathOpenedWithPrefix(types.String("test-cid"), types.String("/var/")) + if b, _ := got.Value().(bool); b { + t.Errorf("prefix '/var/' against ONLY wildcard pattern /var/⋯/log/foo: "+ + "expected false (patterns must not be scanned), got %v", got) } } -func TestOpenNoProfile(t *testing.T) { - objCache := objectcachev1.RuleObjectCacheMock{} - - env, err := cel.NewEnv( - cel.Variable("containerID", cel.StringType), - cel.Variable("path", cel.StringType), - AP(&objCache, config.Config{}), - ) - if err != nil { - t.Fatalf("failed to create env: %v", err) - } - - ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) - if issues != nil { - t.Fatalf("failed to compile expression: %v", issues.Err()) - } +// mockObjectCacheForPattern returns a fixed ProjectedContainerProfile +// for any containerID; used only by the suffix/prefix pattern tests +// above to bypass the full RuleObjectCacheMock setup. +type mockObjectCacheForPattern struct { + objectcache.ObjectCache + pcp *objectcache.ProjectedContainerProfile +} - program, err := env.Program(ast) - if err != nil { - t.Fatalf("failed to create program: %v", err) - } +func (m *mockObjectCacheForPattern) ContainerProfileCache() objectcache.ContainerProfileCache { + return &mockCPCForPattern{pcp: m.pcp} +} - result, _, err := program.Eval(map[string]interface{}{ - "containerID": "test-container-id", - "path": "/etc/passwd", - }) - if err != nil { - t.Fatalf("failed to eval program: %v", err) - } +type mockCPCForPattern struct { + objectcache.ContainerProfileCache + pcp *objectcache.ProjectedContainerProfile +} - actualResult := result.Value().(bool) - assert.False(t, actualResult, "ap.was_path_opened should return false when no profile is available") +func (m *mockCPCForPattern) GetProjectedContainerProfile(_ string) *objectcache.ProjectedContainerProfile { + return m.pcp } -func TestOpenWithFlagsInProfile(t *testing.T) { +func TestOpenInProfile(t *testing.T) { objCache := objectcachev1.RuleObjectCacheMock{ ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), } @@ -167,10 +142,6 @@ func TestOpenWithFlagsInProfile(t *testing.T) { Path: "/tmp/test.txt", Flags: []string{"O_WRONLY", "O_CREAT"}, }, - { - Path: "/var/log/app.log", - Flags: []string{"O_RDWR", "O_APPEND"}, - }, }, }) objCache.SetApplicationProfile(profile) @@ -178,7 +149,6 @@ func TestOpenWithFlagsInProfile(t *testing.T) { env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), - cel.Variable("flags", cel.ListType(cel.StringType)), AP(&objCache, config.Config{}), ) if err != nil { @@ -189,64 +159,31 @@ func TestOpenWithFlagsInProfile(t *testing.T) { name string containerID string path string - flags []string expectedResult bool }{ { - name: "Path and flags match exactly", - containerID: "test-container-id", - path: "/etc/passwd", - flags: []string{"O_RDONLY"}, - expectedResult: true, - }, - { - // v1 degradation: flags projection is out of scope; path-only matching. - name: "Path matches but flags don't match", + name: "Path exists in profile", containerID: "test-container-id", path: "/etc/passwd", - flags: []string{"O_WRONLY"}, expectedResult: true, }, { - name: "Path doesn't exist", + name: "Path does not exist in profile", containerID: "test-container-id", path: "/etc/nonexistent", - flags: []string{"O_RDONLY"}, expectedResult: false, }, { - name: "Multiple flags match", - containerID: "test-container-id", - path: "/tmp/test.txt", - flags: []string{"O_WRONLY", "O_CREAT"}, - expectedResult: true, - }, - { - name: "Multiple flags in different order", - containerID: "test-container-id", - path: "/tmp/test.txt", - flags: []string{"O_CREAT", "O_WRONLY"}, - expectedResult: true, - }, - { - name: "Partial flags match", + name: "Another path exists in profile", containerID: "test-container-id", path: "/tmp/test.txt", - flags: []string{"O_WRONLY"}, - expectedResult: true, - }, - { - name: "Empty flags list", - containerID: "test-container-id", - path: "/etc/passwd", - flags: []string{}, expectedResult: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ast, issues := env.Compile(`ap.was_path_opened_with_flags(containerID, path, flags)`) + ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -259,32 +196,30 @@ func TestOpenWithFlagsInProfile(t *testing.T) { result, _, err := program.Eval(map[string]interface{}{ "containerID": tc.containerID, "path": tc.path, - "flags": tc.flags, }) if err != nil { t.Fatalf("failed to eval program: %v", err) } actualResult := result.Value().(bool) - assert.Equal(t, tc.expectedResult, actualResult, "ap.was_path_opened_with_flags result should match expected value") + assert.Equal(t, tc.expectedResult, actualResult, "ap.was_path_opened result should match expected value") }) } } -func TestOpenWithFlagsNoProfile(t *testing.T) { +func TestOpenNoProfile(t *testing.T) { objCache := objectcachev1.RuleObjectCacheMock{} env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), - cel.Variable("flags", cel.ListType(cel.StringType)), AP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } - ast, issues := env.Compile(`ap.was_path_opened_with_flags(containerID, path, flags)`) + ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -297,40 +232,13 @@ func TestOpenWithFlagsNoProfile(t *testing.T) { result, _, err := program.Eval(map[string]interface{}{ "containerID": "test-container-id", "path": "/etc/passwd", - "flags": []string{"O_RDONLY"}, }) if err != nil { t.Fatalf("failed to eval program: %v", err) } actualResult := result.Value().(bool) - assert.False(t, actualResult, "ap.was_path_opened_with_flags should return false when no profile is available") -} - -func TestOpenWithFlagsCompilation(t *testing.T) { - objCache := objectcachev1.RuleObjectCacheMock{} - - env, err := cel.NewEnv( - cel.Variable("containerID", cel.StringType), - cel.Variable("path", cel.StringType), - cel.Variable("flags", cel.ListType(cel.StringType)), - AP(&objCache, config.Config{}), - ) - if err != nil { - t.Fatalf("failed to create env: %v", err) - } - - // Test that the function compiles correctly - ast, issues := env.Compile(`ap.was_path_opened_with_flags(containerID, path, flags)`) - if issues != nil { - t.Fatalf("failed to compile expression: %v", issues.Err()) - } - - // Test that we can create a program - _, err = env.Program(ast) - if err != nil { - t.Fatalf("failed to create program: %v", err) - } + assert.False(t, actualResult, "ap.was_path_opened should return false when no profile is available") } func TestOpenCompilation(t *testing.T) { diff --git a/pkg/rulemanager/cel/libraries/cache/function_cache.go b/pkg/rulemanager/cel/libraries/cache/function_cache.go index ba07eafcd3..1990f0dac7 100644 --- a/pkg/rulemanager/cel/libraries/cache/function_cache.go +++ b/pkg/rulemanager/cel/libraries/cache/function_cache.go @@ -84,7 +84,7 @@ type CelFunction func(...ref.Val) ref.Val // ensures cached results are invalidated whenever the projection spec changes. func HashForContainerProfile(oc objectcache.ObjectCache) func([]ref.Val) string { return func(values []ref.Val) string { - if len(values) == 0 || oc == nil { + if len(values) == 0 || values[0] == nil || oc == nil { return "" } containerIDStr, ok := values[0].Value().(string) diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go b/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go new file mode 100644 index 0000000000..7058ca1804 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go @@ -0,0 +1,239 @@ +package networkneighborhood + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/cel-go/common/types" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +// TestFixturesParse validates that every YAML fixture under +// tests/resources/network-wildcards/ parses against the v1beta1 +// NetworkNeighborhood schema. This is the user-facing-examples gate: +// the fixtures double as authoritative syntax documentation, so a +// fixture that fails to parse is a documentation bug. +// +// Fixture 14 (recursive-star-rejected) parses but its dnsNames entry +// '**' is rejected at admission time — see the storage REST strategy +// validation test (TestValidate_NetworkProfileEntries). +func TestFixturesParse(t *testing.T) { + fixturesDir := findFixturesDir(t) + entries, err := os.ReadDir(fixturesDir) + require.NoError(t, err) + + if len(entries) == 0 { + t.Fatalf("no fixtures found under %s", fixturesDir) + } + + parsed := 0 + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { + continue + } + name := e.Name() + t.Run(name, func(t *testing.T) { + data, err := os.ReadFile(filepath.Join(fixturesDir, name)) + require.NoError(t, err) + + // Strip the literal "{{NAMESPACE}}" placeholder; the fixtures + // are templates, runtime substitutes a real namespace. + data = []byte(strings.ReplaceAll(string(data), "{{NAMESPACE}}", "test-ns")) + + var nn v1beta1.NetworkNeighborhood + // Strict mode: any unknown field in a fixture is a typo + // against the v1beta1 schema. Documentation must not drift + // from the runtime types. + err = yaml.UnmarshalStrict(data, &nn) + require.NoError(t, err, "fixture %s must parse against v1beta1 schema (strict)", name) + require.Equal(t, "NetworkNeighborhood", nn.Kind, "fixture %s wrong kind", name) + require.NotEmpty(t, nn.Spec.Containers, "fixture %s should declare at least one container", name) + }) + parsed++ + } + if parsed < 20 { + t.Errorf("expected ≥ 20 fixtures, parsed %d", parsed) + } +} + +// TestFixturesMatchExpectedBehaviour walks a curated subset of fixtures +// through the actual CEL library matchers, asserting the documented +// observed→match behaviour from each fixture's header comment. +// +// This is the contract pin between the user-facing examples and the +// runtime: if a fixture says "10.1.2.3 → match" and the matcher +// disagrees, ONE of them is wrong. Today both are pinned by this test. +// +// Coverage: representative cases for each major edge case. Not every +// (fixture × observation) is exercised — that would be brittle as +// the fixtures evolve. +func TestFixturesMatchExpectedBehaviour(t *testing.T) { + cases := []struct { + name string + neighbors []v1beta1.NetworkNeighbor + ingress []v1beta1.NetworkNeighbor + // ipChecks verifies wasAddressInEgress only (back-compat for cases + // with no ingress declared; runs only the egress matcher). + ipChecks []ipCheck + // ipBothChecks verifies BOTH wasAddressInEgress and wasAddressInIngress + // — used for direction-isolation cases so the assertion goes both ways. + ipBothChecks []ipBothCheck + dnsChecks []dnsCheck + }{ + { + name: "fixture-01-literal-ipv4", + neighbors: []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.1.2.3"}}, + }, + ipChecks: []ipCheck{ + {"10.1.2.3", true}, + {"10.1.2.4", false}, + }, + }, + { + name: "fixture-03-cidr-ipv4", + neighbors: []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.0.0.0/8"}}, + }, + ipChecks: []ipCheck{ + {"10.0.0.0", true}, + {"10.255.255.255", true}, + {"11.0.0.1", false}, + }, + }, + { + name: "fixture-05-any-ip-sentinel", + neighbors: []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"*"}}, + }, + ipChecks: []ipCheck{ + {"1.2.3.4", true}, + {"::1", true}, + }, + }, + { + name: "fixture-08-deprecated-ipaddress", + neighbors: []v1beta1.NetworkNeighbor{ + {IPAddress: "10.1.2.3"}, // singular, deprecated form + }, + ipChecks: []ipCheck{ + {"10.1.2.3", true}, + {"10.1.2.4", false}, + }, + }, + { + name: "fixture-10-dns-leading-wildcard", + neighbors: []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"*.example.com."}}, + }, + dnsChecks: []dnsCheck{ + {"api.example.com.", true}, + {"v1.api.example.com.", false}, // RFC 4592: exactly one label + {"example.com.", false}, // zero labels + }, + }, + { + name: "fixture-18-cluster-dns-mid-ellipsis", + neighbors: []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"kubernetes.⋯.svc.cluster.local."}}, + }, + dnsChecks: []dnsCheck{ + {"kubernetes.default.svc.cluster.local.", true}, + {"kubernetes.kube-system.svc.cluster.local.", true}, + {"redis.default.svc.cluster.local.", false}, + }, + }, + { + name: "fixture-15-egress-and-ingress-direction-isolation", + neighbors: []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"8.8.8.8"}}, + }, + ingress: []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.244.0.0/16"}}, + }, + // Direction isolation: each address MUST hit only the direction + // it was declared on. CR (node-agent#41) flagged that the prior + // version only checked egress; this asserts ingress too. + ipBothChecks: []ipBothCheck{ + {observed: "8.8.8.8", wantEgress: true, wantIngress: false}, // egress-only + {observed: "10.244.5.5", wantEgress: false, wantIngress: true}, // ingress-only + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + lib := buildLibWithContainer(t, tc.neighbors, tc.ingress) + for _, c := range tc.ipChecks { + res := lib.wasAddressInEgress(types.String("cid"), types.String(c.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + if res != types.Bool(c.want) { + t.Errorf("egress ip %q: got %v, want %v", c.observed, res, c.want) + } + } + for _, c := range tc.ipBothChecks { + eg := lib.wasAddressInEgress(types.String("cid"), types.String(c.observed)) + eg = cache.ConvertProfileNotAvailableErrToBool(eg, false) + if eg != types.Bool(c.wantEgress) { + t.Errorf("egress ip %q: got %v, want %v", c.observed, eg, c.wantEgress) + } + in := lib.wasAddressInIngress(types.String("cid"), types.String(c.observed)) + in = cache.ConvertProfileNotAvailableErrToBool(in, false) + if in != types.Bool(c.wantIngress) { + t.Errorf("ingress ip %q: got %v, want %v", c.observed, in, c.wantIngress) + } + } + for _, c := range tc.dnsChecks { + res := lib.isDomainInEgress(types.String("cid"), types.String(c.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + if res != types.Bool(c.want) { + t.Errorf("dns %q: got %v, want %v", c.observed, res, c.want) + } + } + }) + } +} + +type ipCheck struct { + observed string + want bool +} + +type ipBothCheck struct { + observed string + wantEgress bool + wantIngress bool +} + +type dnsCheck struct { + observed string + want bool +} + +// findFixturesDir walks up from the test's working directory to locate +// tests/resources/network-wildcards/. The package's own working dir +// when `go test` runs is its source dir, so we walk up to find the +// repo root. +func findFixturesDir(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + require.NoError(t, err) + for i := 0; i < 10; i++ { + candidate := filepath.Join(dir, "tests", "resources", "network-wildcards") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + t.Fatalf("could not find tests/resources/network-wildcards/ from %s", dir) + return "" +} diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go index 7018e479a2..31b3113701 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go @@ -1,17 +1,82 @@ package networkneighborhood import ( + "net" + "strings" + "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" + "github.com/kubescape/node-agent/pkg/objectcache" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" "github.com/kubescape/node-agent/pkg/rulemanager/profilehelper" + "github.com/kubescape/storage/pkg/registry/file/networkmatch" ) +// matchIPField is the wildcard-aware adapter from the projection layer's +// ProjectedField (Values exact-set + Patterns slice) to the v0.0.2 wildcard +// semantics implemented in storage's networkmatch package. +// +// Order of checks (cheapest first): +// 1. Values map — exact byte equality +// 2. Patterns slice — CIDRs, '*' sentinels, RFC 4592 leading wildcards, +// mid-⋯, trailing-* (via networkmatch.MatchIP) +// +// ProjectedField.All is intentionally NOT consulted as a match short-circuit: +// it's the producer-side flag set when projectField is in pass-through +// retention mode (no rule declared profileDataRequired for this surface), +// in which case projectField has already populated Values with every raw +// entry. Treating it as a "match any" sentinel here would let unknown IPs +// match when they're absent from the profile (CR #43, finding R-NET-7). +// +// Cold-path use only: the existing CEL functionCache in nn.go memoises +// (containerID, observed) for the TTL window, so per-call MatchIP/MatchDNS +// cost only fires on cache misses. +func matchIPField(field *objectcache.ProjectedField, observed string) bool { + if observed == "" || field == nil { + return false + } + // Exact-string lookup first (cheapest). + if _, ok := field.Values[observed]; ok { + return true + } + // IP canonicalisation: observed "::ffff:10.0.0.1" should hit a profile + // entry of "10.0.0.1", and expanded IPv6 should hit compact IPv6. + // Single net.ParseIP per call; only fires on Values miss. + if parsed := net.ParseIP(observed); parsed != nil { + if _, ok := field.Values[parsed.String()]; ok { + return true + } + } + if len(field.Patterns) > 0 && networkmatch.MatchIP(field.Patterns, observed) { + return true + } + return false +} + +func matchDNSField(field *objectcache.ProjectedField, observed string) bool { + if observed == "" || field == nil { + return false + } + // FQDN trailing-dot normalisation per spec §5.8: both profile entries + // and observed names MAY or MAY NOT carry a trailing dot. Try both + // canonical forms against Values; cheaper than a per-call MatchDNS. + canon := strings.TrimSuffix(observed, ".") + if _, ok := field.Values[canon]; ok { + return true + } + if _, ok := field.Values[canon+"."]; ok { + return true + } + if len(field.Patterns) > 0 && networkmatch.MatchDNS(field.Patterns, observed) { + return true + } + return false +} + func (l *nnLibrary) wasAddressInEgress(containerID, address ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } - containerIDStr, ok := containerID.Value().(string) if !ok { return types.MaybeNoSuchOverloadErr(containerID) @@ -20,24 +85,17 @@ func (l *nnLibrary) wasAddressInEgress(containerID, address ref.Val) ref.Val { if !ok { return types.MaybeNoSuchOverloadErr(address) } - cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - - if _, ok := cp.EgressAddresses.Values[addressStr]; ok { - return types.Bool(true) - } - - return types.Bool(false) + return types.Bool(matchIPField(&cp.EgressAddresses, addressStr)) } func (l *nnLibrary) wasAddressInIngress(containerID, address ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } - containerIDStr, ok := containerID.Value().(string) if !ok { return types.MaybeNoSuchOverloadErr(containerID) @@ -46,24 +104,17 @@ func (l *nnLibrary) wasAddressInIngress(containerID, address ref.Val) ref.Val { if !ok { return types.MaybeNoSuchOverloadErr(address) } - cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - - if _, ok := cp.IngressAddresses.Values[addressStr]; ok { - return types.Bool(true) - } - - return types.Bool(false) + return types.Bool(matchIPField(&cp.IngressAddresses, addressStr)) } func (l *nnLibrary) isDomainInEgress(containerID, domain ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } - containerIDStr, ok := containerID.Value().(string) if !ok { return types.MaybeNoSuchOverloadErr(containerID) @@ -72,24 +123,17 @@ func (l *nnLibrary) isDomainInEgress(containerID, domain ref.Val) ref.Val { if !ok { return types.MaybeNoSuchOverloadErr(domain) } - cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - - if _, ok := cp.EgressDomains.Values[domainStr]; ok { - return types.Bool(true) - } - - return types.Bool(false) + return types.Bool(matchDNSField(&cp.EgressDomains, domainStr)) } func (l *nnLibrary) isDomainInIngress(containerID, domain ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } - containerIDStr, ok := containerID.Value().(string) if !ok { return types.MaybeNoSuchOverloadErr(containerID) @@ -98,24 +142,17 @@ func (l *nnLibrary) isDomainInIngress(containerID, domain ref.Val) ref.Val { if !ok { return types.MaybeNoSuchOverloadErr(domain) } - cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - - if _, ok := cp.IngressDomains.Values[domainStr]; ok { - return types.Bool(true) - } - - return types.Bool(false) + return types.Bool(matchDNSField(&cp.IngressDomains, domainStr)) } func (l *nnLibrary) wasAddressPortProtocolInEgress(containerID, address, port, protocol ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } - containerIDStr, ok := containerID.Value().(string) if !ok { return types.MaybeNoSuchOverloadErr(containerID) @@ -124,31 +161,30 @@ func (l *nnLibrary) wasAddressPortProtocolInEgress(containerID, address, port, p if !ok { return types.MaybeNoSuchOverloadErr(address) } - // port/protocol projection (AddressPortsByAddr) is out of scope for v1; degrade to address-only matching. - if _, ok := port.Value().(int64); !ok { + // port/protocol projection (AddressPortsByAddr) is out of scope for the + // projection-v1 layer upstream landed; matchers degrade to address-only. + // Wildcards remain enforced via matchIPField. + portInt, ok := port.Value().(int64) + if !ok { return types.MaybeNoSuchOverloadErr(port) } + if portInt < 0 || portInt > 65535 { + return types.Bool(false) + } if _, ok := protocol.Value().(string); !ok { return types.MaybeNoSuchOverloadErr(protocol) } - cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - - if _, ok := cp.EgressAddresses.Values[addressStr]; ok { - return types.Bool(true) - } - - return types.Bool(false) + return types.Bool(matchIPField(&cp.EgressAddresses, addressStr)) } func (l *nnLibrary) wasAddressPortProtocolInIngress(containerID, address, port, protocol ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } - containerIDStr, ok := containerID.Value().(string) if !ok { return types.MaybeNoSuchOverloadErr(containerID) @@ -157,22 +193,19 @@ func (l *nnLibrary) wasAddressPortProtocolInIngress(containerID, address, port, if !ok { return types.MaybeNoSuchOverloadErr(address) } - // port/protocol projection (AddressPortsByAddr) is out of scope for v1; degrade to address-only matching. - if _, ok := port.Value().(int64); !ok { + portInt, ok := port.Value().(int64) + if !ok { return types.MaybeNoSuchOverloadErr(port) } + if portInt < 0 || portInt > 65535 { + return types.Bool(false) + } if _, ok := protocol.Value().(string); !ok { return types.MaybeNoSuchOverloadErr(protocol) } - cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - - if _, ok := cp.IngressAddresses.Values[addressStr]; ok { - return types.Bool(true) - } - - return types.Bool(false) + return types.Bool(matchIPField(&cp.IngressAddresses, addressStr)) } diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go b/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go new file mode 100644 index 0000000000..0739dcc2b5 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go @@ -0,0 +1,386 @@ +package networkneighborhood + +import ( + "testing" + + "github.com/google/cel-go/common/types" + "github.com/goradd/maps" + "github.com/kubescape/node-agent/pkg/objectcache" + objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/assert" + "k8s.io/utils/ptr" +) + +// Helper: build a ready-to-use library with a single-container profile. +func buildLibWithContainer(t *testing.T, neighbors []v1beta1.NetworkNeighbor, ingressNeighbors []v1beta1.NetworkNeighbor) *nnLibrary { + t.Helper() + objCache := objectcachev1.RuleObjectCacheMock{ + ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), + } + objCache.SetSharedContainerData("cid", &objectcache.WatchedContainerData{ + ContainerType: objectcache.Container, + ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ + objectcache.Container: {{Name: "c"}}, + }, + }) + nn := &v1beta1.NetworkNeighborhood{} + nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ + Name: "c", + Egress: neighbors, + Ingress: ingressNeighbors, + }) + objCache.SetNetworkNeighborhood(nn) + return &nnLibrary{ + objectCache: &objCache, + functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), + } +} + +func TestWasAddressInEgress_WildcardCIDRMatch(t *testing.T) { + // Profile uses the new IPAddresses[] field with a CIDR. Old byte-equality + // implementation would fail to match observed IPs that fall inside. + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.0.0.0/8"}}, + }, nil) + + cases := []struct { + observed string + want bool + }{ + {"10.1.2.3", true}, // inside CIDR + {"10.255.255.254", true}, + {"11.0.0.1", false}, // outside + } + for _, tc := range cases { + t.Run(tc.observed, func(t *testing.T) { + res := lib.wasAddressInEgress(types.String("cid"), types.String(tc.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "address %q", tc.observed) + }) + } +} + +func TestWasAddressInEgress_AnyIPSentinel(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"*"}}, + }, nil) + + for _, addr := range []string{"1.2.3.4", "8.8.8.8", "10.0.0.1", "2001:db8::1"} { + res := lib.wasAddressInEgress(types.String("cid"), types.String(addr)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(true), res, "addr %q", addr) + } +} + +func TestWasAddressInEgress_LegacySingularStillWorks(t *testing.T) { + // Backward compatibility: profiles using the deprecated singular + // IPAddress field MUST keep matching as before. + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddress: "10.1.2.3"}, + }, nil) + + res := lib.wasAddressInEgress(types.String("cid"), types.String("10.1.2.3")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(true), res) + + res = lib.wasAddressInEgress(types.String("cid"), types.String("10.1.2.4")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(false), res) +} + +func TestWasAddressInEgress_BothSingularAndPlural(t *testing.T) { + // Mixed profile: one entry uses deprecated IPAddress, another uses new IPAddresses. + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddress: "8.8.8.8"}, + {IPAddresses: []string{"10.0.0.0/8"}}, + }, nil) + + for addr, want := range map[string]bool{ + "8.8.8.8": true, // deprecated singular hit + "10.1.2.3": true, // new CIDR hit + "1.2.3.4": false, // neither + } { + res := lib.wasAddressInEgress(types.String("cid"), types.String(addr)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(want), res, "addr %q", addr) + } +} + +func TestIsDomainInEgress_LeadingWildcard(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"*.stripe.com."}}, + }, nil) + + cases := []struct { + observed string + want bool + }{ + {"api.stripe.com.", true}, + {"webhooks.stripe.com.", true}, + {"v1.api.stripe.com.", false}, // two labels deep + {"stripe.com.", false}, // zero labels — RFC 4592 + {"api.stripe.org.", false}, + } + for _, tc := range cases { + t.Run(tc.observed, func(t *testing.T) { + res := lib.isDomainInEgress(types.String("cid"), types.String(tc.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "obs %q", tc.observed) + }) + } +} + +func TestIsDomainInEgress_MidEllipsis(t *testing.T) { + // User's specific case: parametric namespace label in K8s service FQDN. + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"kubernetes.⋯.svc.cluster.local."}}, + }, nil) + + cases := []struct { + observed string + want bool + }{ + {"kubernetes.default.svc.cluster.local.", true}, + {"kubernetes.kube-system.svc.cluster.local.", true}, + {"redis.default.svc.cluster.local.", false}, // wrong service prefix + {"kubernetes.foo.bar.svc.cluster.local.", false}, // two labels mid + } + for _, tc := range cases { + t.Run(tc.observed, func(t *testing.T) { + res := lib.isDomainInEgress(types.String("cid"), types.String(tc.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "obs %q", tc.observed) + }) + } +} + +func TestIsDomainInEgress_TrailingDotResilience(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"api.stripe.com"}}, // no trailing dot in profile + }, nil) + + // Observed name comes WITH trailing dot (FQDN canonical form). + res := lib.isDomainInEgress(types.String("cid"), types.String("api.stripe.com.")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(true), res) +} + +// CR (node-agent#41 round 3) flagged that routing the deprecated IPAddress +// through MatchIP (round 2 fix) creates an unspoken behaviour change: the +// deprecated field now ALSO accepts wildcard/CIDR patterns. This is +// intentional — the contract is "deprecated singular gets the same +// semantics as the list form" — and these tests pin it explicitly so it +// can't silently regress. +func TestWasAddressInEgress_DeprecatedIPAddress_AcceptsWildcardAndCIDR(t *testing.T) { + cases := []struct { + profileIP string + observed string + want bool + }{ + // '*' sentinel on the deprecated field — matches any valid IP + {"*", "1.2.3.4", true}, + {"*", "8.8.8.8", true}, + {"*", "::1", true}, + // CIDR on the deprecated field — same membership semantics + {"10.0.0.0/8", "10.1.2.3", true}, + {"10.0.0.0/8", "10.255.255.255", true}, + {"10.0.0.0/8", "11.0.0.1", false}, + {"0.0.0.0/0", "203.0.113.7", true}, // any-IPv4 via CIDR + {"::/0", "2001:db8::1", true}, // any-IPv6 via CIDR + // Literal still works + {"192.168.1.1", "192.168.1.1", true}, + {"192.168.1.1", "192.168.1.2", false}, + } + for _, tc := range cases { + t.Run(tc.profileIP+"_vs_"+tc.observed, func(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddress: tc.profileIP}, // deprecated singular field + }, nil) + res := lib.wasAddressInEgress(types.String("cid"), types.String(tc.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "profile=%q observed=%q", tc.profileIP, tc.observed) + }) + } +} + +// CR (node-agent#41 round 2) flagged that the deprecated singular IPAddress +// field originally compared via raw string equality, which would diverge from +// IPAddresses[] behaviour for IPv6 canonicalisation. neighborMatchesIP now +// routes both fields through MatchIP — pin the parity here. +func TestWasAddressInEgress_DeprecatedIPAddress_IPv6Canonicalisation(t *testing.T) { + cases := []struct { + profileIP string + observed string + want bool + }{ + {"2001:db8::1", "2001:db8::1", true}, // identical + {"2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0001", true}, // expanded form same address + {"10.0.0.1", "::ffff:10.0.0.1", true}, // IPv4-mapped IPv6 + {"10.0.0.1", "10.0.0.2", false}, // genuine miss + } + for _, tc := range cases { + t.Run(tc.profileIP+"_vs_"+tc.observed, func(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddress: tc.profileIP}, // deprecated singular field + }, nil) + res := lib.wasAddressInEgress(types.String("cid"), types.String(tc.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "profile=%q observed=%q", tc.profileIP, tc.observed) + }) + } +} + +// CR (node-agent#41) flagged that the deprecated singular DNS field +// originally compared via raw string equality, which would diverge from +// DNSNames behaviour for trailing-dot variants. neighborMatchesDNS now +// routes both fields through MatchDNS — pin the parity here. +func TestIsDomainInEgress_DeprecatedDNS_TrailingDotParity(t *testing.T) { + cases := []struct { + profileDNS string + observed string + want bool + }{ + {"api.stripe.com.", "api.stripe.com.", true}, // both with dot + {"api.stripe.com", "api.stripe.com.", true}, // profile no dot, observed with dot + {"api.stripe.com.", "api.stripe.com", true}, // profile with dot, observed no dot + {"api.stripe.com", "api.stripe.com", true}, // neither dot + {"api.stripe.com.", "api.stripe.org.", false}, // wrong TLD + } + for _, tc := range cases { + t.Run(tc.profileDNS+"_vs_"+tc.observed, func(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {DNS: tc.profileDNS}, // deprecated singular field + }, nil) + res := lib.isDomainInEgress(types.String("cid"), types.String(tc.observed)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "profile=%q observed=%q", tc.profileDNS, tc.observed) + }) + } +} + +// CR (node-agent#41) flagged int64→int32 wrap risk in port comparison. +// 4294967739 narrows to 443 — without the range guard this would +// incorrectly match a profile entry on port 443. +func TestWasAddressPortProtocolInEgress_PortWrapRejected(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + { + IPAddress: "10.1.2.3", + Ports: []v1beta1.NetworkPort{ + {Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}, + }, + }, + }, nil) + + // See TestWasAddressPortProtocolInEgress_WithCIDR for the + // port/protocol regression note. The port-range guard ([0, 65535]) + // still applies — what's gone is port-specific matching: any in-range + // port matches if the address matches. + cases := []struct { + name string + port int64 + want bool + }{ + {"in-range hit", 443, true}, + {"in-range miss", 444, true}, // was: false (port mismatch). Now matches: address-only after projection-v1. + {"wrap-to-443 rejected", 4294967739, false}, // (1<<32)+443 — range guard fires + {"negative rejected", -1, false}, // range guard fires + {"too-large rejected", 65536, false}, // range guard fires + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := lib.wasAddressPortProtocolInEgress( + types.String("cid"), types.String("10.1.2.3"), + types.Int(tc.port), types.String("TCP"), + ) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res) + }) + } +} + +func TestWasAddressInIngress_WildcardCIDR(t *testing.T) { + // Direction isolation: the same address can be allowed on ingress + // but not egress, and vice versa. + lib := buildLibWithContainer(t, + []v1beta1.NetworkNeighbor{ /* empty egress */ }, + []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.244.0.0/16"}}, + }, + ) + + t.Run("ingress-CIDR-hit", func(t *testing.T) { + res := lib.wasAddressInIngress(types.String("cid"), types.String("10.244.5.5")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(true), res) + }) + t.Run("egress-must-stay-empty", func(t *testing.T) { + // Same address on egress must NOT match — direction isolation. + res := lib.wasAddressInEgress(types.String("cid"), types.String("10.244.5.5")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(false), res) + }) +} + +func TestIsDomainInIngress_LeadingWildcard(t *testing.T) { + lib := buildLibWithContainer(t, + nil, + []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"*.internal."}}, + }, + ) + res := lib.isDomainInIngress(types.String("cid"), types.String("api.internal.")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(true), res) + + // Egress is empty so the same name must NOT match on egress. + res = lib.isDomainInEgress(types.String("cid"), types.String("api.internal.")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(false), res) +} + +func TestWasAddressPortProtocolInEgress_WithCIDR(t *testing.T) { + // Composed match: CIDR + port + protocol. Mirror of fixture 19. + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + { + IPAddresses: []string{"10.0.0.0/8"}, + Ports: []v1beta1.NetworkPort{ + {Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}, + }, + }, + }, nil) + + // NOTE: upstream's projection-v1 (PR #799) explicitly drops port/protocol + // granularity from the address surface — the comment in network.go reads + // "port/protocol projection (AddressPortsByAddr) is out of scope for v1; + // degrade to address-only matching". So the matcher now only checks IP. + // + // Spec §4.7 still says ports[] is per-neighbor; the runtime gap is a + // known limitation flagged in the rebase commit. Test expectations + // updated to match runtime reality. Bringing port/protocol back is a + // follow-up: would need projection_apply to surface a per-address + // (port, protocol) set into ProjectedContainerProfile and the CEL + // helper to consult it. + cases := []struct { + observed string + port int64 + proto string + want bool + }{ + {"10.1.2.3", 443, "TCP", true}, // CIDR match (port/proto not enforced) + {"10.1.2.3", 80, "TCP", true}, // was: wrong port — now matches address-only + {"10.1.2.3", 443, "UDP", true}, // was: wrong protocol — now matches address-only + {"11.0.0.1", 443, "TCP", false}, // outside CIDR — still rejected + } + for _, tc := range cases { + t.Run(tc.observed, func(t *testing.T) { + res := lib.wasAddressPortProtocolInEgress( + types.String("cid"), types.String(tc.observed), + types.Int(tc.port), types.String(tc.proto), + ) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res) + }) + } +} diff --git a/pkg/rulemanager/cel/libraries/parse/parse.go b/pkg/rulemanager/cel/libraries/parse/parse.go index ba82f982f6..b1fc0c56d4 100644 --- a/pkg/rulemanager/cel/libraries/parse/parse.go +++ b/pkg/rulemanager/cel/libraries/parse/parse.go @@ -17,7 +17,10 @@ func (l *parseLibrary) getExecPath(args ref.Val, comm ref.Val) ref.Val { return types.MaybeNoSuchOverloadErr(comm) } - // Implement the logic from GetExecPathFromEvent + // 2-arg overload — back-compat. Resolves args[0] → comm. + // Callers that have event.exepath SHOULD use the 3-arg overload below + // to stay symmetric with the recording side's resolveExecPath in + // pkg/containerprofilemanager/v1/event_reporting.go. if len(argsList) > 0 { if argsList[0] != "" { return types.String(argsList[0]) @@ -25,3 +28,55 @@ func (l *parseLibrary) getExecPath(args ref.Val, comm ref.Val) ref.Val { } return types.String(commStr) } + +// getExecPathWithExePath is the 3-arg overload that resolves the exec +// path with symlink-faithful precedence: +// +// 1. argv[0] when it's an absolute path (`/...`) — preserves symlink +// identity as invoked (e.g. busybox-based images where /bin/sh, +// /usr/bin/nslookup, /bin/echo are all symlinks to /bin/busybox; +// argv[0] carries the symlink form, exepath carries the kernel- +// resolved target). User-authored profiles list the symlink form, +// and the recording side (resolveExecPath in +// pkg/containerprofilemanager/v1/event_reporting.go) uses the same +// precedence so profile.Path matches what rules query. +// +// 2. exepath when argv[0] is bare (e.g. "sh", "curl") or empty — the +// kernel-authoritative path is the right tiebreaker here, and +// preserves the existing argv[0]-spoofing protection: an attacker +// passing a misleading bare argv[0] (e.g. argv[0]="sshd" while +// actually exec'ing /usr/bin/curl) gets resolved to the real +// exepath, not the bare lie. The "absolute path → trust argv[0]" +// rule is safe because the kernel only exposes an absolute argv[0] +// when execve was called with that exact path (modulo symlinks +// that the kernel itself follows transparently). +// +// 3. argv[0] when bare AND exepath empty (fexecve / AT_EMPTY_PATH). +// +// 4. comm as final fallback. +// +// This closes the spurious-R0001 gap on busybox-based containers AND +// the prior fork-shell case where event.exepath was the only source. +func (l *parseLibrary) getExecPathWithExePath(args ref.Val, comm ref.Val, exepath ref.Val) ref.Val { + exepathStr, ok := exepath.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(exepath) + } + + argsList, err := celparse.ParseList[string](args) + if err == nil && len(argsList) > 0 { + argv0 := argsList[0] + // Tier 1: absolute argv[0] wins. Symlink-faithful. + if len(argv0) > 0 && argv0[0] == '/' { + return types.String(argv0) + } + } + + // Tier 2: kernel-authoritative exepath when argv[0] is bare/empty. + if exepathStr != "" { + return types.String(exepathStr) + } + + // Tiers 3+4: defer to 2-arg fallback (argv[0]-bare → comm). + return l.getExecPath(args, comm) +} diff --git a/pkg/rulemanager/cel/libraries/parse/parselib.go b/pkg/rulemanager/cel/libraries/parse/parselib.go index 57b05be451..758492d542 100644 --- a/pkg/rulemanager/cel/libraries/parse/parselib.go +++ b/pkg/rulemanager/cel/libraries/parse/parselib.go @@ -47,6 +47,17 @@ func (l *parseLibrary) Declarations() map[string][]cel.FunctionOpt { return l.getExecPath(values[0], values[1]) }), ), + cel.Overload( + "parse_get_exec_path_with_exepath", + []*cel.Type{cel.ListType(cel.StringType), cel.StringType, cel.StringType}, + cel.StringType, + cel.FunctionBinding(func(values ...ref.Val) ref.Val { + if len(values) != 3 { + return types.NewErr("expected 3 arguments, got %d", len(values)) + } + return l.getExecPathWithExePath(values[0], values[1], values[2]) + }), + ), }, } } diff --git a/pkg/rulemanager/cel/libraries/parse/parsing_test.go b/pkg/rulemanager/cel/libraries/parse/parsing_test.go index 5677c8b56f..b756aaea51 100644 --- a/pkg/rulemanager/cel/libraries/parse/parsing_test.go +++ b/pkg/rulemanager/cel/libraries/parse/parsing_test.go @@ -135,3 +135,128 @@ func TestParseLibraryErrorCases(t *testing.T) { }) } } + +// TestGetExecPath_SymmetryWithRecordingSide pins the contract that the +// rule-side resolver MUST agree with pkg/containerprofilemanager/v1/ +// event_reporting.go:resolveExecPath. That recording function uses +// 1. exepath (kernel-authoritative) +// 2. argv[0] when non-empty +// 3. comm +// in that precedence order — so the path stored in the ApplicationProfile +// is whatever the kernel reports. +// +// If the rule side ignores exepath, the profile entry written under +// "/bin/sh" becomes unreachable when the runtime queries with the rule's +// resolved path "sh" (argv[0]), and R0001 fires spuriously on benign +// shell invocations — exactly the regression bobctl tune was hitting on +// merge/upstream-profile-rearch. +// +// These cases mirror TestResolveExecPath in pkg/containerprofilemanager/v1/ +// event_reporting_test.go. They use a 3-arg overload of parse.get_exec_path +// that accepts (args, comm, exepath). +func TestGetExecPath_SymmetryWithRecordingSide(t *testing.T) { + env, err := cel.NewEnv( + cel.Variable("event", cel.AnyType), + Parse(config.Config{}), + ) + if err != nil { + t.Fatalf("failed to create env: %v", err) + } + + tests := []struct { + name string + expr string + expected string + }{ + { + name: "exepath present (canonical exec)", + expr: "parse.get_exec_path(['/usr/sbin/unix_chkpwd', 'root'], 'unix_chkpwd', '/usr/sbin/unix_chkpwd')", + expected: "/usr/sbin/unix_chkpwd", + }, + { + name: "exepath disagrees with argv[0] — exepath wins (argv[0] spoofing)", + // kernel says /usr/bin/curl, argv[0] says sshd. Profile recorded by + // resolveExecPath has "/usr/bin/curl" — rule MUST query the same. + expr: "parse.get_exec_path(['sshd', '-i'], 'curl', '/usr/bin/curl')", + expected: "/usr/bin/curl", + }, + { + name: "exepath empty (fexecve / AT_EMPTY_PATH) — fall back to argv[0]", + expr: "parse.get_exec_path(['unix_chkpwd', 'root'], 'unix_chkpwd', '')", + expected: "unix_chkpwd", + }, + { + name: "exepath + argv[0] empty — fall back to comm", + expr: "parse.get_exec_path(['', 'root'], 'unix_chkpwd', '')", + expected: "unix_chkpwd", + }, + { + name: "fork-shell case — kernel /bin/sh, argv[0] sh, comm sh", + expr: "parse.get_exec_path(['sh', '-c', 'echo'], 'sh', '/bin/sh')", + expected: "/bin/sh", + }, + { + // Busybox-style symlink case: the user runs `/bin/sh` which is + // a symlink to `/bin/busybox`. Inspektor Gadget's eBPF tracer + // reports exepath as the kernel-resolved binary (`/bin/busybox`) + // while argv[0] preserves the symlink-as-invoked form + // (`/bin/sh`). User-authored profiles list the symlink form + // (which is what people think of), and the recording side's + // resolveExecPath records the same form when argv[0] is + // absolute. Rule-side resolution MUST match so ap.was_executed + // finds the profile entry on busybox-based images. + // + // Precedence: absolute-argv[0] > exepath > bare-argv[0] > comm. + // argv[0] being absolute is the signal that the symlink form + // is intentional and present at exec time; bare argv[0] is + // just a shell convention and the kernel-authoritative exepath + // should win (preserving the existing argv[0]-spoofing + // protection where attackers pass a misleading bare argv[0]). + name: "busybox symlink — argv[0] /bin/sh absolute, exepath /bin/busybox", + expr: "parse.get_exec_path(['/bin/sh', '-c', 'echo hi'], 'sh', '/bin/busybox')", + expected: "/bin/sh", + }, + { + name: "busybox symlink — nslookup absolute, exepath /bin/busybox", + expr: "parse.get_exec_path(['/usr/bin/nslookup', 'example.com'], 'nslookup', '/bin/busybox')", + expected: "/usr/bin/nslookup", + }, + { + // Negative case: argv[0] bare → exepath still wins. This + // preserves the argv[0] spoofing protection in the test above + // ("argv[0] spoofing"), where a bare argv[0]='sshd' was being + // rejected in favour of the kernel-authoritative exepath. + name: "bare argv[0] keeps spoof protection — exepath wins", + expr: "parse.get_exec_path(['sshd', '-i'], 'curl', '/usr/bin/curl')", + expected: "/usr/bin/curl", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ast, issues := env.Compile(tt.expr) + if issues != nil { + t.Fatalf("failed to compile expression: %v", issues.Err()) + } + program, err := env.Program(ast) + if err != nil { + t.Fatalf("failed to create program: %v", err) + } + result, _, err := program.Eval(map[string]interface{}{ + "event": map[string]interface{}{ + "args": []string{}, + "comm": "test", + "exepath": "", + }, + }) + if err != nil { + t.Fatalf("failed to eval program: %v", err) + } + actual, ok := result.Value().(string) + if !ok { + t.Fatalf("expected string result, got %T", result.Value()) + } + assert.Equal(t, tt.expected, actual, "result should match expected value") + }) + } +} diff --git a/pkg/rulemanager/ruleswatcher/watcher.go b/pkg/rulemanager/ruleswatcher/watcher.go index 45782beb23..9d4c4b003e 100644 --- a/pkg/rulemanager/ruleswatcher/watcher.go +++ b/pkg/rulemanager/ruleswatcher/watcher.go @@ -2,14 +2,18 @@ package ruleswatcher import ( "context" + "errors" "os" "github.com/Masterminds/semver/v3" "github.com/kubescape/go-logger" "github.com/kubescape/go-logger/helpers" + "github.com/kubescape/node-agent/pkg/config" "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/profiles" "github.com/kubescape/node-agent/pkg/watcher" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -22,14 +26,16 @@ var _ RulesWatcher = (*RulesWatcherImpl)(nil) type RulesWatcherImpl struct { ruleCreator rulecreator.RuleCreator k8sClient k8sclient.K8sClientInterface + cfg *config.Config callback RulesWatcherCallback watchResources []watcher.WatchResource } -func NewRulesWatcher(k8sClient k8sclient.K8sClientInterface, ruleCreator rulecreator.RuleCreator, callback RulesWatcherCallback) *RulesWatcherImpl { +func NewRulesWatcher(k8sClient k8sclient.K8sClientInterface, ruleCreator rulecreator.RuleCreator, callback RulesWatcherCallback, cfg *config.Config) *RulesWatcherImpl { return &RulesWatcherImpl{ ruleCreator: ruleCreator, k8sClient: k8sClient, + cfg: cfg, callback: callback, watchResources: []watcher.WatchResource{ watcher.NewWatchResource(typesv1.RuleGvr, metav1.ListOptions{}), @@ -71,7 +77,8 @@ func (w *RulesWatcherImpl) syncAllRulesAndNotify(ctx context.Context) { // syncAllRulesFromCluster fetches all rules from the cluster and syncs them with the rule creator. // Rules are filtered by: // 1. Enabled status - only enabled rules are considered -// 2. Agent version compatibility - rules with AgentVersionRequirement are checked against AGENT_VERSION env var using semver +// 2. Signature verification - if enabled, verifies rules have valid signatures +// 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 { @@ -80,12 +87,20 @@ func (w *RulesWatcherImpl) syncAllRulesFromCluster(ctx context.Context) error { var enabledRules []typesv1.Rule var skippedVersionCount int + var skippedVerificationCount int for _, item := range unstructuredList.Items { rules, err := unstructuredToRules(&item) if err != nil { logger.L().Warning("RulesWatcher - failed to convert rule during sync", helpers.Error(err)) continue } + + // Verify signature if enabled + if err := w.verifyRules(rules); err != nil { + skippedVerificationCount++ + continue + } + for _, rule := range rules.Spec.Rules { if rule.Enabled { // Check agent version requirement if specified @@ -109,7 +124,8 @@ func (w *RulesWatcherImpl) syncAllRulesFromCluster(ctx context.Context) error { logger.L().Info("RulesWatcher - synced rules from cluster", helpers.Int("enabledRules", len(enabledRules)), helpers.Int("totalRules", len(unstructuredList.Items)), - helpers.Int("skippedByVersion", skippedVersionCount)) + helpers.Int("skippedByVersion", skippedVersionCount), + helpers.Int("skippedByVerification", skippedVerificationCount)) return nil } @@ -126,6 +142,30 @@ func unstructuredToRules(obj *unstructured.Unstructured) (*typesv1.Rules, error) return rule, nil } +func (w *RulesWatcherImpl) verifyRules(rules *typesv1.Rules) error { + if w.cfg == nil || !w.cfg.EnableSignatureVerification { + return nil + } + rulesAdapter := profiles.NewRulesAdapter(rules) + if err := signature.VerifyObject(rulesAdapter); err != nil { + if errors.Is(err, signature.ErrObjectNotSigned) { + logger.L().Debug("Rules resource is not signed, skipping", + helpers.String("name", rules.Name), + helpers.String("namespace", rules.Namespace)) + } else { + logger.L().Warning("Rules resource signature verification failed", + helpers.String("name", rules.Name), + helpers.String("namespace", rules.Namespace), + helpers.Error(err)) + } + return err + } + logger.L().Debug("Rules resource signature verification successful", + helpers.String("name", rules.Name), + helpers.String("namespace", rules.Namespace)) + 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 { diff --git a/pkg/signature/annotations.go b/pkg/signature/annotations.go new file mode 100644 index 0000000000..f603b1ffe1 --- /dev/null +++ b/pkg/signature/annotations.go @@ -0,0 +1,24 @@ +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" +) + +var ErrObjectNotSigned = errors.New("object is not signed (missing signature annotation)") + +// ErrSignatureMismatch wraps the underlying cosign verifier failure when a +// signature is present but does not verify against the object's content + +// certificate. Callers (e.g. ContainerProfileCache's tamper-alert path) +// MUST distinguish this from operational errors (hash computation failure, +// verifier construction failure, malformed signature annotations) — only +// ErrSignatureMismatch indicates an actual tamper event. +var ErrSignatureMismatch = errors.New("signature verification failed") diff --git a/pkg/signature/cluster_flow_test.go b/pkg/signature/cluster_flow_test.go new file mode 100644 index 0000000000..23dfe89580 --- /dev/null +++ b/pkg/signature/cluster_flow_test.go @@ -0,0 +1,150 @@ +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.ApplicationProfile{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "", + Kind: "", + }, + } + profile.Name = "test-signed" + profile.Namespace = "default" + + adapter := profiles.NewApplicationProfileAdapter(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"] != "ApplicationProfile" { + 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.ApplicationProfile{ + 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.NewApplicationProfileAdapter(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..b26813d6d2 --- /dev/null +++ b/pkg/signature/cluster_scenario_test.go @@ -0,0 +1,88 @@ +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.ApplicationProfile{ + 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.NewApplicationProfileAdapter(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"] != "ApplicationProfile" { + 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 metadata["namespace"] != clusterProfile.Namespace { + t.Errorf("Expected metadata.namespace=%s, got %v", clusterProfile.Namespace, 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..b78d8920ab --- /dev/null +++ b/pkg/signature/cosign_adapter.go @@ -0,0 +1,572 @@ +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" + "strconv" + "time" + + "context" + "github.com/golang-jwt/jwt/v5" + "github.com/kubescape/storage/pkg/utils" + "github.com/sigstore/cosign/v3/pkg/cosign" + "github.com/sigstore/cosign/v3/pkg/cosign/bundle" + "github.com/sigstore/cosign/v3/pkg/providers" + _ "github.com/sigstore/cosign/v3/pkg/providers/all" + "github.com/sigstore/fulcio/pkg/api" + "github.com/sigstore/rekor/pkg/generated/client" + "github.com/sigstore/rekor/pkg/generated/models" + "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" +) + +var _ = cosign.Signature +var _ = providers.Enabled +var _ = bundle.RekorBundle{} +var _ = api.CertificateRequest{} +var _ = client.Rekor{} +var _ = models.LogEntry{} +var _ = fulcioroots.Get +var _ = oauthflow.OIDConnect +var _ = oauthflow.DefaultIDTokenGetter + +const ( + sigstoreIssuer = "https://token.actions.githubusercontent.com" + sigstoreOIDC = "kubernetes.io" + fulcioURL = "https://fulcio.sigstore.dev" + rekorURL = "https://rekor.sigstore.dev" +) + +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 := context.Background() + + 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 { + // Fallback to interactive flow if not in CI and no provider + fmt.Println("No OIDC provider enabled (CI). Falling back to interactive 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 + sig.Signature, err = base64.StdEncoding.DecodeString(signatureB64) + if err != nil { + // Try raw if base64 fails + sig.Signature = []byte(signatureB64) + } + + if certB64, ok := annotations[AnnotationCertificate]; ok { + sig.Certificate, err = base64.StdEncoding.DecodeString(certB64) + if err != nil { + // Try raw if base64 fails + sig.Certificate = []byte(certB64) + } + } + + 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..b125f51753 --- /dev/null +++ b/pkg/signature/cosign_adapter_test.go @@ -0,0 +1,143 @@ +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/interface.go b/pkg/signature/interface.go new file mode 100644 index 0000000000..720ca7a59c --- /dev/null +++ b/pkg/signature/interface.go @@ -0,0 +1,63 @@ +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{} +} + +type Signature struct { + Signature []byte + Certificate []byte + RekorBundle []byte + Issuer string + Identity string + Timestamp int64 +} + +type SignOptions struct { + UseKeyless bool + PrivateKey *ecdsa.PrivateKey +} + +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 + } +} + +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..0f9af91682 --- /dev/null +++ b/pkg/signature/profiles/adapter_test.go @@ -0,0 +1,335 @@ +package profiles + +import ( + "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 TestApplicationProfileAdapter(t *testing.T) { + profile := &v1beta1.ApplicationProfile{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "softwarecomposition.kubescape.io/v1beta1", + Kind: "ApplicationProfile", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ap", + Namespace: "default", + UID: types.UID("ap-uid-123"), + Labels: map[string]string{ + "app": "test", + }, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Architectures: []string{"amd64"}, + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "nginx", + Capabilities: []string{"CAP_NET_BIND_SERVICE"}, + }, + }, + }, + } + + adapter := NewApplicationProfileAdapter(profile) + + if adapter == nil { + t.Fatal("Expected non-nil adapter") + } + + if adapter.GetUID() != "ap-uid-123" { + t.Errorf("Expected UID 'ap-uid-123', got '%s'", adapter.GetUID()) + } + + if adapter.GetNamespace() != "default" { + t.Errorf("Expected namespace 'default', got '%s'", adapter.GetNamespace()) + } + + if adapter.GetName() != "test-ap" { + t.Errorf("Expected name 'test-ap', got '%s'", adapter.GetName()) + } + + annotations := adapter.GetAnnotations() + if annotations == nil { + t.Error("Expected annotations map, got nil") + } + + testAnnotations := map[string]string{ + "test-key": "test-value", + } + adapter.SetAnnotations(testAnnotations) + if profile.Annotations["test-key"] != "test-value" { + t.Error("Failed to set annotations") + } + + 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") + } + + metadata, ok := apContent["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("Expected metadata to be map[string]interface{}") + } + + if metadata["name"] != "test-ap" { + t.Errorf("Expected content name 'test-ap', got '%v'", metadata["name"]) + } + + if metadata["namespace"] != "default" { + t.Errorf("Expected content namespace 'default', got '%v'", metadata["namespace"]) + } + + if apContent["apiVersion"] != "softwarecomposition.kubescape.io/v1beta1" { + t.Errorf("Expected apiVersion 'softwarecomposition.kubescape.io/v1beta1', got '%v'", apContent["apiVersion"]) + } + + if apContent["kind"] != "ApplicationProfile" { + t.Errorf("Expected kind 'ApplicationProfile', got '%v'", apContent["kind"]) + } +} + +func TestApplicationProfileAdapterSignAndVerify(t *testing.T) { + profile := &v1beta1.ApplicationProfile{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "softwarecomposition.kubescape.io/v1beta1", + Kind: "ApplicationProfile", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "sign-test-ap", + Namespace: "default", + UID: types.UID("sign-ap-uid"), + Labels: map[string]string{ + "test": "signing", + }, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Architectures: []string{"amd64", "arm64"}, + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "app", + Capabilities: []string{"CAP_NET_ADMIN"}, + }, + }, + }, + } + + adapter := NewApplicationProfileAdapter(profile) + + err := signature.SignObjectDisableKeyless(adapter) + if err != nil { + t.Fatalf("SignObjectDisableKeyless failed: %v", err) + } + + if profile.Annotations == nil { + t.Error("Expected annotations to be set on profile") + } + + if _, ok := profile.Annotations[signature.AnnotationSignature]; !ok { + t.Error("Expected signature annotation on profile") + } + + err = signature.VerifyObjectAllowUntrusted(adapter) + if 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.GetNamespace() != "default" { + t.Errorf("Expected namespace 'default', got '%s'", adapter.GetNamespace()) + } + + if adapter.GetName() != "test-seccomp" { + t.Errorf("Expected name 'test-seccomp', got '%s'", adapter.GetName()) + } + + annotations := adapter.GetAnnotations() + if annotations == nil { + t.Error("Expected annotations map, got nil") + } + + testAnnotations := map[string]string{ + "seccomp-key": "seccomp-value", + } + adapter.SetAnnotations(testAnnotations) + if profile.Annotations["seccomp-key"] != "seccomp-value" { + t.Error("Failed to set annotations") + } + + 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") + } + + metadata, ok := scContent["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("Expected metadata to be map[string]interface{}") + } + + if metadata["name"] != "test-seccomp" { + t.Errorf("Expected content name 'test-seccomp', got '%v'", metadata["name"]) + } + + if metadata["namespace"] != "default" { + t.Errorf("Expected content namespace 'default', got '%v'", metadata["namespace"]) + } + + if scContent["apiVersion"] != "softwarecomposition.kubescape.io/v1beta1" { + t.Errorf("Expected apiVersion 'softwarecomposition.kubescape.io/v1beta1', got '%v'", scContent["apiVersion"]) + } + + if scContent["kind"] != "SeccompProfile" { + t.Errorf("Expected kind 'SeccompProfile', got '%v'", scContent["kind"]) + } +} + +func TestSeccompProfileAdapterSignAndVerify(t *testing.T) { + profile := &v1beta1.SeccompProfile{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "softwarecomposition.kubescape.io/v1beta1", + Kind: "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) + + err := signature.SignObjectDisableKeyless(adapter) + if err != nil { + t.Fatalf("SignObjectDisableKeyless failed: %v", err) + } + + if profile.Annotations == nil { + t.Error("Expected annotations to be set on profile") + } + + if _, ok := profile.Annotations[signature.AnnotationSignature]; !ok { + t.Error("Expected signature annotation on profile") + } + + err = signature.VerifyObjectAllowUntrusted(adapter) + if err != nil { + t.Fatalf("VerifyObjectAllowUntrusted failed: %v", err) + } +} + +func TestAdapterUniqueness(t *testing.T) { + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unique-ap", + Namespace: "default", + UID: types.UID("ap-unique-uid"), + }, + Spec: v1beta1.ApplicationProfileSpec{ + Architectures: []string{"amd64"}, + }, + } + + sp := &v1beta1.SeccompProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unique-sp", + Namespace: "default", + UID: types.UID("sp-unique-uid"), + }, + Spec: v1beta1.SeccompProfileSpec{}, + } + + apAdapter := NewApplicationProfileAdapter(ap) + spAdapter := NewSeccompProfileAdapter(sp) + + err := signature.SignObjectDisableKeyless(apAdapter) + if err != nil { + t.Fatalf("SignObjectDisableKeyless failed for ApplicationProfile: %v", err) + } + + err = signature.SignObjectDisableKeyless(spAdapter) + if err != nil { + t.Fatalf("SignObjectDisableKeyless failed for SeccompProfile: %v", err) + } + + apSig, err := signature.GetObjectSignature(apAdapter) + if err != nil { + t.Fatalf("GetObjectSignature failed for ApplicationProfile: %v", err) + } + + if apSig == nil { + t.Fatal("GetObjectSignature returned nil for ApplicationProfile") + } + + spSig, err := signature.GetObjectSignature(spAdapter) + if err != nil { + t.Fatalf("GetObjectSignature failed for SeccompProfile: %v", err) + } + + if spSig == nil { + t.Fatal("GetObjectSignature returned nil for SeccompProfile") + } + + if apSig.Issuer != "local" { + t.Errorf("Expected AP issuer 'local', got '%s'", apSig.Issuer) + } + + if spSig.Issuer != "local" { + t.Errorf("Expected SP issuer 'local', got '%s'", spSig.Issuer) + } +} diff --git a/pkg/signature/profiles/applicationprofile_adapter.go b/pkg/signature/profiles/applicationprofile_adapter.go new file mode 100644 index 0000000000..5a21b0a2e3 --- /dev/null +++ b/pkg/signature/profiles/applicationprofile_adapter.go @@ -0,0 +1,81 @@ +package profiles + +import ( + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +type ApplicationProfileAdapter struct { + profile *v1beta1.ApplicationProfile +} + +func NewApplicationProfileAdapter(profile *v1beta1.ApplicationProfile) *ApplicationProfileAdapter { + return &ApplicationProfileAdapter{ + profile: profile, + } +} + +func (a *ApplicationProfileAdapter) GetAnnotations() map[string]string { + if a.profile.Annotations == nil { + a.profile.Annotations = make(map[string]string) + } + return a.profile.Annotations +} + +func (a *ApplicationProfileAdapter) SetAnnotations(annotations map[string]string) { + a.profile.Annotations = annotations +} + +func (a *ApplicationProfileAdapter) GetUID() string { + return string(a.profile.UID) +} + +func (a *ApplicationProfileAdapter) GetNamespace() string { + return a.profile.Namespace +} + +func (a *ApplicationProfileAdapter) GetName() string { + return a.profile.Name +} + +func (a *ApplicationProfileAdapter) GetContent() interface{} { + // Normalize PolicyByRuleId to ensure consistent JSON representation + // Empty maps become {} instead of null + for i := range a.profile.Spec.Containers { + if a.profile.Spec.Containers[i].PolicyByRuleId == nil { + a.profile.Spec.Containers[i].PolicyByRuleId = make(map[string]v1beta1.RulePolicy) + } + } + for i := range a.profile.Spec.InitContainers { + if a.profile.Spec.InitContainers[i].PolicyByRuleId == nil { + a.profile.Spec.InitContainers[i].PolicyByRuleId = make(map[string]v1beta1.RulePolicy) + } + } + for i := range a.profile.Spec.EphemeralContainers { + if a.profile.Spec.EphemeralContainers[i].PolicyByRuleId == nil { + a.profile.Spec.EphemeralContainers[i].PolicyByRuleId = make(map[string]v1beta1.RulePolicy) + } + } + + apiVersion := a.profile.APIVersion + if apiVersion == "" { + apiVersion = "spdx.softwarecomposition.kubescape.io/v1beta1" + } + kind := a.profile.Kind + if kind == "" { + kind = "ApplicationProfile" + } + return map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "name": a.profile.Name, + "namespace": a.profile.Namespace, + "labels": a.profile.Labels, + }, + "spec": a.profile.Spec, + } +} + +func (a *ApplicationProfileAdapter) GetUpdatedObject() interface{} { + return a.profile +} diff --git a/pkg/signature/profiles/empty_typemeta_test.go b/pkg/signature/profiles/empty_typemeta_test.go new file mode 100644 index 0000000000..259ded5c77 --- /dev/null +++ b/pkg/signature/profiles/empty_typemeta_test.go @@ -0,0 +1,78 @@ +package profiles + +import ( + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestApplicationProfileAdapterEmptyTypeMeta(t *testing.T) { + profile := &v1beta1.ApplicationProfile{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "", + Kind: "", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ap", + Namespace: "default", + }, + Spec: v1beta1.ApplicationProfileSpec{ + Architectures: []string{"amd64"}, + }, + } + + adapter := NewApplicationProfileAdapter(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"] != "ApplicationProfile" { + t.Errorf("Expected fallback kind 'ApplicationProfile', 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/networkneighborhood_adapter.go b/pkg/signature/profiles/networkneighborhood_adapter.go new file mode 100644 index 0000000000..e62caf4312 --- /dev/null +++ b/pkg/signature/profiles/networkneighborhood_adapter.go @@ -0,0 +1,63 @@ +package profiles + +import ( + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +type NetworkNeighborhoodAdapter struct { + nn *v1beta1.NetworkNeighborhood +} + +func NewNetworkNeighborhoodAdapter(nn *v1beta1.NetworkNeighborhood) *NetworkNeighborhoodAdapter { + return &NetworkNeighborhoodAdapter{ + nn: nn, + } +} + +func (a *NetworkNeighborhoodAdapter) GetAnnotations() map[string]string { + if a.nn.Annotations == nil { + a.nn.Annotations = make(map[string]string) + } + return a.nn.Annotations +} + +func (a *NetworkNeighborhoodAdapter) SetAnnotations(annotations map[string]string) { + a.nn.Annotations = annotations +} + +func (a *NetworkNeighborhoodAdapter) GetUID() string { + return string(a.nn.UID) +} + +func (a *NetworkNeighborhoodAdapter) GetNamespace() string { + return a.nn.Namespace +} + +func (a *NetworkNeighborhoodAdapter) GetName() string { + return a.nn.Name +} + +func (a *NetworkNeighborhoodAdapter) GetContent() interface{} { + apiVersion := a.nn.APIVersion + if apiVersion == "" { + apiVersion = "spdx.softwarecomposition.kubescape.io/v1beta1" + } + kind := a.nn.Kind + if kind == "" { + kind = "NetworkNeighborhood" + } + return map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "name": a.nn.Name, + "namespace": a.nn.Namespace, + "labels": a.nn.Labels, + }, + "spec": a.nn.Spec, + } +} + +func (a *NetworkNeighborhoodAdapter) GetUpdatedObject() interface{} { + return a.nn +} diff --git a/pkg/signature/profiles/networkneighborhood_adapter_test.go b/pkg/signature/profiles/networkneighborhood_adapter_test.go new file mode 100644 index 0000000000..7968784eb6 --- /dev/null +++ b/pkg/signature/profiles/networkneighborhood_adapter_test.go @@ -0,0 +1,99 @@ +package profiles + +import ( + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNetworkNeighborhoodAdapter(t *testing.T) { + nn := &v1beta1.NetworkNeighborhood{ + TypeMeta: metav1.TypeMeta{ + Kind: "NetworkNeighborhood", + APIVersion: "spdx.softwarecomposition.kubescape.io/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-nn", + Namespace: "test-ns", + UID: "test-uid", + Annotations: map[string]string{ + "existing": "annotation", + }, + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + Containers: []v1beta1.NetworkNeighborhoodContainer{ + { + Name: "test-container", + Ingress: []v1beta1.NetworkNeighbor{ + { + Identifier: "test-neighbor", + }, + }, + }, + }, + }, + } + + adapter := NewNetworkNeighborhoodAdapter(nn) + + assert.Equal(t, "test-nn", adapter.GetName()) + assert.Equal(t, "test-ns", adapter.GetNamespace()) + assert.Equal(t, "test-uid", adapter.GetUID()) + + annotations := adapter.GetAnnotations() + assert.Equal(t, "annotation", annotations["existing"]) + + newAnnotations := map[string]string{"new": "annotation"} + adapter.SetAnnotations(newAnnotations) + assert.Equal(t, newAnnotations, nn.Annotations) + + content := adapter.GetContent().(map[string]interface{}) + assert.Equal(t, "NetworkNeighborhood", content["kind"]) + assert.Equal(t, "spdx.softwarecomposition.kubescape.io/v1beta1", content["apiVersion"]) + + metadata := content["metadata"].(map[string]interface{}) + assert.Equal(t, "test-nn", metadata["name"]) + assert.Equal(t, "test-ns", metadata["namespace"]) + + spec := content["spec"].(v1beta1.NetworkNeighborhoodSpec) + assert.Equal(t, 1, len(spec.Containers)) + assert.Equal(t, "test-container", spec.Containers[0].Name) + + assert.Equal(t, nn, adapter.GetUpdatedObject()) +} + +func TestNetworkNeighborhoodAdapter_EmptyTypeMeta(t *testing.T) { + nn := &v1beta1.NetworkNeighborhood{ + TypeMeta: metav1.TypeMeta{ + Kind: "", + APIVersion: "", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-nn", + Namespace: "test-ns", + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + Containers: []v1beta1.NetworkNeighborhoodContainer{ + { + Name: "test-container", + }, + }, + }, + } + + adapter := NewNetworkNeighborhoodAdapter(nn) + content := adapter.GetContent().(map[string]interface{}) + + assert.Equal(t, "NetworkNeighborhood", content["kind"]) + assert.Equal(t, "spdx.softwarecomposition.kubescape.io/v1beta1", content["apiVersion"]) + + metadata := content["metadata"].(map[string]interface{}) + assert.Equal(t, "test-nn", metadata["name"]) + assert.Equal(t, "test-ns", metadata["namespace"]) + + spec := content["spec"].(v1beta1.NetworkNeighborhoodSpec) + assert.Equal(t, 1, len(spec.Containers)) + assert.Equal(t, "test-container", spec.Containers[0].Name) +} diff --git a/pkg/signature/profiles/rules_adapter.go b/pkg/signature/profiles/rules_adapter.go new file mode 100644 index 0000000000..248e3c1a91 --- /dev/null +++ b/pkg/signature/profiles/rules_adapter.go @@ -0,0 +1,60 @@ +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": map[string]interface{}{ + "name": r.rules.Name, + "namespace": r.rules.Namespace, + "labels": r.rules.Labels, + }, + "spec": r.rules.Spec, + } +} + +func (r *RulesAdapter) GetUpdatedObject() interface{} { + return r.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..f617e4ebe6 --- /dev/null +++ b/pkg/signature/profiles/rules_adapter_test.go @@ -0,0 +1,184 @@ +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"]) + } + + if metadata["namespace"] != "default" { + t.Errorf("Expected namespace 'default', 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..8252cfbf76 --- /dev/null +++ b/pkg/signature/profiles/seccompprofile_adapter.go @@ -0,0 +1,63 @@ +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 { + if s.profile.Annotations == nil { + s.profile.Annotations = make(map[string]string) + } + 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 +} diff --git a/pkg/signature/sign.go b/pkg/signature/sign.go new file mode 100644 index 0000000000..74ef6ba819 --- /dev/null +++ b/pkg/signature/sign.go @@ -0,0 +1,114 @@ +package signature + +import ( + "fmt" + + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" +) + +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) + } + + 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) + } + + content := obj.GetContent() + + 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) + } + + existingAnnotations := obj.GetAnnotations() + if existingAnnotations == nil { + existingAnnotations = make(map[string]string) + } + + for k, v := range annotations { + existingAnnotations[k] = v + } + + obj.SetAnnotations(existingAnnotations) + + 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 +} + +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_test.go b/pkg/signature/sign_test.go new file mode 100644 index 0000000000..091484bce8 --- /dev/null +++ b/pkg/signature/sign_test.go @@ -0,0 +1,231 @@ +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") + } + }) + } +} diff --git a/pkg/signature/signer.go b/pkg/signature/signer.go new file mode 100644 index 0000000000..8f3197bd93 --- /dev/null +++ b/pkg/signature/signer.go @@ -0,0 +1,20 @@ +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..4278757cb8 --- /dev/null +++ b/pkg/signature/verifier.go @@ -0,0 +1,38 @@ +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..9c4e1c233e --- /dev/null +++ b/pkg/signature/verify.go @@ -0,0 +1,89 @@ +package signature + +import ( + "fmt" + + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" +) + +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 { + return fmt.Errorf("failed to decode signature from annotations: %w", err) + } + + content := obj.GetContent() + 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().Info("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..70973bf2ee --- /dev/null +++ b/pkg/signature/verify_test.go @@ -0,0 +1,435 @@ +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) { + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tamper-warn-ap", + Namespace: "tamper-ns", + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{{Path: "/usr/bin/curl"}}, + Syscalls: []string{"read", "write"}, + }, + }, + }, + } + + adapter := profiles.NewApplicationProfileAdapter(ap) + if err := SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("sign failed: %v", err) + } + + // Tamper: add an exec entry. + ap.Spec.Containers[0].Execs = append(ap.Spec.Containers[0].Execs, + v1beta1.ExecCalls{Path: "/usr/bin/nslookup"}) + + tamperedAdapter := profiles.NewApplicationProfileAdapter(ap) + + 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) { + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tamper-warn-nn", + Namespace: "tamper-ns", + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + Containers: []v1beta1.NetworkNeighborhoodContainer{ + { + Name: "curl", + Egress: []v1beta1.NetworkNeighbor{ + { + Identifier: "legit", + DNSNames: []string{"example.com."}, + IPAddress: "93.184.216.34", + }, + }, + }, + }, + }, + } + + adapter := profiles.NewNetworkNeighborhoodAdapter(nn) + if err := SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("sign failed: %v", err) + } + + // Tamper: add an egress entry. + nn.Spec.Containers[0].Egress = append(nn.Spec.Containers[0].Egress, + v1beta1.NetworkNeighbor{ + Identifier: "evil", + DNSNames: []string{"evil-c2.io."}, + IPAddress: "6.6.6.6", + }) + + tamperedAdapter := profiles.NewNetworkNeighborhoodAdapter(nn) + + 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) + } +} + +// TestSuccessfulVerifyLogsInfo verifies that a valid signature produces the +// "Successfully verified object signature" info log with identity and issuer. +func TestSuccessfulVerifyLogsInfo(t *testing.T) { + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-ap", + Namespace: "valid-ns", + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "nginx", + Execs: []v1beta1.ExecCalls{{Path: "/usr/sbin/nginx"}}, + Syscalls: []string{"read", "write", "openat"}, + }, + }, + }, + } + + adapter := profiles.NewApplicationProfileAdapter(ap) + 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 info 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) { + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sign-log-ap", + Namespace: "sign-ns", + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "app", + Execs: []v1beta1.ExecCalls{{Path: "/app/main"}}, + Syscalls: []string{"read"}, + }, + }, + }, + } + + adapter := profiles.NewApplicationProfileAdapter(ap) + + 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/chart/crds/runtime-rule-binding.crd.yaml b/tests/chart/crds/runtime-rule-binding.crd.yaml index d37280065e..d01b29b443 100644 --- a/tests/chart/crds/runtime-rule-binding.crd.yaml +++ b/tests/chart/crds/runtime-rule-binding.crd.yaml @@ -95,4 +95,4 @@ spec: items: type: string severity: - type: string \ No newline at end of file + type: string diff --git a/tests/chart/templates/node-agent/default-rule-binding.yaml b/tests/chart/templates/node-agent/default-rule-binding.yaml index 26367de97f..f51f3f66cf 100644 --- a/tests/chart/templates/node-agent/default-rule-binding.yaml +++ b/tests/chart/templates/node-agent/default-rule-binding.yaml @@ -15,6 +15,7 @@ spec: - "kubeconfig" rules: - ruleName: "Unexpected process launched" + - ruleName: "Unexpected process arguments" - ruleName: "Files Access Anomalies in container" - ruleName: "Syscalls Anomalies in container" - ruleName: "Linux Capabilities Anomalies in container" @@ -40,3 +41,4 @@ spec: - ruleName: "Unexpected Egress Network Traffic" - ruleName: "Malicious Ptrace Usage" - ruleName: "Unexpected io_uring Operation Detected" + - ruleName: "Signed profile tampered" diff --git a/tests/chart/templates/node-agent/default-rules.yaml b/tests/chart/templates/node-agent/default-rules.yaml index 0a4fe1d87f..f5431b79dd 100644 --- a/tests/chart/templates/node-agent/default-rules.yaml +++ b/tests/chart/templates/node-agent/default-rules.yaml @@ -1,7 +1,7 @@ apiVersion: kubescape.io/v1 kind: Rules metadata: - name: kubescape-rules + name: default-rules namespace: kubescape annotations: kubescape.io/namespace: kubescape @@ -18,7 +18,7 @@ spec: uniqueId: "event.comm + '_' + event.exepath" ruleExpression: - eventType: "exec" - expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm))" + expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))" profileDependency: 0 profileDataRequired: execs: all @@ -28,11 +28,51 @@ spec: mitreTactic: "TA0002" mitreTechnique: "T1059" tags: + - "anomaly" + - "process" + - "exec" + - "applicationprofile" - "context:kubernetes" + # --------------------------------------------------------------- + # R0040 — Unexpected process arguments + # + # Additive companion to R0001. Fires only when: + # 1. The exec'd path IS in the user-defined ApplicationProfile + # (so R0001 stays silent), AND + # 2. The runtime arg vector does NOT match any profile entry's + # arg pattern via dynamicpathdetector.CompareExecArgs. + # + # Profile arg vectors may carry wildcard tokens: + # "⋯" — exactly one position; "*" — zero or more trailing args. + # Anything else is literal-equality. + # + # Use case: a profile entry like {Path: "/bin/sh", Args: ["-c", "*"]} + # allows `sh -c ` but flags `sh -x ` as drift. + # --------------------------------------------------------------- + - name: "Unexpected process arguments" + enabled: true + id: "R0040" + description: "Process path is allowed by profile but argument vector does not match any profile entry's arg pattern (literal or wildcard ⋯/*)" + expressions: + message: "'Unexpected process arguments: ' + event.comm + ' with PID ' + string(event.pid)" + uniqueId: "event.comm + '_' + parse.get_exec_path(event.args, event.comm, event.exepath)" + ruleExpression: + - eventType: "exec" + expression: > + ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) && + !ap.was_executed_with_args(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath), event.args) + profileDependency: 0 + severity: 1 + supportPolicy: false + isTriggerAlert: true + mitreTactic: "TA0002" + mitreTechnique: "T1059" + tags: - "anomaly" - "process" - "exec" - "applicationprofile" + - "context:kubernetes" - name: "Files Access Anomalies in container" enabled: true id: "R0002" @@ -82,11 +122,11 @@ spec: mitreTactic: "TA0009" mitreTechnique: "T1005" tags: - - "context:kubernetes" - "anomaly" - "file" - "open" - "applicationprofile" + - "context:kubernetes" - name: "Syscalls Anomalies in container" enabled: true id: "R0003" @@ -106,10 +146,10 @@ spec: mitreTactic: "TA0002" mitreTechnique: "T1059" tags: - - "context:kubernetes" - "anomaly" - "syscall" - "applicationprofile" + - "context:kubernetes" - name: "Linux Capabilities Anomalies in container" enabled: true id: "R0004" @@ -129,10 +169,10 @@ spec: mitreTactic: "TA0002" mitreTechnique: "T1059" tags: - - "context:kubernetes" - "anomaly" - "capabilities" - "applicationprofile" + - "context:kubernetes" - name: "DNS Anomalies in container" enabled: true id: "R0005" @@ -148,14 +188,14 @@ spec: egressDomains: all severity: 1 supportPolicy: false - isTriggerAlert: false + isTriggerAlert: true mitreTactic: "TA0011" mitreTechnique: "T1071.004" tags: - - "context:kubernetes" - "dns" - "anomaly" - "networkprofile" + - "context:kubernetes" - name: "Unexpected service account token access" enabled: true id: "R0006" @@ -185,10 +225,10 @@ spec: mitreTactic: "TA0006" mitreTechnique: "T1528" tags: - - "context:kubernetes" - "anomaly" - "serviceaccount" - "applicationprofile" + - "context:kubernetes" - name: "Workload uses Kubernetes API unexpectedly" enabled: true id: "R0007" @@ -198,7 +238,7 @@ spec: uniqueId: "eventType == 'exec' ? 'exec_' + event.comm : 'network_' + event.dstAddr" ruleExpression: - eventType: "exec" - expression: "(event.comm == 'kubectl' || event.exepath.endsWith('/kubectl')) && !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm))" + expression: "(event.comm == 'kubectl' || event.exepath.endsWith('/kubectl')) && !ap.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) && !nn.was_address_in_egress(event.containerId, event.dstAddr)" profileDependency: 0 @@ -211,18 +251,18 @@ spec: mitreTactic: "TA0008" mitreTechnique: "T1210" tags: - - "context:kubernetes" - "exec" - "network" - "anomaly" - "applicationprofile" + - "context:kubernetes" - name: "Read Environment Variables from procfs" enabled: true id: "R0008" description: "Detecting reading environment variables from procfs." expressions: message: "'Reading environment variables from procfs: ' + event.path + ' by process ' + event.comm" - uniqueId: "event.comm" + uniqueId: "event.comm + '_' + event.path" ruleExpression: - eventType: "open" expression: > @@ -242,11 +282,11 @@ spec: mitreTactic: "TA0006" mitreTechnique: "T1552.001" tags: - - "context:kubernetes" - "anomaly" - "procfs" - "environment" - "applicationprofile" + - "context:kubernetes" - name: "eBPF Program Load" enabled: true id: "R0009" @@ -267,11 +307,10 @@ spec: mitreTactic: "TA0005" mitreTechnique: "T1218" tags: - - "context:kubernetes" - - "context:host" - "bpf" - "ebpf" - "applicationprofile" + - "context:kubernetes" - name: "Unexpected Sensitive File Access" enabled: true id: "R0010" @@ -292,13 +331,12 @@ spec: mitreTactic: "TA0006" mitreTechnique: "T1005" tags: - - "context:kubernetes" - - "context:host" - "files" - "anomaly" - "applicationprofile" + - "context:kubernetes" - name: "Unexpected Egress Network Traffic" - enabled: false + enabled: true id: "R0011" description: "Detecting unexpected egress network traffic that is not whitelisted by application profile." expressions: @@ -312,15 +350,15 @@ spec: egressAddresses: all severity: 5 # Medium supportPolicy: false - isTriggerAlert: false + isTriggerAlert: true mitreTactic: "TA0010" mitreTechnique: "T1041" tags: - - "context:kubernetes" - "whitelisted" - "network" - "anomaly" - "networkprofile" + - "context:kubernetes" - name: "Process executed from malicious source" enabled: true id: "R1000" @@ -333,7 +371,7 @@ spec: expression: > (event.exepath == '/dev/shm' || event.exepath.startsWith('/dev/shm/')) || (event.cwd == '/dev/shm' || event.cwd.startsWith('/dev/shm/') || - (parse.get_exec_path(event.args, event.comm).startsWith('/dev/shm/'))) + (parse.get_exec_path(event.args, event.comm, event.exepath).startsWith('/dev/shm/'))) profileDependency: 2 severity: 8 supportPolicy: false @@ -341,11 +379,10 @@ spec: mitreTactic: "TA0002" mitreTechnique: "T1059" tags: - - "context:kubernetes" - - "context:host" - "exec" - "signature" - "malicious" + - "context:kubernetes" - name: "Drifted process executed" enabled: true id: "R1001" @@ -358,7 +395,7 @@ spec: expression: > (event.upperlayer == true || event.pupperlayer == true) && - !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm)) + !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) profileDependency: 1 profileDataRequired: execs: all @@ -368,12 +405,12 @@ spec: mitreTactic: "TA0005" mitreTechnique: "T1036" tags: - - "context:kubernetes" - "exec" - "malicious" - "binary" - "base image" - "applicationprofile" + - "context:kubernetes" - name: "Process tries to load a kernel module" enabled: true id: "R1002" @@ -391,12 +428,11 @@ spec: mitreTactic: "TA0005" mitreTechnique: "T1547.006" tags: - - "context:kubernetes" - - "context:host" - "kmod" - "kernel" - "module" - "load" + - "context:kubernetes" - name: "Disallowed ssh connection" enabled: false id: "R1003" @@ -416,12 +452,12 @@ spec: mitreTactic: "TA0008" mitreTechnique: "T1021.001" tags: - - "context:kubernetes" - "ssh" - "connection" - "port" - "malicious" - "networkprofile" + - "context:kubernetes" - name: "Process executed from mount" enabled: true id: "R1004" @@ -431,7 +467,7 @@ spec: uniqueId: "event.comm" ruleExpression: - eventType: "exec" - expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm)) && 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).startsWith(mount))" + expression: "!ap.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))" profileDependency: 1 profileDataRequired: execs: all @@ -441,10 +477,10 @@ spec: mitreTactic: "TA0002" mitreTechnique: "T1059" tags: - - "context:kubernetes" - "exec" - "mount" - "applicationprofile" + - "context:kubernetes" - name: "Fileless execution detected" enabled: true id: "R1005" @@ -462,11 +498,10 @@ spec: mitreTactic: "TA0005" mitreTechnique: "T1055" tags: - - "context:kubernetes" - - "context:host" - "fileless" - "execution" - "malicious" + - "context:kubernetes" - name: "Process tries to escape container" enabled: true id: "R1006" @@ -487,12 +522,12 @@ spec: mitreTactic: "TA0004" mitreTechnique: "T1611" tags: - - "context:kubernetes" - "unshare" - "escape" - "unshare" - "anomaly" - "applicationprofile" + - "context:kubernetes" - name: "Crypto miner launched" enabled: true id: "R1007" @@ -510,10 +545,10 @@ spec: mitreTactic: "TA0040" mitreTechnique: "T1496" tags: - - "context:kubernetes" - "crypto" - "miners" - "malicious" + - "context:kubernetes" - name: "Crypto Mining Domain Communication" enabled: true id: "R1008" @@ -531,13 +566,12 @@ spec: mitreTactic: "TA0011" mitreTechnique: "T1071.004" tags: - - "context:kubernetes" - - "context:host" - "network" - "crypto" - "miners" - "malicious" - "dns" + - "context:kubernetes" - name: "Crypto Mining Related Port Communication" enabled: true id: "R1009" @@ -561,13 +595,12 @@ spec: mitreTactic: "TA0011" mitreTechnique: "T1071" tags: - - "context:kubernetes" - - "context:host" - "network" - "crypto" - "miners" - "malicious" - "networkprofile" + - "context:kubernetes" - name: "Soft link created over sensitive file" enabled: true id: "R1010" @@ -589,11 +622,10 @@ spec: mitreTactic: "TA0006" mitreTechnique: "T1005" tags: - - "context:kubernetes" - - "context:host" - "anomaly" - "symlink" - "applicationprofile" + - "context:kubernetes" - name: "ld_preload hooks technique detected" enabled: false id: "R1011" @@ -616,10 +648,10 @@ spec: mitreTactic: "TA0005" mitreTechnique: "T1574.006" tags: - - "context:kubernetes" - "exec" - "malicious" - "applicationprofile" + - "context:kubernetes" - name: "Hard link created over sensitive file" enabled: true id: "R1012" @@ -641,10 +673,10 @@ spec: mitreTactic: "TA0006" mitreTechnique: "T1005" tags: - - "context:kubernetes" - "files" - "malicious" - "applicationprofile" + - "context:kubernetes" - name: "Malicious Ptrace Usage" enabled: true id: "R1015" @@ -662,10 +694,9 @@ spec: mitreTactic: "TA0005" mitreTechnique: "T1622" tags: - - "context:kubernetes" - - "context:host" - "process" - "malicious" + - "context:kubernetes" - name: "Unexpected io_uring Operation Detected" enabled: true id: "R1030" @@ -685,7 +716,25 @@ spec: mitreTactic: "TA0002" mitreTechnique: "T1218" tags: - - "context:kubernetes" - "syscalls" - "io_uring" - "applicationprofile" + - "context:kubernetes" + - name: "Signed profile tampered" + enabled: true + id: "R1016" + description: "Detects when a previously signed ApplicationProfile or NetworkNeighborhood has been tampered with (signature no longer valid)." + expressions: + message: "'Signed profile tampered'" + uniqueId: "'R1016'" + ruleExpression: [] + profileDependency: 2 + severity: 10 + supportPolicy: false + isTriggerAlert: false + mitreTactic: "TA0005" + mitreTechnique: "T1565" + tags: + - "integrity" + - "signature" + - "tamper" diff --git a/tests/chart/values.yaml b/tests/chart/values.yaml index 1aea3a150f..db2872bb62 100644 --- a/tests/chart/values.yaml +++ b/tests/chart/values.yaml @@ -32,7 +32,7 @@ global: storage: name: "storage" image: - repository: quay.io/kubescape/storage + repository: ghcr.io/k8sstormcenter/storage tag: v0.0.156 pullPolicy: Always cleanupInterval: "6h" @@ -50,7 +50,7 @@ storage: nodeAgent: name: node-agent image: - repository: quay.io/kubescape/node-agent + repository: ghcr.io/k8sstormcenter/node-agent tag: v0.2.21 pullPolicy: IfNotPresent diff --git a/tests/component_test.go b/tests/component_test.go index fcdb760bfb..d7fd045e69 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -8,9 +8,11 @@ import ( "fmt" "path" "reflect" + "runtime" "slices" "sort" "strconv" + "strings" "testing" "time" @@ -18,6 +20,8 @@ import ( "github.com/kubescape/go-logger/helpers" helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/k8s-interface/k8sinterface" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" "github.com/kubescape/node-agent/pkg/utils" "github.com/kubescape/node-agent/tests/testutils" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" @@ -491,56 +495,289 @@ func Test_09_FalsePositiveTest(t *testing.T) { alerts, err := testutils.GetAlerts(ns.Name) require.NoError(t, err, "Error getting alerts") - assert.Equal(t, 0, len(alerts), "Expected no alerts to be generated, but got %d alerts", len(alerts)) + // Some rules are structurally noisy on real apps and can't reasonably + // reach zero alerts under an auto-learned baseline: + // + // - R0003 (Syscalls Anomalies): the baseline can never capture + // every syscall a real workload will eventually make (rare + // error paths, late-startup allocations, GC, async I/O). Bob + // chart ships R0003 disabled by default. + // - R0006 (Unexpected service account token access): every pod + // with a service-account legitimately reads + // /var/run/secrets/kubernetes.io/serviceaccount/token to + // authenticate to the K8s API. Hipster-shop services (and the + // prometheus / alertmanager infra the test framework deploys) + // all do this on startup and on every API call. + // + // Test_09's contract is "no FPs on benign workloads under EXEC / + // OPEN / NETWORK / SIGNED-PROFILE rules" — the noisy syscall- and + // SA-token rules are evaluated on their own merits elsewhere (e.g. + // Test_10's 10b subtest pins R0003 firing when the AP declares NO + // syscalls). Filter both out here. + noisyRules := map[string]string{ + "R0003": "Syscalls Anomalies", + "R0006": "SA token access", + } + filtered := alerts[:0] + excluded := map[string]int{} + for _, a := range alerts { + if _, isNoisy := noisyRules[a.Labels["rule_id"]]; isNoisy { + excluded[a.Labels["rule_id"]]++ + continue + } + filtered = append(filtered, a) + } + for ruleID, count := range excluded { + t.Logf("excluded %d %s (%s) alerts from FP gate — structurally noisy on real apps", count, ruleID, noisyRules[ruleID]) + } + if len(filtered) > 0 { + for i, a := range filtered { + t.Logf("unexpected FP[%d]: rule_id=%s rule_name=%s comm=%s container=%s", i, a.Labels["rule_id"], a.Labels["rule_name"], a.Labels["comm"], a.Labels["container_name"]) + } + } + assert.Equal(t, 0, len(filtered), "Expected no non-noisy alerts to be generated, but got %d (excluding %v)", len(filtered), excluded) } +// Test_10_CryptoMinerDetection tests crypto-miner detection from two angles: +// - malware_scan: ClamAV file-scanning detects xmrig binary signature +// - empty_profile_rules: empty user-defined AP means every exec/DNS is anomalous, +// so rule-based detection fires immediately without a learning period func Test_10_MalwareDetectionTest(t *testing.T) { start := time.Now() defer tearDownTest(t, start) - t.Log("Creating namespace") - ns := testutils.NewRandomNamespace() + // --------------------------------------------------------------- + // 10a. Malware file-scanning (ClamAV signature match) + // --------------------------------------------------------------- + t.Run("malware_scan", func(t *testing.T) { + ns := testutils.NewRandomNamespace() - t.Log("Deploy container with malware") - exitCode := testutils.RunCommand("kubectl", "run", "-n", ns.Name, "malware-cryptominer", "--image=quay.io/petr_ruzicka/malware-cryptominer-container:2.0.2") - require.Equalf(t, 0, exitCode, "expected no error when deploying malware container") + t.Log("Deploy container with malware") + exitCode := testutils.RunCommand("kubectl", "run", "-n", ns.Name, "malware-cryptominer", "--image=quay.io/petr_ruzicka/malware-cryptominer-container:2.0.2") + require.Equalf(t, 0, exitCode, "expected no error when deploying malware container") - // Wait for pod to be ready - exitCode = testutils.RunCommand("kubectl", "wait", "--for=condition=Ready", "pod", "malware-cryptominer", "-n", ns.Name, "--timeout=300s") - require.Equalf(t, 0, exitCode, "expected no error when waiting for pod to be ready") + exitCode = testutils.RunCommand("kubectl", "wait", "--for=condition=Ready", "pod", "malware-cryptominer", "-n", ns.Name, "--timeout=300s") + require.Equalf(t, 0, exitCode, "expected no error when waiting for pod to be ready") - // wait for application profile to be completed - time.Sleep(3 * time.Minute) + // Wait for application profile to be completed. + time.Sleep(3 * time.Minute) - _, _, err := testutils.ExecIntoPod("malware-cryptominer", ns.Name, []string{"ls", "-l", "/usr/share/nginx/html/xmrig"}, "") - require.NoErrorf(t, err, "expected no error when executing command in malware container") + _, _, err := testutils.ExecIntoPod("malware-cryptominer", ns.Name, []string{"ls", "-l", "/usr/share/nginx/html/xmrig"}, "") + require.NoErrorf(t, err, "expected no error when executing command in malware container") - _, _, err = testutils.ExecIntoPod("malware-cryptominer", ns.Name, []string{"/usr/share/nginx/html/xmrig/xmrig"}, "") + _, _, err = testutils.ExecIntoPod("malware-cryptominer", ns.Name, []string{"/usr/share/nginx/html/xmrig/xmrig"}, "") - // wait for the alerts to be generated - time.Sleep(20 * time.Second) + time.Sleep(20 * time.Second) - alerts, err := testutils.GetMalwareAlerts(ns.Name) - require.NoError(t, err, "Error getting alerts") + alerts, err := testutils.GetMalwareAlerts(ns.Name) + require.NoError(t, err, "Error getting alerts") - expectedMalwares := []string{ - "Multios.Coinminer.Miner-6781728-2.UNOFFICIAL", - } + expectedMalwares := []string{ + "Multios.Coinminer.Miner-6781728-2.UNOFFICIAL", + } - malwaresDetected := map[string]bool{} + malwaresDetected := map[string]bool{} + for _, alert := range alerts { + podName, podNameOk := alert.Labels["pod_name"] + malwareName, malwareNameOk := alert.Labels["malware_name"] + if podNameOk && malwareNameOk { + if podName == "malware-cryptominer" && slices.Contains(expectedMalwares, malwareName) { + malwaresDetected[malwareName] = true + } + } + } - for _, alert := range alerts { - podName, podNameOk := alert.Labels["pod_name"] - malwareName, malwareNameOk := alert.Labels["malware_name"] + assert.Equal(t, len(expectedMalwares), len(malwaresDetected), + "Expected %d malwares to be detected, but got %d", len(expectedMalwares), len(malwaresDetected)) + }) + + // --------------------------------------------------------------- + // 10b. Behavioral rule detection with empty user-defined AP. + // The miner starts immediately; because the AP declares nothing, + // every exec, DNS lookup, and network connection is anomalous. + // + // Expected rules: + // R0001: Unexpected process launched (every exec) + // R0003: Syscalls Anomalies (empty syscall list) + // + // Rules that MAY fire depending on network conditions: + // R0005: DNS Anomalies (requires DNS responses with answers; + // trace_dns drops NXDOMAIN, so behind a firewall these + // won't arrive) + // R1008: Crypto Mining Domain Communication (same DNS dependency) + // R1009: Crypto Mining Related Port Communication (requires TCP + // connectivity to mining pool ports 3333/45700) + // R1007: Crypto miner launched via randomx (amd64 only) + // + // Race condition note: the node-agent fetches the user-defined AP + // from storage asynchronously after detecting the container. Events + // arriving before the fetch completes see profileExists=false, + // causing Required rules (R0001 etc.) to be skipped. The miner's + // initial exec happens during this window — so we must exec into + // the pod AFTER the profile is cached to generate observable exec + // events. + // --------------------------------------------------------------- + t.Run("empty_profile_rules", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + // Create an ApplicationProfile with an empty container entry for k8s-miner. + // The container name must match the pod's container so + // GetContainerFromApplicationProfile finds it. With no execs, syscalls, + // opens, or capabilities listed, every operation is anomalous. + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "crypto2", + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + {Name: "k8s-miner"}, + }, + }, + } - if podNameOk && malwareNameOk { - if podName == "malware-cryptominer" && slices.Contains(expectedMalwares, malwareName) { - malwaresDetected[malwareName] = true + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "create empty AP in storage") + + require.Eventually(t, func() bool { + _, getErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), "crypto2", v1.GetOptions{}) + return getErr == nil + }, 30*time.Second, 1*time.Second, "empty AP must be stored") + + // Deploy crypto miner with user-defined profile label. + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/crypto-miner-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + t.Log("Crypto miner pod is ready") + + // Wait for node-agent to fetch the user-defined AP from storage and + // cache it. The miner's initial execve races with this fetch, so + // R0001 is skipped for that event. Syscalls keep flowing, so R0003 + // fires once the profile is cached. + time.Sleep(20 * time.Second) + + // Exec into the pod to generate post-profile-load events: + // exec event → R0001 (cat not in empty AP) + // open event → R0002 (/etc/hostname starts with /etc/) + stdout, stderr, execErr := wl.ExecIntoPod([]string{"cat", "/etc/hostname"}, "k8s-miner") + t.Logf("exec cat /etc/hostname: err=%v stdout=%q stderr=%q", execErr, stdout, stderr) + + // Collect alerts — R0001 must appear from the exec above. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + alerts, err = testutils.GetAlerts(ns.Name) + if err != nil || len(alerts) == 0 { + return false } + for _, a := range alerts { + if a.Labels["rule_id"] == "R0001" { + return true + } + } + return false + }, 120*time.Second, 10*time.Second, "expected R0001 alert from exec with empty AP") + + time.Sleep(15 * time.Second) + alerts, _ = testutils.GetAlerts(ns.Name) + + t.Logf("=== %d alerts ===", len(alerts)) + for i, a := range alerts { + t.Logf(" [%d] %s(%s) comm=%s container=%s", + i, a.Labels["rule_name"], a.Labels["rule_id"], + a.Labels["comm"], a.Labels["container_name"]) + } + + rulesSeen := map[string]bool{} + for _, a := range alerts { + rulesSeen[a.Labels["rule_id"]] = true + } + + // These rules must fire with an empty AP — every operation is anomalous. + assert.True(t, rulesSeen["R0001"], + "R0001 (Unexpected process launched) must fire — cat exec not in empty AP") + assert.True(t, rulesSeen["R0002"], + "R0002 (Files Access Anomalies) must fire — /etc/hostname not in empty AP opens") + assert.True(t, rulesSeen["R0003"], + "R0003 (Syscalls Anomalies) must fire — miner syscalls not in empty AP") + assert.True(t, rulesSeen["R0004"], + "R0004 (Linux Capabilities Anomalies) must fire — capabilities not in empty AP") + + // DNS/network rules depend on the miner resolving pool domains and + // establishing TCP connections. In sandboxed/firewalled environments + // these won't fire: trace_dns drops NXDOMAIN, and TCP to mining + // ports is blocked. Log what fired for visibility. + for _, entry := range []struct { + id, desc string + }{ + {"R0005", "DNS Anomalies"}, + {"R1007", "Crypto miner launched via randomx"}, + {"R1008", "Crypto Mining Domain Communication"}, + {"R1009", "Crypto Mining Related Port Communication"}, + } { + if rulesSeen[entry.id] { + t.Logf("%s (%s) fired", entry.id, entry.desc) + } + } + }) + + // --------------------------------------------------------------- + // 10c. RandomX detection (R1007) via xmrig benchmark mode. + // Uses --bench 1M which runs RandomX hashing without a pool + // connection, reliably triggering the x86 FPU tracepoint + // that the randomx eBPF gadget monitors. + // x86_64 (amd64) only — the gadget is disabled on arm64. + // --------------------------------------------------------------- + t.Run("randomx_bench", func(t *testing.T) { + if runtime.GOARCH != "amd64" { + t.Skip("randomx tracer is x86_64 only") + } + + ns := testutils.NewRandomNamespace() + + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/crypto-miner-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + t.Log("xmrig benchmark pod is ready, waiting for RandomX FPU events...") + + // xmrig needs ~5s to init the RandomX dataset, then starts hashing. + // The eBPF gadget needs 5 FPU events within 5s to fire. + // Give it 30s total. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + alerts, err = testutils.GetAlerts(ns.Name) + if err != nil || len(alerts) == 0 { + return false + } + for _, a := range alerts { + if a.Labels["rule_id"] == "R1007" { + return true + } + } + return false + }, 120*time.Second, 10*time.Second, "expected R1007 (RandomX crypto miner) from xmrig --bench") + + alerts, _ = testutils.GetAlerts(ns.Name) + t.Logf("=== %d alerts ===", len(alerts)) + for i, a := range alerts { + t.Logf(" [%d] %s(%s) comm=%s container=%s", + i, a.Labels["rule_name"], a.Labels["rule_id"], + a.Labels["comm"], a.Labels["container_name"]) } - } - assert.Equal(t, len(expectedMalwares), len(malwaresDetected), "Expected %d malwares to be detected, but got %d malwares", len(expectedMalwares), len(malwaresDetected)) + rulesSeen := map[string]bool{} + for _, a := range alerts { + rulesSeen[a.Labels["rule_id"]] = true + } + + assert.True(t, rulesSeen["R1007"], + "R1007 (Crypto miner launched via randomx) must fire — xmrig benchmark runs RandomX hashing") + }) } func Test_11_EndpointTest(t *testing.T) { @@ -1569,3 +1806,2094 @@ func Test_24_ProcessTreeDepthTest(t *testing.T) { t.Logf("Found alerts for the process tree depth: %v", alerts) } + +// Test_27_ApplicationProfileOpens tests that the dynamic path matching in +// application profiles works correctly for both recorded (auto-learned) +// profiles and user-defined profiles. +// +// Path matching symbols: +// +// ⋯ (U+22EF DynamicIdentifier) — matches exactly ONE path segment +// * (WildcardIdentifier) — matches ZERO or more path segments +// 0 (in endpoints) — wildcard port (any port) +// +// R0002 "Files Access Anomalies in container" fires when a file is opened +// under a monitored prefix (/etc/, /var/log/, …) and the path was NOT +// recorded in the application profile. +func Test_27_ApplicationProfileOpens(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + const ruleName = "Files Access Anomalies in container" + const profileName = "nginx-regex-profile" + + // --- result tracking for end-of-test summary --- + type subtestResult struct { + name string + profilePath string + filePath string + expectAlert bool + passed bool + detail string + } + var results []subtestResult + addResult := func(name, profilePath, filePath string, expectAlert, passed bool, detail string) { + results = append(results, subtestResult{name, profilePath, filePath, expectAlert, passed, detail}) + } + defer func() { + t.Log("\n========== Test_27 Summary ==========") + anyFailed := false + for _, r := range results { + status := "PASS" + if !r.passed { + status = "FAIL" + anyFailed = true + } + expect := "expect alert" + if !r.expectAlert { + expect = "expect NO alert" + } + t.Logf(" [%s] %-35s profile=%-25s file=%-25s %s", status, r.name, r.profilePath, r.filePath, expect) + if !r.passed { + t.Logf(" -> %s", r.detail) + } + } + if !anyFailed { + t.Log(" All subtests passed.") + } + t.Log("======================================") + }() + + // deployWithProfile creates a user-defined ApplicationProfile with the + // given Opens list, polls until it is retrievable from storage, then + // deploys nginx with the kubescape.io/user-defined-profile label + // pointing at it, and waits for the pod to be ready. + deployWithProfile := func(t *testing.T, opens []v1beta1.OpenCalls) *testutils.TestWorkload { + t.Helper() + ns := testutils.NewRandomNamespace() + + profile := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: profileName, + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Architectures: []string{"amd64"}, + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "nginx", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/cat", Args: []string{"/bin/cat"}}, + }, + Opens: opens, + }, + }, + }, + } + + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), profile, metav1.CreateOptions{}) + require.NoError(t, err, "create user-defined profile %q in ns %s", profileName, ns.Name) + + // Poll until the profile is retrievable from storage before deploying. + // Node-agent does a single fetch on container start with no retry. + require.Eventually(t, func() bool { + _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), profileName, v1.GetOptions{}) + return apErr == nil + }, 30*time.Second, 1*time.Second, "AP must be retrievable from storage before deploying the pod") + + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/nginx-user-profile-deployment.yaml")) + require.NoError(t, err, "create workload in ns %s", ns.Name) + require.NoError(t, wl.WaitForReady(80), "workload not ready in ns %s", ns.Name) + + // Wait for node-agent to load the user-defined profile into cache. + time.Sleep(10 * time.Second) + return wl + } + + // triggerAndGetAlerts execs cat on the given path, then polls for alerts + // up to 60s to avoid race conditions with alert propagation. + triggerAndGetAlerts := func(t *testing.T, wl *testutils.TestWorkload, filePath string) []testutils.Alert { + t.Helper() + stdout, stderr, err := wl.ExecIntoPod([]string{"cat", filePath}, "nginx") + if err != nil { + t.Errorf("exec 'cat %s' in container nginx failed: %v (stdout=%q stderr=%q)", filePath, err, stdout, stderr) + } + // Poll for alerts — they may take time to propagate through + // eBPF → node-agent → alertmanager. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + alerts, err = testutils.GetAlerts(wl.Namespace) + return err == nil + }, 60*time.Second, 5*time.Second, "alerts must be retrievable from ns %s", wl.Namespace) + // Give extra time for all alerts to arrive after first successful fetch. + time.Sleep(10 * time.Second) + alerts, err = testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "get alerts from ns %s", wl.Namespace) + return alerts + } + + // hasAlert checks whether an R0002 alert exists for comm=cat, container=nginx. + hasAlert := func(alerts []testutils.Alert) bool { + for _, a := range alerts { + if a.Labels["rule_name"] == ruleName && + a.Labels["comm"] == "cat" && + a.Labels["container_name"] == "nginx" { + return true + } + } + return false + } + + // --------------------------------------------------------------- + // 1a. Recorded (auto-learned) profile must use absolute paths. + // There must be no "." in the Opens paths. + // --------------------------------------------------------------- + t.Run("recorded_profile_absolute_paths", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + require.NoError(t, wl.WaitForApplicationProfileCompletion(80)) + + profile, err := wl.GetApplicationProfile() + require.NoError(t, err, "get application profile") + + passed := true + for _, container := range profile.Spec.Containers { + for _, open := range container.Opens { + if !strings.HasPrefix(open.Path, "/") { + t.Errorf("recorded path must be absolute: got %q (container %s)", open.Path, container.Name) + passed = false + } + if open.Path == "." { + t.Errorf("recorded path must not be relative dot: got %q (container %s)", open.Path, container.Name) + passed = false + } + } + } + detail := "" + if !passed { + detail = "found non-absolute or '.' paths in recorded profile" + } + addResult("recorded_profile_absolute_paths", "(auto-learned)", "(nginx startup)", false, passed, detail) + }) + + // --------------------------------------------------------------- + // 1b. User-defined profile wildcard tests. + // Each sub-test deploys nginx in its own namespace with a + // different Opens pattern and verifies R0002 behaviour. + // --------------------------------------------------------------- + + // 1b-1: Exact path — profile has the exact file => no alert. + t.Run("exact_path_match", func(t *testing.T) { + profilePath := "/etc/nginx/nginx.conf" + filePath := "/etc/nginx/nginx.conf" + wl := deployWithProfile(t, []v1beta1.OpenCalls{ + {Path: profilePath, Flags: []string{"O_RDONLY"}}, + {Path: "/etc/ld.so.cache", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, // dynamic linker opens this on every exec + }) + alerts := triggerAndGetAlerts(t, wl, filePath) + got := hasAlert(alerts) + if got { + t.Errorf("expected NO R0002 alert: profile allows %q, opened %q, but alert fired", profilePath, filePath) + } + addResult("exact_path_match", profilePath, filePath, false, !got, + fmt.Sprintf("got %d alerts, expected none for cat", len(alerts))) + }) + + // 1b-2: Exact path — profile has a DIFFERENT file => alert. + t.Run("exact_path_mismatch", func(t *testing.T) { + profilePath := "/etc/nginx/nginx.conf" + filePath := "/etc/hostname" + wl := deployWithProfile(t, []v1beta1.OpenCalls{ + {Path: profilePath, Flags: []string{"O_RDONLY"}}, + }) + alerts := triggerAndGetAlerts(t, wl, filePath) + got := hasAlert(alerts) + if !got { + t.Errorf("expected R0002 alert: profile only allows %q, opened %q, but no alert", profilePath, filePath) + } + addResult("exact_path_mismatch", profilePath, filePath, true, got, + fmt.Sprintf("got %d alerts, expected at least one for cat", len(alerts))) + }) + + // 1b-3: Ellipsis ⋯ matches single segment — /etc/⋯ covers /etc/hostname. + t.Run("ellipsis_single_segment_match", func(t *testing.T) { + profilePath := "/etc/" + dynamicpathdetector.DynamicIdentifier + filePath := "/etc/hostname" + wl := deployWithProfile(t, []v1beta1.OpenCalls{ + {Path: profilePath, Flags: []string{"O_RDONLY"}}, + }) + alerts := triggerAndGetAlerts(t, wl, filePath) + got := hasAlert(alerts) + if got { + t.Errorf("expected NO R0002 alert: profile %q should match %q (single segment), but alert fired", profilePath, filePath) + } + addResult("ellipsis_single_segment_match", profilePath, filePath, false, !got, + fmt.Sprintf("got %d alerts, expected none for cat", len(alerts))) + }) + + // 1b-4: Ellipsis ⋯ rejects multi-segment — /etc/⋯ does NOT cover + // /etc/nginx/nginx.conf (two segments past /etc/). + t.Run("ellipsis_rejects_multi_segment", func(t *testing.T) { + profilePath := "/etc/" + dynamicpathdetector.DynamicIdentifier + filePath := "/etc/nginx/nginx.conf" + wl := deployWithProfile(t, []v1beta1.OpenCalls{ + {Path: profilePath, Flags: []string{"O_RDONLY"}}, + }) + alerts := triggerAndGetAlerts(t, wl, filePath) + got := hasAlert(alerts) + if !got { + t.Errorf("expected R0002 alert: profile %q should NOT match %q (two segments), but no alert", profilePath, filePath) + } + addResult("ellipsis_rejects_multi_segment", profilePath, filePath, true, got, + fmt.Sprintf("got %d alerts, expected at least one for cat", len(alerts))) + }) + + // 1b-5: Wildcard * matches any depth — /etc/* covers /etc/nginx/nginx.conf. + t.Run("wildcard_matches_deep_path", func(t *testing.T) { + profilePath := "/etc/*" + filePath := "/etc/nginx/nginx.conf" + wl := deployWithProfile(t, []v1beta1.OpenCalls{ + {Path: profilePath, Flags: []string{"O_RDONLY"}}, + }) + alerts := triggerAndGetAlerts(t, wl, filePath) + got := hasAlert(alerts) + if got { + t.Errorf("expected NO R0002 alert: profile %q should match %q (wildcard), but alert fired", profilePath, filePath) + } + addResult("wildcard_matches_deep_path", profilePath, filePath, false, !got, + fmt.Sprintf("got %d alerts, expected none for cat", len(alerts))) + }) + + // --------------------------------------------------------------- + // 1c. Deploy known-application-profile-wildcards.yaml (curl image) + // and verify that files under wildcard-covered opens paths + // produce no R0002 alert. + // --------------------------------------------------------------- + t.Run("wildcard_yaml_profile_allowed_opens", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + wildcardProfileName := "fusioncore-profile-wildcards" + + // Create the profile matching known-application-profile-wildcards.yaml. + profile := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: wildcardProfileName, + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Architectures: []string{"amd64"}, + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + ImageID: "docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058", + ImageTag: "docker.io/curlimages/curl:8.5.0", + Capabilities: []string{ + "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH", + "CAP_SETGID", "CAP_SETPCAP", "CAP_SETUID", "CAP_SYS_ADMIN", + }, + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep", Args: []string{"/bin/sleep", "infinity"}}, + {Path: "/bin/cat", Args: []string{"/bin/cat"}}, + {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-sm2", "fusioncore.ai"}}, + }, + Opens: []v1beta1.OpenCalls{ + {Path: "/etc/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, + {Path: "/etc/ssl/openssl.cnf", Flags: []string{"O_RDONLY", "O_LARGEFILE"}}, + {Path: "/home/*", Flags: []string{"O_RDONLY", "O_LARGEFILE"}}, + {Path: "/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, + {Path: "/usr/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, + {Path: "/usr/local/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, + {Path: "/proc/*/cgroup", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/proc/*/kernel/cap_last_cap", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/proc/*/mountinfo", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/proc/*/task/*/fd", Flags: []string{"O_RDONLY", "O_DIRECTORY", "O_CLOEXEC"}}, + {Path: "/sys/fs/cgroup/cpu.max", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/sys/kernel/mm/transparent_hugepage/hpage_pmd_size", Flags: []string{"O_RDONLY"}}, + {Path: "/7/setgroups", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/runc", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + }, + Syscalls: []string{ + "arch_prctl", "bind", "brk", "capget", "capset", "chdir", + "clone", "close", "close_range", "connect", "epoll_ctl", + "epoll_pwait", "execve", "exit", "exit_group", "faccessat2", + "fchown", "fcntl", "fstat", "fstatfs", "futex", "getcwd", + "getdents64", "getegid", "geteuid", "getgid", "getpeername", + "getppid", "getsockname", "getsockopt", "gettid", "getuid", + "ioctl", "membarrier", "mmap", "mprotect", "munmap", + "nanosleep", "newfstatat", "open", "openat", "openat2", + "pipe", "poll", "prctl", "read", "recvfrom", "recvmsg", + "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "sendto", + "set_tid_address", "setgid", "setgroups", "setsockopt", + "setuid", "sigaltstack", "socket", "statx", "tkill", + "unknown", "write", "writev", + }, + }, + }, + }, + } + + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), profile, metav1.CreateOptions{}) + require.NoError(t, err, "create wildcard profile %q in ns %s", wildcardProfileName, ns.Name) + + // Poll until the profile is retrievable from storage before deploying. + require.Eventually(t, func() bool { + _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), wildcardProfileName, v1.GetOptions{}) + return apErr == nil + }, 30*time.Second, 1*time.Second, "AP must be retrievable before deploying the pod") + + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/curl-user-profile-wildcards-deployment.yaml")) + require.NoError(t, err, "create curl workload in ns %s", ns.Name) + require.NoError(t, wl.WaitForReady(80), "curl workload not ready in ns %s", ns.Name) + + // Wait for node-agent to load the user-defined profile into cache. + time.Sleep(10 * time.Second) + + // Cat files that are covered by the wildcard opens. + allowedFiles := []string{ + "/etc/hosts", // covered by /etc/* + "/etc/resolv.conf", // covered by /etc/* + "/etc/ssl/openssl.cnf", // exact match + } + for _, f := range allowedFiles { + stdout, stderr, err := wl.ExecIntoPod([]string{"cat", f}, "curl") + if err != nil { + t.Logf("exec 'cat %s' failed: %v (stdout=%q stderr=%q)", f, err, stdout, stderr) + } + } + + // Poll for alerts to propagate. + time.Sleep(15 * time.Second) + alerts, err := testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "get alerts from ns %s", wl.Namespace) + + var r0002Fired bool + for _, a := range alerts { + if a.Labels["rule_name"] == ruleName && + a.Labels["comm"] == "cat" && + a.Labels["container_name"] == "curl" { + r0002Fired = true + break + } + } + if r0002Fired { + t.Errorf("expected NO R0002 for files covered by wildcard opens, but alert fired") + } + addResult("wildcard_yaml_profile_allowed_opens", + "/etc/*, /etc/ssl/openssl.cnf", "/etc/hosts, /etc/resolv.conf, /etc/ssl/openssl.cnf", + false, !r0002Fired, + fmt.Sprintf("got R0002=%v, expected none for wildcard-covered files", r0002Fired)) + }) +} + +// Test_28_UserDefinedNetworkNeighborhood exercises user-defined AP + NN. +// Each subtest gets its own namespace to avoid alert cross-contamination. +// +// The NN allows only fusioncore.ai (162.0.217.171) on TCP/80. +// R0005 requires real resolvable domains (not NXDOMAIN), because trace_dns +// drops DNS responses with 0 answers. +func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + // setup creates a namespace with user-defined AP + NN + pod. + // The NN allows only fusioncore.ai (162.0.217.171) on TCP/80. + setup := func(t *testing.T) *testutils.TestWorkload { + t.Helper() + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + // Upstream ContainerProfileCache (kubescape/node-agent#788) reads ONE + // pod label `kubescape.io/user-defined-profile=` and uses + // as the lookup key for BOTH the user AP and the user NN. + // AP and NN MUST therefore share that single name. + const overlayName = "curl-28-overlay" + + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: overlayName, + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + {Path: "/usr/bin/curl"}, + {Path: "/usr/bin/nslookup"}, + {Path: "/usr/bin/wget"}, + }, + Syscalls: []string{"socket", "connect", "sendto", "recvfrom", "read", "write", "close", "openat", "mmap", "mprotect", "munmap", "fcntl", "ioctl", "poll", "epoll_create1", "epoll_ctl", "epoll_wait", "bind", "listen", "accept4", "getsockopt", "setsockopt", "getsockname", "getpid", "fstat", "rt_sigaction", "rt_sigprocmask", "writev"}, + }, + }, + }, + } + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "create AP") + + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: overlayName, + Namespace: ns.Name, + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, + Labels: map[string]string{ + helpersv1.ApiGroupMetadataKey: "apps", + helpersv1.ApiVersionMetadataKey: "v1", + helpersv1.RelatedKindMetadataKey: "Deployment", + helpersv1.RelatedNameMetadataKey: "curl-28", + helpersv1.RelatedNamespaceMetadataKey: ns.Name, + }, + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "curl-28"}, + }, + Containers: []v1beta1.NetworkNeighborhoodContainer{ + { + Name: "curl", + Egress: []v1beta1.NetworkNeighbor{ + { + Identifier: "fusioncore-egress", + Type: "external", + DNS: "fusioncore.ai.", + DNSNames: []string{"fusioncore.ai."}, + IPAddress: "162.0.217.171", + Ports: []v1beta1.NetworkPort{ + {Name: "TCP-80", Protocol: "TCP", Port: ptr.To(int32(80))}, + }, + }, + }, + }, + }, + }, + } + _, err = storageClient.NetworkNeighborhoods(ns.Name).Create( + context.Background(), nn, metav1.CreateOptions{}) + require.NoError(t, err, "create NN") + + require.Eventually(t, func() bool { + _, apErr := storageClient.ApplicationProfiles(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) + _, nnErr := storageClient.NetworkNeighborhoods(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) + return apErr == nil && nnErr == nil + }, 30*time.Second, 1*time.Second, "AP+NN must be in storage before pod deploy") + + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/nginx-user-defined-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + // Cache-load latency on the upstream ContainerProfileCache is bursty + // — 15s is enough on a quiet runner but not on a loaded one. The + // failure mode is alert metadata `errorMessage:"waiting for profile + // update"`, which means the rule manager evaluated against an + // unloaded NN and fired R0005/R0011 spuriously. 30s covers the + // observed worst-case in CI without pushing total test time too + // far. Real fix would be to poll a cache-loaded signal, but no + // such signal is exposed today. + time.Sleep(30 * time.Second) + return wl + } + + countByRule := func(alerts []testutils.Alert, ruleID string) int { + n := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == ruleID { + n++ + } + } + return n + } + + waitAlerts := func(t *testing.T, ns string) []testutils.Alert { + t.Helper() + var alerts []testutils.Alert + var err error + require.Eventually(t, func() bool { + alerts, err = testutils.GetAlerts(ns) + return err == nil + }, 60*time.Second, 5*time.Second, "must be able to fetch alerts") + // Extra settle time for remaining alerts. + time.Sleep(10 * time.Second) + alerts, _ = testutils.GetAlerts(ns) + return alerts + } + + logAlerts := func(t *testing.T, alerts []testutils.Alert) { + t.Helper() + for i, a := range alerts { + t.Logf(" [%d] %s(%s) comm=%s container=%s", + i, a.Labels["rule_name"], a.Labels["rule_id"], + a.Labels["comm"], a.Labels["container_name"]) + } + } + + // --------------------------------------------------------------- + // 28a. Allowed traffic — fusioncore.ai is in the NN. + // No R0005 (DNS) and no R0011 (egress) expected. + // --------------------------------------------------------------- + t.Run("allowed_fusioncore_no_alert", func(t *testing.T) { + wl := setup(t) + + // DNS lookup via nslookup (domain in NN). + stdout, stderr, err := wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") + t.Logf("nslookup fusioncore.ai → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + // HTTP via curl (domain + IP in NN). + stdout, stderr, err = wl.ExecIntoPod([]string{"curl", "-sm5", "http://fusioncore.ai"}, "curl") + t.Logf("curl fusioncore.ai → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assert.Equal(t, 0, countByRule(alerts, "R0005"), + "fusioncore.ai is in NN — should NOT fire R0005") + assert.Equal(t, 0, countByRule(alerts, "R0011"), + "fusioncore.ai IP is in NN — should NOT fire R0011") + }) + + // --------------------------------------------------------------- + // 28b. Unknown domains — domains NOT in the NN → R0005. + // Uses both nslookup (pure DNS) and curl (DNS + TCP). + // --------------------------------------------------------------- + t.Run("unknown_domain_R0005", func(t *testing.T) { + wl := setup(t) + + // nslookup generates a DNS query without any TCP connection. + wl.ExecIntoPod([]string{"nslookup", "google.com"}, "curl") + // curl resolves + connects. + wl.ExecIntoPod([]string{"curl", "-sm5", "http://ebpf.io"}, "curl") + wl.ExecIntoPod([]string{"curl", "-sm5", "http://cloudflare.com"}, "curl") + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + require.Greater(t, countByRule(alerts, "R0005"), 0, + "unknown domains must fire R0005") + }) + + // --------------------------------------------------------------- + // 28c. Unknown IPs — raw IP egress NOT in the NN → R0011. + // --------------------------------------------------------------- + t.Run("unknown_ip_R0011", func(t *testing.T) { + wl := setup(t) + + wl.ExecIntoPod([]string{"curl", "-sm5", "http://8.8.8.8"}, "curl") + wl.ExecIntoPod([]string{"curl", "-sm5", "http://1.1.1.1"}, "curl") + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + require.Greater(t, countByRule(alerts, "R0011"), 0, + "IPs not in NN must fire R0011") + }) + + // --------------------------------------------------------------- + // 28d. MITM — DNS spoofing simulation. + // fusioncore.ai is an allowed domain but the IP is spoofed. + // + // Step 1: nslookup fusioncore.ai (legitimate DNS, no alert). + // Step 2: curl --resolve fusioncore.ai:80:8.8.4.4 + // Simulates a DNS MITM returning a different IP. + // The domain is allowed but the connection goes to + // 8.8.4.4 (not 162.0.217.171) → R0011. + // --------------------------------------------------------------- + t.Run("mitm_spoofed_ip_R0011", func(t *testing.T) { + wl := setup(t) + + // Step 1: Legitimate DNS lookup — no alert expected. + wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") + + // Step 2: MITM — domain resolves to spoofed IP 8.8.4.4. + // curl --resolve skips DNS and connects directly to the + // spoofed IP, simulating what happens after DNS poisoning. + stdout, stderr, err := wl.ExecIntoPod( + []string{"curl", "-sm5", "--resolve", "fusioncore.ai:80:8.8.4.4", "http://fusioncore.ai"}, "curl") + t.Logf("curl MITM → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + require.Greater(t, countByRule(alerts, "R0011"), 0, + "MITM: fusioncore.ai allowed but spoofed IP 8.8.4.4 must fire R0011") + }) + + // --------------------------------------------------------------- + // 28e. MITM — real CoreDNS poisoning via template plugin. + // Poisons CoreDNS so fusioncore.ai resolves to 8.8.4.4 + // instead of the legitimate 162.0.217.171. + // + // nslookup triggers the poisoned DNS response. + // R0005 does NOT fire: fusioncore.ai is in the NN egress + // list and BusyBox nslookup does NOT do PTR reverse-lookups. + // R0011 does NOT fire: no TCP egress (DNS is UDP to cluster + // DNS which is a private IP filtered by is_private_ip). + // + // This documents a detection gap: pure DNS MITM (without + // subsequent TCP to the spoofed IP) is invisible to both + // R0005 and R0011 when the domain is already whitelisted. + // + // NOTE: this subtest MUST run last — it modifies the + // cluster-wide CoreDNS configmap. + // --------------------------------------------------------------- + t.Run("mitm_coredns_poisoning", func(t *testing.T) { + wl := setup(t) + ctx := context.Background() + k8sClient := k8sinterface.NewKubernetesApi() + + // ── Back up original CoreDNS Corefile ── + cm, err := k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + require.NoError(t, err, "get coredns configmap") + originalCorefile := cm.Data["Corefile"] + + restartAndWaitCoreDNS := func() { + deploy, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + require.NoError(t, err, "get coredns deployment") + if deploy.Spec.Template.ObjectMeta.Annotations == nil { + deploy.Spec.Template.ObjectMeta.Annotations = make(map[string]string) + } + deploy.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) + _, err = k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Update(ctx, deploy, metav1.UpdateOptions{}) + require.NoError(t, err, "restart coredns") + + require.Eventually(t, func() bool { + d, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + if err != nil || d.Spec.Replicas == nil { + return false + } + return d.Status.ReadyReplicas == *d.Spec.Replicas && + d.Status.UpdatedReplicas == *d.Spec.Replicas + }, 60*time.Second, 2*time.Second, "coredns must become ready") + } + + // ── Restore CoreDNS on cleanup (best-effort) ── + t.Cleanup(func() { + t.Log("cleanup: restoring CoreDNS Corefile") + cm, err := k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + if err != nil { + t.Logf("cleanup: get coredns cm: %v", err) + return + } + cm.Data["Corefile"] = originalCorefile + if _, err := k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Update(ctx, cm, metav1.UpdateOptions{}); err != nil { + t.Logf("cleanup: update coredns cm: %v", err) + return + } + deploy, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + if err != nil { + t.Logf("cleanup: get coredns deploy: %v", err) + return + } + if deploy.Spec.Template.ObjectMeta.Annotations == nil { + deploy.Spec.Template.ObjectMeta.Annotations = make(map[string]string) + } + deploy.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) + if _, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Update(ctx, deploy, metav1.UpdateOptions{}); err != nil { + t.Logf("cleanup: restart coredns: %v", err) + } + }) + + // ── Poison CoreDNS: fusioncore.ai → 8.8.4.4 ── + poisoned := strings.Replace(originalCorefile, + "forward .", + "template IN A fusioncore.ai {\n answer \"fusioncore.ai. 60 IN A 8.8.4.4\"\n fallthrough\n }\n forward .", + 1) + require.NotEqual(t, originalCorefile, poisoned, "template injection must modify Corefile") + + cm.Data["Corefile"] = poisoned + _, err = k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Update(ctx, cm, metav1.UpdateOptions{}) + require.NoError(t, err, "apply poisoned Corefile") + restartAndWaitCoreDNS() + + // Verify poisoned DNS returns the spoofed IP. + require.Eventually(t, func() bool { + stdout, _, _ := wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") + return strings.Contains(stdout, "8.8.4.4") + }, 30*time.Second, 3*time.Second, "poisoned CoreDNS must return 8.8.4.4 for fusioncore.ai") + + // ── Trigger alerts ── + // nslookup does DNS only (no TCP egress). + // BusyBox nslookup does NOT do PTR reverse-lookups on result IPs. + stdout, stderr, err := wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") + t.Logf("nslookup (poisoned) → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + // R0005 does NOT fire: fusioncore.ai is already in the NN + // egress list, and BusyBox nslookup does NOT perform PTR + // reverse-lookups on result IPs, so no unknown domain is queried. + assert.Equal(t, 0, countByRule(alerts, "R0005"), + "DNS MITM: domain is in NN and no PTR lookup — R0005 should not fire") + + // R0011 does NOT fire: nslookup generates only DNS (UDP) + // traffic to the cluster DNS service, which is a private IP + // excluded by is_private_ip(). + assert.Equal(t, 0, countByRule(alerts, "R0011"), + "DNS MITM: nslookup has no TCP egress — R0011 should not fire") + }) + + // --------------------------------------------------------------- + // 28f. MITM — CoreDNS poisoning with TCP egress. + // Same CoreDNS poisoning as 28e, but now fusioncore.ai + // resolves to 128.130.194.56 (a routable IP that accepts + // TCP on port 80). curl generates a real TCP connection + // to the spoofed IP. + // + // Expected: + // R0005 = 0 — domain is in NN, no PTR reverse-lookup. + // R0011 fires — TCP egress to 128.130.194.56 which is + // NOT in the NN (NN only has 162.0.217.171). + // + // NOTE: runs after 28e; modifies cluster-wide CoreDNS. + // --------------------------------------------------------------- + t.Run("mitm_coredns_poisoning_tcp", func(t *testing.T) { + wl := setup(t) + ctx := context.Background() + k8sClient := k8sinterface.NewKubernetesApi() + + // ── Back up original CoreDNS Corefile ── + cm, err := k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + require.NoError(t, err, "get coredns configmap") + originalCorefile := cm.Data["Corefile"] + + restartAndWaitCoreDNS := func() { + deploy, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + require.NoError(t, err, "get coredns deployment") + if deploy.Spec.Template.ObjectMeta.Annotations == nil { + deploy.Spec.Template.ObjectMeta.Annotations = make(map[string]string) + } + deploy.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) + _, err = k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Update(ctx, deploy, metav1.UpdateOptions{}) + require.NoError(t, err, "restart coredns") + + require.Eventually(t, func() bool { + d, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + if err != nil || d.Spec.Replicas == nil { + return false + } + return d.Status.ReadyReplicas == *d.Spec.Replicas && + d.Status.UpdatedReplicas == *d.Spec.Replicas + }, 60*time.Second, 2*time.Second, "coredns must become ready") + } + + // ── Restore CoreDNS on cleanup (best-effort) ── + t.Cleanup(func() { + t.Log("cleanup: restoring CoreDNS Corefile") + cm, err := k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + if err != nil { + t.Logf("cleanup: get coredns cm: %v", err) + return + } + cm.Data["Corefile"] = originalCorefile + if _, err := k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Update(ctx, cm, metav1.UpdateOptions{}); err != nil { + t.Logf("cleanup: update coredns cm: %v", err) + return + } + deploy, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + if err != nil { + t.Logf("cleanup: get coredns deploy: %v", err) + return + } + if deploy.Spec.Template.ObjectMeta.Annotations == nil { + deploy.Spec.Template.ObjectMeta.Annotations = make(map[string]string) + } + deploy.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) + if _, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Update(ctx, deploy, metav1.UpdateOptions{}); err != nil { + t.Logf("cleanup: restart coredns: %v", err) + } + }) + + // ── Poison CoreDNS: fusioncore.ai → 128.130.194.56 ── + poisoned := strings.Replace(originalCorefile, + "forward .", + "template IN A fusioncore.ai {\n answer \"fusioncore.ai. 60 IN A 128.130.194.56\"\n fallthrough\n }\n forward .", + 1) + require.NotEqual(t, originalCorefile, poisoned, "template injection must modify Corefile") + + cm.Data["Corefile"] = poisoned + _, err = k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Update(ctx, cm, metav1.UpdateOptions{}) + require.NoError(t, err, "apply poisoned Corefile") + restartAndWaitCoreDNS() + + // Verify poisoned DNS returns the spoofed IP. + require.Eventually(t, func() bool { + stdout, _, _ := wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") + return strings.Contains(stdout, "128.130.194.56") + }, 30*time.Second, 3*time.Second, "poisoned CoreDNS must return 128.130.194.56 for fusioncore.ai") + + // ── Trigger alerts ── + // curl resolves fusioncore.ai → 128.130.194.56 (poisoned) + // then opens a TCP connection to 128.130.194.56:80. + stdout, stderr, err := wl.ExecIntoPod( + []string{"curl", "-sm5", "http://fusioncore.ai"}, "curl") + t.Logf("curl (poisoned DNS) → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + // R0005 does NOT fire: fusioncore.ai is already in the NN + // egress list, and curl (like BusyBox nslookup) does NOT + // perform PTR reverse-lookups on resolved IPs. + assert.Equal(t, 0, countByRule(alerts, "R0005"), + "DNS MITM: domain is in NN and no PTR lookup — R0005 should not fire") + + // R0011 fires: TCP egress to 128.130.194.56 which is NOT + // in the NN (NN only allows 162.0.217.171). + require.Greater(t, countByRule(alerts, "R0011"), 0, + "DNS MITM: TCP to spoofed IP 128.130.194.56 must fire R0011") + }) +} + +// Test_29_SignedApplicationProfile verifies that a cryptographically signed +// ApplicationProfile can be pushed to storage, loaded by node-agent, and +// used for anomaly detection just like any other user-defined profile. +// +// The test signs an AP with key-based ECDSA (no OIDC/Sigstore needed), +// pushes it to storage, verifies the signature survives the round-trip, +// deploys a pod referencing the signed profile, and asserts that executing +// a binary NOT in the profile fires R0001 (Unexpected process launched). +func Test_29_SignedApplicationProfile(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + // ── 1. Build the ApplicationProfile ── + // Use nil (not empty slices) for unused fields — storage normalizes + // []string{} → nil on save, which changes the content hash. + // Matching the storage representation ensures the signature survives + // the round-trip (same approach as cluster_flow_test.go). + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "signed-ap", + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + {Path: "/usr/bin/curl"}, + }, + Syscalls: []string{"close", "connect", "openat", "read", "socket", "write"}, + }, + }, + }, + } + + // ── 2. Sign the AP (key-based, no OIDC) ── + adapter := profiles.NewApplicationProfileAdapter(ap) + err := signature.SignObjectDisableKeyless(adapter) + require.NoError(t, err, "sign AP") + require.True(t, signature.IsSigned(adapter), "AP must be signed") + + // Verify signature locally. + require.NoError(t, signature.VerifyObjectAllowUntrusted(adapter), + "signature must verify immediately after signing") + + sig, err := signature.GetObjectSignature(adapter) + require.NoError(t, err, "extract signature") + require.NotEmpty(t, sig.Signature, "signature bytes must not be empty") + require.NotEmpty(t, sig.Certificate, "certificate must not be empty") + t.Logf("AP signed: issuer=%s identity=%s sigLen=%d", sig.Issuer, sig.Identity, len(sig.Signature)) + + // ── 3. Push signed AP to storage ── + // Create preserves annotations (including signature.*). + _, err = storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "create signed AP in storage") + + // ── 4. Verify signature survives the storage round-trip ── + require.Eventually(t, func() bool { + stored, getErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), "signed-ap", v1.GetOptions{}) + if getErr != nil { + return false + } + return signature.IsSigned(profiles.NewApplicationProfileAdapter(stored)) + }, 30*time.Second, 1*time.Second, "stored AP must retain signature annotations") + + storedAP, err := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), "signed-ap", v1.GetOptions{}) + require.NoError(t, err) + storedAdapter := profiles.NewApplicationProfileAdapter(storedAP) + err = signature.VerifyObjectAllowUntrusted(storedAdapter) + require.NoError(t, err, "stored AP signature must still verify after round-trip") + t.Log("Signature round-trip verification passed") + + // ── 6. Deploy pod referencing the signed profile ── + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/curl-signed-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + time.Sleep(15 * time.Second) // let node-agent load the profile + + // ── 7. Exec an allowed binary — should NOT fire R0001 ── + stdout, stderr, execErr := wl.ExecIntoPod([]string{"curl", "-sm5", "http://ebpf.io"}, "curl") + t.Logf("curl (allowed) → err=%v stdout=%q stderr=%q", execErr, stdout, stderr) + + // ── 8. Exec an anomalous binary — should fire R0001 ── + // The user-defined profile may not be cached yet when the first exec runs. + // Re-exec nslookup on each poll so the eBPF event is generated after + // the profile is loaded (same race as the crypto miner test). + stdout, stderr, execErr = wl.ExecIntoPod([]string{"nslookup", "ebpf.io"}, "curl") + t.Logf("nslookup (anomalous) → err=%v stdout=%q stderr=%q", execErr, stdout, stderr) + + // ── 9. Wait for R0001 alert ── + var alerts []testutils.Alert + require.Eventually(t, func() bool { + // Re-exec on each poll to ensure the event arrives after the profile is cached. + wl.ExecIntoPod([]string{"nslookup", "ebpf.io"}, "curl") + + alerts, err = testutils.GetAlerts(ns.Name) + if err != nil || len(alerts) == 0 { + return false + } + for _, a := range alerts { + if a.Labels["rule_id"] == "R0001" { + return true + } + } + return false + }, 120*time.Second, 10*time.Second, "nslookup is not in signed AP — must fire R0001") + + // Extra settle time. + time.Sleep(10 * time.Second) + alerts, _ = testutils.GetAlerts(ns.Name) + + t.Logf("=== %d alerts ===", len(alerts)) + for i, a := range alerts { + t.Logf(" [%d] %s(%s) comm=%s container=%s", + i, a.Labels["rule_name"], a.Labels["rule_id"], + a.Labels["comm"], a.Labels["container_name"]) + } + + // R0001 must have fired for the anomalous exec. + r0001Count := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == "R0001" { + r0001Count++ + } + } + require.Greater(t, r0001Count, 0, "nslookup not in signed AP must fire R0001") +} + +// Test_30_TamperedSignedProfiles verifies that cryptographic signature +// verification detects tampering of both ApplicationProfile and +// NetworkNeighborhood objects. +// +// Current state of enforcement (as of merge): +// - enableSignatureVerification defaults to false +// - When enabled: tampered profiles are silently SKIPPED (not loaded) +// - No R-number rule fires on signature verification failure +// - User-defined NNs in addContainer() are NOT verified (known gap) +// - System fails open: no profile → no anomaly baseline → no detection +// +// This test proves: +// - The crypto layer detects tampering (sign → tamper → verify fails) +// - Without enforcement, tampered profiles are loaded and used +func Test_30_TamperedSignedProfiles(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + // --------------------------------------------------------------- + // 30a. Tamper detection at the crypto layer — AP and NN. + // Sign both objects, tamper their specs, verify fails. + // --------------------------------------------------------------- + t.Run("tamper_invalidates_signature", func(t *testing.T) { + // ── ApplicationProfile ── + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tamper-test-ap", + Namespace: "tamper-test-ns", + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "app", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + {Path: "/usr/bin/curl"}, + }, + Syscalls: []string{"read", "write", "close"}, + }, + }, + }, + } + + apAdapter := profiles.NewApplicationProfileAdapter(ap) + require.NoError(t, signature.SignObjectDisableKeyless(apAdapter), "sign AP") + require.True(t, signature.IsSigned(apAdapter)) + require.NoError(t, signature.VerifyObjectAllowUntrusted(apAdapter), "untampered AP must verify") + + // Tamper: attacker adds nslookup to whitelist + ap.Spec.Containers[0].Execs = append(ap.Spec.Containers[0].Execs, + v1beta1.ExecCalls{Path: "/usr/bin/nslookup"}) + + tamperedAPAdapter := profiles.NewApplicationProfileAdapter(ap) + err := signature.VerifyObjectAllowUntrusted(tamperedAPAdapter) + require.Error(t, err, "tampered AP must fail verification") + t.Logf("AP tamper detected: %v", err) + + // ── NetworkNeighborhood ── + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tamper-test-nn", + Namespace: "tamper-test-ns", + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, + Labels: map[string]string{ + helpersv1.RelatedKindMetadataKey: "Deployment", + helpersv1.RelatedNameMetadataKey: "tamper-test", + }, + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "tamper-test"}, + }, + Containers: []v1beta1.NetworkNeighborhoodContainer{ + { + Name: "app", + Egress: []v1beta1.NetworkNeighbor{ + { + Identifier: "allowed-egress", + Type: "external", + DNS: "fusioncore.ai.", + DNSNames: []string{"fusioncore.ai."}, + IPAddress: "162.0.217.171", + Ports: []v1beta1.NetworkPort{ + {Name: "TCP-80", Protocol: "TCP", Port: ptr.To(int32(80))}, + }, + }, + }, + }, + }, + }, + } + + nnAdapter := profiles.NewNetworkNeighborhoodAdapter(nn) + require.NoError(t, signature.SignObjectDisableKeyless(nnAdapter), "sign NN") + require.True(t, signature.IsSigned(nnAdapter)) + require.NoError(t, signature.VerifyObjectAllowUntrusted(nnAdapter), "untampered NN must verify") + + // Tamper: attacker adds a C2 domain to the egress whitelist + nn.Spec.Containers[0].Egress = append(nn.Spec.Containers[0].Egress, + v1beta1.NetworkNeighbor{ + Identifier: "c2-backdoor", + Type: "external", + DNS: "evil-c2.example.com.", + DNSNames: []string{"evil-c2.example.com."}, + IPAddress: "6.6.6.6", + Ports: []v1beta1.NetworkPort{ + {Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}, + }, + }) + + tamperedNNAdapter := profiles.NewNetworkNeighborhoodAdapter(nn) + err = signature.VerifyObjectAllowUntrusted(tamperedNNAdapter) + require.Error(t, err, "tampered NN must fail verification") + t.Logf("NN tamper detected: %v", err) + }) + + // --------------------------------------------------------------- + // 30b. Tampered AP is still loaded when enforcement is off. + // + // enableSignatureVerification defaults to false. + // The tampered profile is pushed to storage and node-agent + // loads it without checking the signature. Anomaly detection + // uses the tampered baseline → the attacker's added exec + // path (nslookup) is whitelisted. + // + // With enableSignatureVerification=true, the tampered profile + // would be rejected and the pod would have no baseline. + // --------------------------------------------------------------- + t.Run("tampered_profile_loaded_without_enforcement", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + // Build AP: only sleep + curl allowed. + // Use nil for unused fields (storage normalizes empty slices to nil). + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "signed-ap", + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + {Path: "/usr/bin/curl"}, + }, + Syscalls: []string{"close", "connect", "openat", "read", "socket", "write"}, + }, + }, + }, + } + + // Sign the AP. + apAdapter := profiles.NewApplicationProfileAdapter(ap) + require.NoError(t, signature.SignObjectDisableKeyless(apAdapter)) + require.NoError(t, signature.VerifyObjectAllowUntrusted(apAdapter), "pre-tamper verification") + + // Tamper: attacker adds nslookup to the whitelist. + ap.Spec.Containers[0].Execs = append(ap.Spec.Containers[0].Execs, + v1beta1.ExecCalls{Path: "/usr/bin/nslookup"}) + + // Signature is now invalid. + tamperedAdapter := profiles.NewApplicationProfileAdapter(ap) + require.Error(t, signature.VerifyObjectAllowUntrusted(tamperedAdapter), + "tampered AP must fail verification") + + // Push tampered AP to storage (signature annotations are stale). + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "push tampered AP to storage") + + // Verify stored AP has stale signature. + require.Eventually(t, func() bool { + stored, getErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), "signed-ap", v1.GetOptions{}) + if getErr != nil { + return false + } + storedAdapter := profiles.NewApplicationProfileAdapter(stored) + // Signature annotation exists but verification should fail. + if !signature.IsSigned(storedAdapter) { + return false + } + return signature.VerifyObjectAllowUntrusted(storedAdapter) != nil + }, 30*time.Second, 1*time.Second, "stored AP must have stale signature that fails verification") + t.Log("Stored AP has invalid signature (tamper detected at crypto layer)") + + // Deploy pod referencing the tampered profile. + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/curl-signed-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + + // Drive the unexpected exec inside Eventually so cache-load latency + // is absorbed by retries instead of a blind sleep. Same pattern as + // Test_29 (signed AP, anomalous exec) — without it, the first exec + // can land before the CP cache projects the user-defined AP, the + // rule manager evaluates against an empty baseline, and R0001 never + // fires within the polling window. + // + // wget is NOT in the AP (even after the attacker added nslookup), so + // once the cache loads, every wget exec produces an R0001 alert. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + wl.ExecIntoPod([]string{"wget", "-qO-", "--timeout=2", "http://ebpf.io"}, "curl") + alerts, err = testutils.GetAlerts(ns.Name) + if err != nil { + return false + } + for _, a := range alerts { + if a.Labels["rule_id"] == "R0001" && a.Labels["comm"] == "wget" { + return true + } + } + return false + }, 120*time.Second, 10*time.Second, + "wget not in tampered AP must fire R0001 — proves tampered profile was loaded (enforcement off)") + + // Settle so any pending alerts flush, then dump for diagnostics. + time.Sleep(10 * time.Second) + alerts, _ = testutils.GetAlerts(ns.Name) + t.Logf("=== %d alerts ===", len(alerts)) + for i, a := range alerts { + t.Logf(" [%d] %s(%s) comm=%s container=%s", + i, a.Labels["rule_name"], a.Labels["rule_id"], + a.Labels["comm"], a.Labels["container_name"]) + } + + // With enableSignatureVerification=true: + // - The tampered AP would be rejected (verifyUserApplicationProfile returns false) + // - The pod would have no baseline → no anomaly rules fire for wget + // - System fails OPEN (attacker evades detection by tampering the profile) + // - NOTE: user-defined NNs are not yet gated on the same flag (known gap) + // R1016 ("Signed profile tampered") fires regardless of the flag — that + // path is handled by Test_31. + t.Log("With enableSignatureVerification=true, the tampered profile would be silently rejected.") + }) +} + +// Test_31_TamperDetectionAlert verifies that R1016 "Signed profile tampered" +// fires when a previously signed ApplicationProfile or NetworkNeighborhood +// has been tampered with (signature annotations stale relative to the +// resource bytes). +// +// Coverage: +// 31a — tampered AP fires R1016 (the original scenario; regression-pinned +// after upstream PR #788's cache rewrite re-wired alert emission). +// 31b — untampered signed AP does NOT fire R1016 (negative; signature +// verifies cleanly so no alert). +// 31c — unsigned AP does NOT fire R1016 (signing is opt-in; not-signed +// is not the same as tampered). +// 31d — tampered NN fires R1016 via the parallel NN code path (different +// storage call, same emission contract). +// +// All four subtests share signSignedAP / signSignedNN helpers; each subtest +// uses its own namespace + its own AP/NN name to avoid alert cross-talk +// between scenarios. +// +// R1016 fires regardless of cfg.EnableSignatureVerification: the alert is +// always emitted on tamper; the flag only gates whether the cache also +// rejects the load. +func Test_31_TamperDetectionAlert(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + // signSignedAP returns a signed ApplicationProfile in nsName under name. + // + // IMPORTANT: storage's PreSave normalises spec content (DeflateSortString + // sorts+dedupes Syscalls/Capabilities/Architectures, DeflateStringer + // dedupes Execs, AnalyzeOpens/Endpoints/UnifyIdentifiedCallStacks + // rewrite their respective slices, GetContent injects empty + // PolicyByRuleId maps, and K8s itself may default fields). Signing + // locally and then pushing to storage makes the SIGNED hash mismatch + // the POST-STORE content hash that node-agent's tamper check sees, + // firing R1016 on an untampered profile. + // + // Sign-after-roundtrip eliminates every drift source at once: push + // the AP unsigned, read back the storage-normalised form, sign THAT, + // and let the caller push the signed version (deployAndWait does an + // Update-or-Create, so the second push goes through the same + // idempotent deflate and produces the same content hash). + signSignedAP := func(t *testing.T, nsName, name string) *v1beta1.ApplicationProfile { + t.Helper() + // Pre-sort syscalls so the first roundtrip is a no-op for that field + // — keeps the assertion that "deflate is idempotent on already-sorted + // content" honest. + syscalls := []string{"close", "connect", "openat", "read", "socket", "write"} + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: nsName}, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + {Path: "/usr/bin/curl"}, + }, + Syscalls: syscalls, + }, + }, + }, + } + + // Round-trip 1: push unsigned, read back the normalised form. + _, err := storageClient.ApplicationProfiles(nsName).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "create unsigned AP for normalisation") + var stored *v1beta1.ApplicationProfile + require.Eventually(t, func() bool { + s, gerr := storageClient.ApplicationProfiles(nsName).Get( + context.Background(), name, v1.GetOptions{}) + if gerr != nil { + return false + } + stored = s + return true + }, 30*time.Second, 1*time.Second, "AP must be retrievable after unsigned create") + + // Sign the storage-normalised content. Now the hash in the signature + // annotation matches what node-agent will see when it loads the AP. + require.NoError(t, + signature.SignObjectDisableKeyless(profiles.NewApplicationProfileAdapter(stored)), + "sign storage-normalised AP") + + // Delete the unsigned in-storage copy so the caller's deployAndWait + // Create succeeds without an AlreadyExists conflict. Storage will + // re-deflate the signed AP on the second push; since that content + // is already normalised, deflate is a no-op and the hash stays + // stable. + require.NoError(t, + storageClient.ApplicationProfiles(nsName).Delete( + context.Background(), name, metav1.DeleteOptions{}), + "delete unsigned AP before caller re-pushes signed version") + // Strip server-managed metadata so the Create call doesn't see a + // stale resourceVersion / uid / creationTimestamp. + stored.ObjectMeta.ResourceVersion = "" + stored.ObjectMeta.UID = "" + stored.ObjectMeta.CreationTimestamp = v1.Time{} + stored.ObjectMeta.Generation = 0 + return stored + } + + signSignedNN := func(t *testing.T, nsName, name string) *v1beta1.NetworkNeighborhood { + t.Helper() + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: nsName}, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "curl-signed"}}, + Containers: []v1beta1.NetworkNeighborhoodContainer{ + {Name: "curl"}, + }, + }, + } + require.NoError(t, signature.SignObjectDisableKeyless(profiles.NewNetworkNeighborhoodAdapter(nn)), "sign NN") + return nn + } + + // deployAndWait pushes the AP (and optionally NN) into storage, then + // deploys curl-signed-deployment.yaml and waits for it to come up. The + // deployment YAML uses kubescape.io/user-defined-profile=signed-ap as + // its label, so AP+NN names must equal "signed-ap" for the upstream + // CP cache to pick them up. + deployAndWait := func(t *testing.T, ns testutils.TestNamespace, ap *v1beta1.ApplicationProfile, nn *v1beta1.NetworkNeighborhood) *testutils.TestWorkload { + t.Helper() + if ap != nil { + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "push AP to storage") + } + if nn != nil { + _, err := storageClient.NetworkNeighborhoods(ns.Name).Create( + context.Background(), nn, metav1.CreateOptions{}) + require.NoError(t, err, "push NN to storage") + } + require.Eventually(t, func() bool { + if ap != nil { + if _, err := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), ap.Name, v1.GetOptions{}); err != nil { + return false + } + } + if nn != nil { + if _, err := storageClient.NetworkNeighborhoods(ns.Name).Get( + context.Background(), nn.Name, v1.GetOptions{}); err != nil { + return false + } + } + return true + }, 30*time.Second, 1*time.Second, "AP/NN must be in storage before pod deploy") + + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/curl-signed-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + return wl + } + + countR1016 := func(t *testing.T, nsName string, settle time.Duration) int { + t.Helper() + // Allow node-agent to load the profile and for any alert to flush. + time.Sleep(settle) + alerts, err := testutils.GetAlerts(nsName) + if err != nil { + t.Logf("GetAlerts error: %v", err) + return 0 + } + n := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == "R1016" { + n++ + assert.Equal(t, "Signed profile tampered", a.Labels["rule_name"], + "R1016 alert must have correct rule name") + assert.Equal(t, nsName, a.Labels["namespace"], + "R1016 alert must have correct namespace") + } + } + t.Logf("[%s] R1016 count = %d (out of %d alerts)", nsName, n, len(alerts)) + return n + } + + // ----------------------------------------------------------------- + // 31a — tampered AP fires R1016 + // ----------------------------------------------------------------- + t.Run("tampered_user_defined_AP_fires_R1016", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + ap := signSignedAP(t, ns.Name, "signed-ap") + // Tamper after signing: append an unauthorized exec entry. The + // signature annotations stay (stale). + ap.Spec.Containers[0].Execs = append(ap.Spec.Containers[0].Execs, + v1beta1.ExecCalls{Path: "/usr/bin/nslookup"}) + require.Error(t, + signature.VerifyObjectAllowUntrusted(profiles.NewApplicationProfileAdapter(ap)), + "tampered AP must fail verification") + + _ = deployAndWait(t, ns, ap, nil) + + require.Eventually(t, func() bool { + alerts, _ := testutils.GetAlerts(ns.Name) + for _, a := range alerts { + if a.Labels["rule_id"] == "R1016" { + return true + } + } + return false + }, 120*time.Second, 5*time.Second, "tampered AP must produce R1016") + + require.Greater(t, countR1016(t, ns.Name, 5*time.Second), 0) + }) + + // ----------------------------------------------------------------- + // 31b — untampered signed AP must NOT fire R1016 + // ----------------------------------------------------------------- + t.Run("untampered_signed_AP_no_R1016", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + ap := signSignedAP(t, ns.Name, "signed-ap") + // Don't tamper. Signature verifies cleanly. + require.NoError(t, + signature.VerifyObjectAllowUntrusted(profiles.NewApplicationProfileAdapter(ap)), + "untampered signed AP must verify") + + _ = deployAndWait(t, ns, ap, nil) + // Wait for cache load to happen (cache picks it up within ~15s). + assert.Equal(t, 0, countR1016(t, ns.Name, 30*time.Second), + "untampered signed AP must NOT fire R1016") + }) + + // ----------------------------------------------------------------- + // 31c — unsigned AP must NOT fire R1016 (signing is opt-in) + // ----------------------------------------------------------------- + t.Run("unsigned_AP_no_R1016", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "signed-ap", Namespace: ns.Name}, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + }, + Syscalls: []string{"socket"}, + }, + }, + }, + } + require.False(t, + signature.IsSigned(profiles.NewApplicationProfileAdapter(ap)), + "unsigned AP must not have signature annotations") + + _ = deployAndWait(t, ns, ap, nil) + assert.Equal(t, 0, countR1016(t, ns.Name, 30*time.Second), + "unsigned AP must NOT fire R1016 — not-signed is not the same as tampered") + }) + + // ----------------------------------------------------------------- + // 31d — tampered NN fires R1016 via the NN code path + // ----------------------------------------------------------------- + t.Run("tampered_user_defined_NN_fires_R1016", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + // Untampered AP (matched on name to the pod label) so the AP path + // stays silent and we know any R1016 came from the NN path. + ap := signSignedAP(t, ns.Name, "signed-ap") + nn := signSignedNN(t, ns.Name, "signed-ap") + // Tamper the NN: add a container the original signature didn't cover. + nn.Spec.Containers = append(nn.Spec.Containers, + v1beta1.NetworkNeighborhoodContainer{Name: "drift"}) + require.Error(t, + signature.VerifyObjectAllowUntrusted(profiles.NewNetworkNeighborhoodAdapter(nn)), + "tampered NN must fail verification") + + _ = deployAndWait(t, ns, ap, nn) + + require.Eventually(t, func() bool { + alerts, _ := testutils.GetAlerts(ns.Name) + for _, a := range alerts { + if a.Labels["rule_id"] == "R1016" { + return true + } + } + return false + }, 120*time.Second, 5*time.Second, "tampered NN must produce R1016") + + require.Greater(t, countR1016(t, ns.Name, 5*time.Second), 0) + }) + +} + +// --------------------------------------------------------------------------- +// Test_32_UnexpectedProcessArguments — component test for the wildcard-aware +// exec-argument matching (R0040). Each subtest gets its own namespace so +// alerts don't cross-contaminate. +// +// AP overlay declares 4 allowed exec patterns for the curl pod. Profile +// shape: +// - Path = full kernel-resolved exec path (used by parse.get_exec_path +// + ap.was_executed for path-level matching) +// - Args[0] = ABSOLUTE invoking path (e.g. "/bin/sh"). Matches runtime +// argv[0] as captured by eBPF after the symlink-faithful +// precedence fix (parse.get_exec_path / resolveExecPath +// prefer absolute argv[0] over kernel exepath when argv[0] +// starts with "/"). Recording side records the same form +// via the matching precedence in +// pkg/containerprofilemanager/v1/event_reporting.go:: +// resolveExecPath, so profile.Args[0] agrees with what +// CompareExecArgs compares against at rule-eval time. See +// pkg/rulemanager/cel/libraries/parse/parse.go for the +// live precedence definition. +// +// /bin/sleep [/bin/sleep, *] — pod startup, must stay silent +// /bin/sh [/bin/sh, -c, *] — sh -c +// /bin/echo [/bin/echo, hello, *] — echo hello +// /usr/bin/curl [/usr/bin/curl, -s, ⋯] — curl -s +// +// Profile loaded into the new ContainerProfileCache via the unified +// kubescape.io/user-defined-profile= label. The exec.go CEL function +// routes ap.was_executed_with_args through dynamicpathdetector.CompareExecArgs +// — see storage/pkg/registry/file/dynamicpathdetector/tests/ +// compare_exec_args_test.go::TestCompareExecArgs_Argv0BareName for the +// matcher-level contract these subtests rest on. +// +// R0040 ("Unexpected process arguments") fires when: +// - the exec'd path IS in the profile (R0001 silent), AND +// - the runtime arg vector does NOT match any profile entry's pattern. +// +// Each subtest asserts R0001 silence as a PRECONDITION (path resolution +// works), THEN asserts presence/absence of R0040. If R0001 fires, the +// failure points at the recording-side exepath capture (event.exepath +// empty AND argv[0] not absolute → parse.get_exec_path falls back to +// bare comm → profile +// Path lookup misses), not at R0040 logic. Separating the two axes +// stops Test_32 from flaking on unrelated capture-layer gaps. +// --------------------------------------------------------------------------- +func Test_32_UnexpectedProcessArguments(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + const overlayName = "curl-32-overlay" + + setup := func(t *testing.T) *testutils.TestWorkload { + t.Helper() + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: overlayName, + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + // Profile shape: Path AND Args[0] both use the + // absolute-path symlink form (/bin/sh, + // /usr/bin/nslookup, ...). With the symlink- + // faithful precedence in parse.get_exec_path + // (fix 9a6eb359), the rule queries the + // symlink-as-invoked path that the kernel + // preserves in argv[0]. Recording-side + // resolveExecPath uses the same precedence so + // auto-learned profiles get the same key. + // + // Storage's CompareExecArgs is a strict + // positional compare — no special argv[0] + // normalisation — so Args[0] MUST be the same + // string as runtime argv[0]. For + // kubectl-exec'd processes that's the absolute + // path the caller invoked. + // + // pod startup: sleep + {Path: "/bin/sleep", Args: []string{"/bin/sleep", dynamicpathdetector.WildcardIdentifier}}, + // sh -c + {Path: "/bin/sh", Args: []string{"/bin/sh", "-c", dynamicpathdetector.WildcardIdentifier}}, + // echo hello + {Path: "/bin/echo", Args: []string{"/bin/echo", "hello", dynamicpathdetector.WildcardIdentifier}}, + // curl -s + {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier}}, + }, + Syscalls: []string{"socket", "connect", "sendto", "recvfrom", "read", "write", "close", "openat", "mmap", "mprotect", "munmap", "fcntl", "ioctl", "poll", "epoll_create1", "epoll_ctl", "epoll_wait", "bind", "listen", "accept4", "getsockopt", "setsockopt", "getsockname", "getpid", "fstat", "rt_sigaction", "rt_sigprocmask", "writev", "execve"}, + }, + }, + }, + } + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "create AP") + + require.Eventually(t, func() bool { + _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), overlayName, v1.GetOptions{}) + return apErr == nil + }, 30*time.Second, 1*time.Second, "AP must be in storage before pod deploy") + + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/curl-exec-arg-wildcards-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + // let node-agent load the user AP into the CP cache + time.Sleep(15 * time.Second) + return wl + } + + countByRule := func(alerts []testutils.Alert, ruleID string) int { + n := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == ruleID { + n++ + } + } + return n + } + + waitAlerts := func(t *testing.T, ns string) []testutils.Alert { + t.Helper() + var alerts []testutils.Alert + var err error + require.Eventually(t, func() bool { + alerts, err = testutils.GetAlerts(ns) + return err == nil + }, 60*time.Second, 5*time.Second, "must be able to fetch alerts") + // settle time for any in-flight alerts + time.Sleep(10 * time.Second) + alerts, _ = testutils.GetAlerts(ns) + return alerts + } + + logAlerts := func(t *testing.T, alerts []testutils.Alert) { + t.Helper() + for i, a := range alerts { + t.Logf(" [%d] %s(%s) comm=%s container=%s", + i, a.Labels["rule_name"], a.Labels["rule_id"], + a.Labels["comm"], a.Labels["container_name"]) + } + } + + // R0001 silence is a precondition for every subtest below: it means + // parse.get_exec_path resolved to the profile's Path key, so R0040 + // gets to evaluate its argv comparison cleanly. A non-zero R0001 for + // the test binary's comm means the recording / capture / resolution + // chain dropped event.exepath — that's a separate bug (track it in + // the recording side, not in R0040), and asserting it here fails the + // subtest on the right axis instead of polluting the R0040 signal. + assertR0001Silent := func(t *testing.T, alerts []testutils.Alert, comm string) { + t.Helper() + n := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == "R0001" && a.Labels["comm"] == comm { + n++ + } + } + require.Zero(t, n, + "R0001 precondition: path resolution failed for comm=%q. "+ + "parse.get_exec_path either didn't receive event.exepath or "+ + "profile Path doesn't match its return value. Fix capture-side "+ + "exepath before reading R0040 results from this subtest.", comm) + } + + // ----------------------------------------------------------------- + // 32a. sh -c — argv [sh, -c, "echo hi"] matches + // profile [sh, -c, *]. R0040 must NOT fire. + // ----------------------------------------------------------------- + t.Run("sh_dash_c_matches_wildcard_trailing", func(t *testing.T) { + wl := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"sh", "-c", "echo hi"}, "curl") + t.Logf("sh -c 'echo hi' → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "sh") + assert.Equal(t, 0, countByRule(alerts, "R0040"), + "sh -c matches profile [sh, -c, *] — R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32b. sh -x — argv [sh, -x, "echo hi"] does NOT match + // profile [sh, -c, *] (literal anchor `-c` mismatch). Path + // /bin/sh IS in profile so R0001 stays silent. R0040 must fire. + // ----------------------------------------------------------------- + t.Run("sh_dash_x_mismatches_R0040", func(t *testing.T) { + wl := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"sh", "-x", "echo hi"}, "curl") + t.Logf("sh -x 'echo hi' → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "sh") + require.Greater(t, countByRule(alerts, "R0040"), 0, + "sh -x mismatches profile [sh, -c, *] → R0040 must fire") + }) + + // ----------------------------------------------------------------- + // 32c. echo hello — argv [echo, hello, world, from, test] + // matches profile [echo, hello, *]. R0040 must NOT fire. + // ----------------------------------------------------------------- + t.Run("echo_hello_matches_wildcard_trailing", func(t *testing.T) { + wl := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"echo", "hello", "world", "from", "test"}, "curl") + t.Logf("echo hello world from test → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "echo") + assert.Equal(t, 0, countByRule(alerts, "R0040"), + "echo hello matches profile [echo, hello, *] — R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32d. echo goodbye — argv [echo, goodbye, world] does + // NOT match profile [echo, hello, *] (literal anchor `hello` + // mismatch). R0040 must fire. + // ----------------------------------------------------------------- + t.Run("echo_goodbye_mismatches_R0040", func(t *testing.T) { + wl := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"echo", "goodbye", "world"}, "curl") + t.Logf("echo goodbye world → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "echo") + require.Greater(t, countByRule(alerts, "R0040"), 0, + "echo goodbye mismatches profile [echo, hello, *] (literal anchor) → R0040 must fire") + }) +} + +// Test_33_AnalyzeOpensWildcardAnchoring pins the wildcard-matching +// contract that storage-side CompareDynamic enforces, end-to-end through +// R0002 ("Files Access Anomalies in container"). +// +// Each subtest spins up a fresh nginx pod with a user-defined AP that +// carries ONE Opens entry, then `cat`s a target path that probes a +// boundary case from the storage-side analyzer fixes (kubescape/storage +// PR #316 review by matthyx + entlein): +// +// - Anchored trailing `*` matches one OR MORE remaining segments — +// never zero. So `/etc/*` matches `/etc/passwd` but NOT the bare +// `/etc` directory. Without this rule, R0002 silently allowed +// access to the parent of any profiled directory. +// - DynamicIdentifier (⋯) consumes EXACTLY ONE segment. +// - Mid-path `*` consumes ZERO or more, so `/etc/*/*` still matches +// `/etc/ssh` (inner `*` consumed zero, trailing `*` consumed one). +// - splitPath normalises trailing slashes on both dynamic and +// regular paths so `/etc/passwd/` is treated as `/etc/passwd`. +// - Mixed `⋯/*` patterns: ⋯ pins one segment, `*` consumes the rest +// (with one-or-more semantics). +// +// Component-level pin sits ON TOP of the unit tests in storage's +// pkg/registry/file/dynamicpathdetector/tests/coverage_test.go. +// Both layers must agree — if the unit suite drifts away from these +// runtime expectations, R0002 has either a false-positive or a +// false-negative bug. +func Test_33_AnalyzeOpensWildcardAnchoring(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + const ruleName = "Files Access Anomalies in container" + const profileName = "nginx-regex-profile" + + type subtestResult struct { + name string + profilePath string + filePath string + expectAlert bool + passed bool + detail string + } + var results []subtestResult + addResult := func(name, profilePath, filePath string, expectAlert, passed bool, detail string) { + results = append(results, subtestResult{name, profilePath, filePath, expectAlert, passed, detail}) + } + defer func() { + t.Log("\n========== Test_33 Summary ==========") + anyFailed := false + for _, r := range results { + status := "PASS" + if !r.passed { + status = "FAIL" + anyFailed = true + } + expect := "expect alert" + if !r.expectAlert { + expect = "expect NO alert" + } + t.Logf(" [%s] %-50s profile=%-25s file=%-30s %s", status, r.name, r.profilePath, r.filePath, expect) + if !r.passed { + t.Logf(" -> %s", r.detail) + } + } + if !anyFailed { + t.Log(" All subtests passed.") + } + t.Log("======================================") + }() + + // deployWithProfile creates a user-defined AP with a single Opens + // entry (plus a couple of always-needed paths nginx hits at startup), + // then deploys nginx with the user-defined-profile label pointing at + // it and waits for the pod + cache load. + deployWithProfile := func(t *testing.T, profilePath string) *testutils.TestWorkload { + t.Helper() + ns := testutils.NewRandomNamespace() + + profile := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: profileName, + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Architectures: []string{"amd64"}, + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "nginx", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/cat", Args: []string{"/bin/cat"}}, + }, + Opens: []v1beta1.OpenCalls{ + {Path: profilePath, Flags: []string{"O_RDONLY"}}, + // Dynamic linker fires this on every exec — keep + // it whitelisted so it doesn't drown out the + // signal we actually care about. + {Path: "/etc/ld.so.cache", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + }, + }, + }, + }, + } + + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), profile, metav1.CreateOptions{}) + require.NoError(t, err, "create user-defined profile %q in ns %s", profileName, ns.Name) + + require.Eventually(t, func() bool { + _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), profileName, v1.GetOptions{}) + return apErr == nil + }, 30*time.Second, 1*time.Second, "AP must be retrievable from storage before deploying the pod") + + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/nginx-user-profile-deployment.yaml")) + require.NoError(t, err, "create workload in ns %s", ns.Name) + // 11 subtests deploy a fresh pod sequentially, so each later subtest + // races against an increasingly loaded kind cluster — the upstream + // CP cache reconciler, alertmanager, and prometheus all chew CPU at + // boot. 80s timed out intermittently; 180s gives headroom without + // pushing the total test runtime into a different regime. + require.NoError(t, wl.WaitForReady(180), "workload not ready in ns %s", ns.Name) + + // Wait for node-agent to load the user-defined profile into cache. + time.Sleep(10 * time.Second) + return wl + } + + // catAndAlerts execs `cat ` (ignoring cat's own exit error — + // catting a directory or a non-readable file still triggers the + // open() syscall the eBPF tracer captures), then polls for alerts. + catAndAlerts := func(t *testing.T, wl *testutils.TestWorkload, filePath string) []testutils.Alert { + t.Helper() + stdout, stderr, _ := wl.ExecIntoPod([]string{"cat", filePath}, "nginx") + t.Logf("cat %q → stdout=%q stderr=%q", filePath, stdout, stderr) + + var alerts []testutils.Alert + require.Eventually(t, func() bool { + a, err := testutils.GetAlerts(wl.Namespace) + if err != nil { + return false + } + alerts = a + return true + }, 60*time.Second, 5*time.Second, "alerts must be retrievable from ns %s", wl.Namespace) + // Settle so any late R0002 alert lands before we count. + time.Sleep(10 * time.Second) + alerts, err := testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "get alerts from ns %s", wl.Namespace) + return alerts + } + + // hasR0002 returns true if any R0002 alert fired for `cat` in the + // nginx container. + hasR0002 := func(alerts []testutils.Alert) bool { + for _, a := range alerts { + if a.Labels["rule_name"] == ruleName && + a.Labels["comm"] == "cat" && + a.Labels["container_name"] == "nginx" { + return true + } + } + return false + } + + tests := []struct { + name string + profilePath string + filePath string + expectAlert bool + why string // contract pinned by this case + }{ + // ─── Trailing-`*` anchoring (the security fix) ────────────── + // + // IMPORTANT: R0002's CEL ruleExpression has a strict prefix + // filter (event.path.startsWith('/etc/'), startsWith('/var/log/'), + // etc. — all with trailing slash). Bare `/etc` and `/var/log` + // don't match those prefixes, so the rule never evaluates on + // them and the matcher's anchoring contract stays invisible at + // runtime. Probe one level deeper instead — `/etc/ssl` IS under + // the `/etc/` monitored prefix, so R0002 CAN see whether a + // `/etc/ssl/*` profile entry matches the bare `/etc/ssl` parent. + { + name: "trailing_star_matches_immediate_child", + profilePath: "/etc/*", + filePath: "/etc/hosts", + expectAlert: false, + why: "/etc/* matches a one-segment child under /etc", + }, + { + name: "trailing_star_matches_deep_child", + profilePath: "/etc/*", + filePath: "/etc/ssl/openssl.cnf", + expectAlert: false, + why: "/etc/* matches a multi-segment path under /etc (mid-path zero-or-more)", + }, + { + name: "trailing_star_does_not_match_bare_parent_under_monitored_prefix", + profilePath: "/etc/ssl/*", + filePath: "/etc/ssl", + expectAlert: true, + why: "/etc/ssl/* must NOT match the bare /etc/ssl directory itself — pins the security fix at a path R0002's prefix filter can observe", + }, + { + name: "deep_prefix_trailing_star_does_not_match_parent", + profilePath: "/etc/ssl/certs/*", + filePath: "/etc/ssl/certs", + expectAlert: true, + why: "Same anchoring rule, deeper: /etc/ssl/certs/* does NOT match /etc/ssl/certs", + }, + + // ─── DynamicIdentifier (⋯) exactly-one ────────────────────── + { + name: "ellipsis_requires_one_segment_not_zero", + profilePath: "/etc/passwd/" + dynamicpathdetector.DynamicIdentifier, + filePath: "/etc/passwd", + expectAlert: true, + why: "⋯ consumes EXACTLY ONE segment; /etc/passwd/⋯ requires one more, /etc/passwd alone has zero past — must fire R0002", + }, + + // ─── Mixed ⋯/* combinations ───────────────────────────────── + { + name: "ellipsis_then_trailing_star_matches_two_segment_tail", + profilePath: "/proc/" + dynamicpathdetector.DynamicIdentifier + "/*", + filePath: "/proc/1/status", + expectAlert: false, + why: "/proc/⋯/* matches /proc/1/status (⋯ consumes 1, * consumes ≥1)", + }, + { + name: "ellipsis_then_trailing_star_matches_three_segment_tail", + profilePath: "/proc/" + dynamicpathdetector.DynamicIdentifier + "/*", + filePath: "/proc/1/task/1", + expectAlert: false, + why: "/proc/⋯/* matches deeper paths (⋯ consumes 1, * consumes ≥1 covering rest)", + }, + + // ─── Multiple trailing wildcards ──────────────────────────── + { + name: "double_trailing_matches_one_child", + profilePath: "/etc/*/*", + filePath: "/etc/ssl", + expectAlert: false, + why: "/etc/*/* matches /etc/ssh (mid-* consumes zero, trailing-* consumes one)", + }, + { + name: "double_trailing_matches_deep_child", + profilePath: "/etc/*/*", + filePath: "/etc/ssl/openssl.cnf", + expectAlert: false, + why: "/etc/*/* matches /etc/ssl/openssl.cnf (mid-* consumes one, trailing-* consumes one)", + }, + { + name: "double_trailing_does_not_match_parent_under_monitored_prefix", + profilePath: "/etc/ssl/*/*", + filePath: "/etc/ssl", + expectAlert: true, + why: "/etc/ssl/*/* requires at least one segment past /etc/ssl; bare /etc/ssl must NOT match (probed under /etc/ so R0002 sees it)", + }, + + // ─── splitPath trailing-slash normalisation ───────────────── + { + name: "trailing_slash_in_profile_normalises_to_literal", + profilePath: "/etc/passwd/", + filePath: "/etc/passwd", + expectAlert: false, + why: "Profile `/etc/passwd/` is normalised to `/etc/passwd`; matches the literal at runtime", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Logf("contract: %s", tc.why) + wl := deployWithProfile(t, tc.profilePath) + alerts := catAndAlerts(t, wl, tc.filePath) + got := hasR0002(alerts) + + detail := fmt.Sprintf("got %d alerts total; R0002 fired = %v", len(alerts), got) + passed := got == tc.expectAlert + if !passed { + if tc.expectAlert { + t.Errorf("expected R0002 alert: profile %q must NOT match %q (%s); but no alert fired", + tc.profilePath, tc.filePath, tc.why) + } else { + t.Errorf("expected NO R0002 alert: profile %q should match %q (%s); but alert fired", + tc.profilePath, tc.filePath, tc.why) + } + } + addResult(tc.name, tc.profilePath, tc.filePath, tc.expectAlert, passed, detail) + }) + } +} diff --git a/tests/resources/aplint_test.go b/tests/resources/aplint_test.go new file mode 100644 index 0000000000..87c1be234a --- /dev/null +++ b/tests/resources/aplint_test.go @@ -0,0 +1,368 @@ +// AP-fixture lint tests. +// +// Validates every ApplicationProfile / NetworkNeighborhood YAML under +// tests/resources/ against the ground-truth syntax rules learned from a +// real auto-recorded AP for curlimages/curl:8.5.0 (originally captured +// by the fork in commit fea3b062 — known-application-profile.yaml). Each +// rule maps a real-world drift mode that has bitten the fork once already +// (e.g. argv[0] basename vs full path — Test_32 first run on PR #37). +// +// Runs as a regular `go test ./...` — no component tag, no kind cluster. +// +// LintApplicationProfile is exported (uppercase) and returns []Violation +// rather than calling t.Errorf directly, so this whole file can be lifted +// into a standalone bobctl subcommand `bobctl lint ` without any +// testing-package dependency. The Test_* functions below are just thin +// wrappers that turn violations into t.Errorf calls. +package resources + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "sigs.k8s.io/yaml" +) + +// applicationProfileLike captures only the fields we lint; we don't import +// the storage v1beta1 types because we want this lint runnable in isolation. +type applicationProfileLike struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Spec struct { + Containers []struct { + Name string `json:"name"` + Execs []struct { + Path string `json:"path"` + Args []string `json:"args"` + } `json:"execs"` + Opens []struct { + Path string `json:"path"` + Flags []string `json:"flags"` + } `json:"opens"` + } `json:"containers"` + } `json:"spec"` +} + +// Violation is a single rule failure — id, target file (if any), and a +// human-readable message. Returned by LintApplicationProfile so callers +// can treat lint output as data (CLI exit code, JSON, t.Errorf, etc). +type Violation struct { + Rule string + Path string + Msg string +} + +func (v Violation) String() string { + if v.Path != "" { + return fmt.Sprintf("[%s] %s: %s", v.Rule, v.Path, v.Msg) + } + return fmt.Sprintf("[%s] %s", v.Rule, v.Msg) +} + +// validOpenFlags is the set of O_* flags the fork has seen in real +// auto-recorded profiles. Extend as new flags appear; a typo'd flag +// (e.g. `O_LARGEFLE`) is caught immediately. +var validOpenFlags = map[string]bool{ + "O_RDONLY": true, + "O_WRONLY": true, + "O_RDWR": true, + "O_CLOEXEC": true, + "O_LARGEFILE": true, + "O_DIRECTORY": true, + "O_NONBLOCK": true, + "O_APPEND": true, + "O_CREAT": true, + "O_EXCL": true, + "O_TRUNC": true, + "O_NOFOLLOW": true, + "O_NOATIME": true, + "O_DIRECT": true, + "O_SYNC": true, + "O_PATH": true, + "O_TMPFILE": true, +} + +// dynamicIdentifier and wildcardIdentifier mirror the constants in +// storage/pkg/registry/file/dynamicpathdetector. Duplicated here so this +// linter has zero dependency on the storage module. +const ( + dynamicIdentifier = "⋯" + wildcardIdentifier = "*" +) + +// LintApplicationProfileYAML parses a YAML doc as an ApplicationProfile and +// runs all rules. Returns the slice of violations (empty == clean). Pure +// function — no I/O, no testing-package coupling. +func LintApplicationProfileYAML(doc []byte, sourceLabel string) []Violation { + var ap applicationProfileLike + if err := yaml.Unmarshal(doc, &ap); err != nil { + return []Violation{{Rule: "R-AP-00", Path: sourceLabel, Msg: fmt.Sprintf("yaml parse: %v", err)}} + } + return LintApplicationProfile(&ap, sourceLabel) +} + +// LintApplicationProfile runs every rule against an already-parsed AP. +// Returns the slice of violations (empty == clean). +// +// Rule IDs: +// R-AP-00 — yaml parse failure (only from LintApplicationProfileYAML) +// R-AP-01 — kind must be ApplicationProfile +// R-AP-02 — at least one container +// R-AP-03 — container name non-empty +// R-AP-10 — exec.path absolute +// R-AP-11 — exec.path no wildcards +// R-AP-12 — exec.args[0] equals exec.path (or wildcard) +// R-AP-13 — exec.args wildcard tokens are whole-word +// R-AP-20 — open.path non-empty + absolute +// R-AP-21 — open.flags non-empty +// R-AP-22 — open.flags from known O_* set +func LintApplicationProfile(ap *applicationProfileLike, src string) []Violation { + var v []Violation + add := func(rule, msg string) { v = append(v, Violation{Rule: rule, Path: src, Msg: msg}) } + + if ap.Kind != "ApplicationProfile" { + add("R-AP-01", fmt.Sprintf("kind is %q, expected \"ApplicationProfile\"", ap.Kind)) + } + if len(ap.Spec.Containers) == 0 { + add("R-AP-02", "spec.containers is empty") + return v + } + + for ci, c := range ap.Spec.Containers { + if c.Name == "" { + add("R-AP-03", fmt.Sprintf("spec.containers[%d].name is empty", ci)) + } + + for ei, e := range c.Execs { + if e.Path == "" { + add("R-AP-10", fmt.Sprintf("containers[%d].execs[%d].path is empty", ci, ei)) + continue + } + if !strings.HasPrefix(e.Path, "/") { + add("R-AP-10", fmt.Sprintf("containers[%d].execs[%d].path %q must be absolute (start with /)", ci, ei, e.Path)) + } + if strings.Contains(e.Path, dynamicIdentifier) || strings.Contains(e.Path, wildcardIdentifier) { + add("R-AP-11", fmt.Sprintf("containers[%d].execs[%d].path %q must NOT contain wildcards (only args[*] may)", ci, ei, e.Path)) + } + + if len(e.Args) == 0 { + continue // path-only entry is legal + } + + // R-AP-12: args[0] must equal the full exec.path. The eBPF + // tracer captures argv[0] as the full binary path; profile + // entries that use a basename (e.g. "sh" instead of "/bin/sh") + // silently fail to match at runtime. Caught the hard way on + // Test_32's first CI run (PR #37 run 25178930763). Exception: + // args[0] may be the wildcard token if the user genuinely + // means "any binary at this path". + if e.Args[0] != e.Path && e.Args[0] != wildcardIdentifier { + add("R-AP-12", fmt.Sprintf("containers[%d].execs[%d].args[0] = %q, must equal path %q (eBPF captures argv[0] as full path)", ci, ei, e.Args[0], e.Path)) + } + + for ai, a := range e.Args { + if a == "" { + add("R-AP-13", fmt.Sprintf("containers[%d].execs[%d].args[%d] is empty", ci, ei, ai)) + } + if strings.Contains(a, dynamicIdentifier) && a != dynamicIdentifier { + add("R-AP-13", fmt.Sprintf("containers[%d].execs[%d].args[%d] = %q — ⋯ must be its own token, not embedded", ci, ei, ai, a)) + } + if strings.Contains(a, wildcardIdentifier) && a != wildcardIdentifier { + add("R-AP-13", fmt.Sprintf("containers[%d].execs[%d].args[%d] = %q — * must be its own token, not embedded", ci, ei, ai, a)) + } + } + } + + for oi, o := range c.Opens { + if o.Path == "" { + add("R-AP-20", fmt.Sprintf("containers[%d].opens[%d].path is empty", ci, oi)) + continue + } + if !strings.HasPrefix(o.Path, "/") { + add("R-AP-20", fmt.Sprintf("containers[%d].opens[%d].path %q must be absolute", ci, oi, o.Path)) + } + if len(o.Flags) == 0 { + add("R-AP-21", fmt.Sprintf("containers[%d].opens[%d].flags is empty", ci, oi)) + } + for fi, f := range o.Flags { + if !validOpenFlags[f] { + add("R-AP-22", fmt.Sprintf("containers[%d].opens[%d].flags[%d] = %q — not a recognised O_* flag (typo?)", ci, oi, fi, f)) + } + } + } + } + return v +} + +// --------------------------------------------------------------------------- +// Test layer — walk YAMLs in this directory, run the linter, surface +// violations as t.Errorf. +// --------------------------------------------------------------------------- + +func TestApplicationProfileFixturesLint(t *testing.T) { + matches, err := filepath.Glob("*.yaml") + if err != nil { + t.Fatalf("glob: %v", err) + } + if len(matches) == 0 { + t.Skip("no YAML fixtures found — running outside tests/resources?") + } + + for _, p := range matches { + t.Run(filepath.Base(p), func(t *testing.T) { + data, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read %s: %v", p, err) + } + // Skip fixtures whose top-level Kind isn't ApplicationProfile. + // Done via a real YAML parse (not strings.Contains) so quoted + // or otherwise-formatted "kind: ApplicationProfile" still + // matches, and so we don't pick up the substring inside + // nested OwnerReferences / event payloads. CodeRabbit PR #38 + // finding (aplint_test.go:221). + var head struct { + Kind string `yaml:"kind"` + } + if err := yaml.Unmarshal(data, &head); err != nil { + // Un-parseable YAML in this directory is a fixture-quality + // bug; failing here surfaces it instead of silently + // skipping the guardrail. CodeRabbit PR #38 finding + // (aplint_test.go:230). + t.Fatalf("fixture %s is not valid YAML: %v", p, err) + } + if head.Kind != "ApplicationProfile" { + // Skipping is correct here — the resources/ directory + // also holds Deployments, Services, NetworkNeighborhoods + // etc. that this linter intentionally doesn't cover. + t.Skipf("not an ApplicationProfile fixture (kind=%q)", head.Kind) + } + for _, v := range LintApplicationProfileYAML(data, p) { + t.Errorf("%s", v) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Self-tests — feed deliberately-bad YAML, verify the expected rule fires. +// Pin rule semantics so a refactor can't silently drop a check. +// --------------------------------------------------------------------------- + +func ruleFired(violations []Violation, ruleID string) bool { + for _, v := range violations { + if v.Rule == ruleID { + return true + } + } + return false +} + +func TestLinter_R_AP_12_argv0_must_be_full_path(t *testing.T) { + bad := []byte(` +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ApplicationProfile +metadata: { name: bad } +spec: + containers: + - name: c + execs: + - path: /bin/sh + args: ["sh", "-c", "echo hi"] +`) + if !ruleFired(LintApplicationProfileYAML(bad, ""), "R-AP-12") { + t.Fatal("expected R-AP-12 violation for basename argv[0]") + } +} + +func TestLinter_R_AP_11_path_no_wildcards(t *testing.T) { + bad := []byte(` +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ApplicationProfile +metadata: { name: bad } +spec: + containers: + - name: c + execs: + - path: /usr/bin/* + args: ["/usr/bin/curl"] +`) + if !ruleFired(LintApplicationProfileYAML(bad, ""), "R-AP-11") { + t.Fatal("expected R-AP-11 violation for wildcard in path") + } +} + +func TestLinter_R_AP_22_unknown_open_flag(t *testing.T) { + bad := []byte(` +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ApplicationProfile +metadata: { name: bad } +spec: + containers: + - name: c + opens: + - path: /etc/passwd + flags: ["O_RDONLY", "O_LARGEFLE"] +`) + if !ruleFired(LintApplicationProfileYAML(bad, ""), "R-AP-22") { + t.Fatal("expected R-AP-22 violation for typo'd flag") + } +} + +func TestLinter_R_AP_10_path_must_be_absolute(t *testing.T) { + bad := []byte(` +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ApplicationProfile +metadata: { name: bad } +spec: + containers: + - name: c + execs: + - path: bin/sh + args: ["bin/sh"] +`) + if !ruleFired(LintApplicationProfileYAML(bad, ""), "R-AP-10") { + t.Fatal("expected R-AP-10 violation for relative path") + } +} + +func TestLinter_R_AP_12_wildcard_argv0_allowed(t *testing.T) { + // args[0] = "*" is the rare-but-legal "match any binary at this path" case. + ok := []byte(` +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ApplicationProfile +metadata: { name: ok } +spec: + containers: + - name: c + execs: + - path: /bin/sh + args: ["*"] +`) + if ruleFired(LintApplicationProfileYAML(ok, ""), "R-AP-12") { + t.Fatal("R-AP-12 must NOT fire when args[0] is the wildcard token") + } +} + +func TestLinter_canonical_AP_passes(t *testing.T) { + // The fork's reference profile (from fea3b062) is the gold standard; + // regressions here mean the linter has drifted from real-world syntax. + // Failing (not skipping) when the fixture is missing keeps the + // "gold standard" test from silently disappearing if someone deletes + // or renames the file. CodeRabbit PR #38 finding (aplint_test.go:230). + data, err := os.ReadFile("known-application-profile.yaml") + if err != nil { + t.Fatalf("canonical AP fixture missing — this guards the linter's gold standard, never delete it: %v", err) + } + violations := LintApplicationProfileYAML(data, "known-application-profile.yaml") + if len(violations) > 0 { + for _, v := range violations { + t.Errorf("%s", v) + } + } +} diff --git a/tests/resources/crypto-miner-deployment.yaml b/tests/resources/crypto-miner-deployment.yaml new file mode 100644 index 0000000000..382a3cb995 --- /dev/null +++ b/tests/resources/crypto-miner-deployment.yaml @@ -0,0 +1,24 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: k8s-miner-deployment + labels: + app: k8s-miner +spec: + replicas: 1 + selector: + matchLabels: + app: k8s-miner + template: + metadata: + labels: + app: k8s-miner + kubescape.io/user-defined-profile: crypto2 + spec: + containers: + - name: k8s-miner + image: docker.io/amitschendel/crypto-miner-1 + imagePullPolicy: Always + workingDir: /usr/app/src + command: ["./xmrig"] + args: ["--bench", "1M"] diff --git a/tests/resources/curl-exec-arg-wildcards-deployment.yaml b/tests/resources/curl-exec-arg-wildcards-deployment.yaml new file mode 100644 index 0000000000..2f06f8baef --- /dev/null +++ b/tests/resources/curl-exec-arg-wildcards-deployment.yaml @@ -0,0 +1,28 @@ +## Curl pod for Test_32_UnexpectedProcessArguments. +## +## Carries the unified user-defined-profile label used by upstream's +## ContainerProfileCache (kubescape/node-agent#788). The label value +## must match the name of BOTH the user ApplicationProfile and (when +## present) the user NetworkNeighborhood. The test creates only the AP +## with that name; the NN side is intentionally absent. +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: curl-32 + name: curl-32 +spec: + selector: + matchLabels: + app: curl-32 + replicas: 1 + template: + metadata: + labels: + app: curl-32 + kubescape.io/user-defined-profile: curl-32-overlay + spec: + containers: + - name: curl + image: docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058 + command: ["sleep", "infinity"] diff --git a/tests/resources/curl-plain-deployment.yaml b/tests/resources/curl-plain-deployment.yaml new file mode 100644 index 0000000000..003810550a --- /dev/null +++ b/tests/resources/curl-plain-deployment.yaml @@ -0,0 +1,20 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: curl-fusioncore-28-0 + name: curl-fusioncore-deployment +spec: + selector: + matchLabels: + app: curl-fusioncore-28-0 + replicas: 1 + template: + metadata: + labels: + app: curl-fusioncore-28-0 + spec: + containers: + - name: curl + image: docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058 + command: ["sleep", "infinity"] diff --git a/tests/resources/curl-signed-deployment.yaml b/tests/resources/curl-signed-deployment.yaml new file mode 100644 index 0000000000..df15283ccd --- /dev/null +++ b/tests/resources/curl-signed-deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: curl-29 + name: curl-29 +spec: + selector: + matchLabels: + app: curl-29 + replicas: 1 + template: + metadata: + labels: + app: curl-29 + kubescape.io/user-defined-profile: signed-ap + spec: + containers: + - name: curl + image: docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058 + command: ["sleep", "infinity"] diff --git a/tests/resources/curl-user-network-deployment.yaml b/tests/resources/curl-user-network-deployment.yaml new file mode 100644 index 0000000000..122de0f1c1 --- /dev/null +++ b/tests/resources/curl-user-network-deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: curl-fusioncore-28-1 + name: curl-fusioncore-deployment +spec: + selector: + matchLabels: + app: curl-fusioncore-28-1 + replicas: 1 + template: + metadata: + labels: + app: curl-fusioncore-28-1 + kubescape.io/user-defined-network: fusioncore-network + spec: + containers: + - name: curl + image: docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058 + command: ["sleep", "infinity"] diff --git a/tests/resources/curl-user-profile-wildcards-deployment.yaml b/tests/resources/curl-user-profile-wildcards-deployment.yaml new file mode 100644 index 0000000000..7b2e4ab7db --- /dev/null +++ b/tests/resources/curl-user-profile-wildcards-deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: curl-fusioncore + name: curl-fusioncore-deployment +spec: + selector: + matchLabels: + app: curl-fusioncore + replicas: 1 + template: + metadata: + labels: + app: curl-fusioncore + kubescape.io/user-defined-profile: fusioncore-profile-wildcards + spec: + containers: + - name: curl + image: docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058 + command: ["sleep", "infinity"] diff --git a/tests/resources/known-application-profile.yaml b/tests/resources/known-application-profile.yaml new file mode 100644 index 0000000000..b802941572 --- /dev/null +++ b/tests/resources/known-application-profile.yaml @@ -0,0 +1,245 @@ +## +## User-defined ApplicationProfile for Test_28. +## +## Referenced directly from a pod via the label: +## kubescape.io/user-defined-profile: fusioncore-profile +## +## Modeled after a real auto-learned AP from curlimages/curl:8.5.0. +## +## Usage: +## sed "s/{{NAMESPACE}}/$NS/g" known-application-profile.yaml \ +## | kubectl apply -f - +## +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ApplicationProfile +metadata: + name: fusioncore-profile + namespace: "{{NAMESPACE}}" +spec: + architectures: ["amd64"] + containers: + - name: curl + imageID: "docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058" + imageTag: "docker.io/curlimages/curl:8.5.0" + capabilities: + - CAP_CHOWN + - CAP_DAC_OVERRIDE + - CAP_DAC_READ_SEARCH + - CAP_SETGID + - CAP_SETPCAP + - CAP_SETUID + - CAP_SYS_ADMIN + execs: + - path: /bin/sleep + args: ["/bin/sleep", "infinity"] + - path: /bin/cat + args: ["/bin/cat"] + - path: /usr/bin/curl + args: ["/usr/bin/curl", "-sm2", "fusioncore.ai"] + - path: /usr/bin/nslookup + args: ["/usr/bin/nslookup"] + opens: + - path: /7/setgroups + flags: ["O_RDONLY", "O_CLOEXEC"] + - path: /etc/hosts + flags: ["O_CLOEXEC", "O_RDONLY", "O_LARGEFILE"] + - path: /etc/ld-musl-x86_64.path + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /etc/passwd + flags: ["O_RDONLY", "O_CLOEXEC", "O_LARGEFILE"] + - path: /etc/resolv.conf + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /etc/ssl/openssl.cnf + flags: ["O_RDONLY", "O_LARGEFILE"] + - path: /home/curl_user/.config/curlrc + flags: ["O_RDONLY", "O_LARGEFILE"] + - path: /home/curl_user/.curlrc + flags: ["O_RDONLY", "O_LARGEFILE"] + - path: /lib/libbrotlicommon.so.1 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /lib/libbrotlidec.so.1 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /lib/libcom_err.so.2.1 + flags: ["O_CLOEXEC", "O_RDONLY", "O_LARGEFILE"] + - path: /lib/libcrypto.so.3 + flags: ["O_CLOEXEC", "O_RDONLY", "O_LARGEFILE"] + - path: /lib/libcurl.so.4 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /lib/libgssapi_krb5.so.2 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /lib/libidn2.so.0 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /lib/libk5crypto.so.3 + flags: ["O_CLOEXEC", "O_RDONLY", "O_LARGEFILE"] + - path: /lib/libkeyutils.so.1 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /lib/libkrb5.so.3 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /lib/libkrb5support.so.0 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /lib/libnghttp2.so.14 + flags: ["O_CLOEXEC", "O_RDONLY", "O_LARGEFILE"] + - path: /lib/libpsl.so.5 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /lib/libssh2.so.1 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /lib/libssl.so.3 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /lib/libunistring.so.5 + flags: ["O_CLOEXEC", "O_RDONLY", "O_LARGEFILE"] + - path: /lib/libz.so.1.3 + flags: ["O_LARGEFILE", "O_CLOEXEC", "O_RDONLY"] + - path: /proc/⋯/cgroup + flags: ["O_RDONLY", "O_CLOEXEC"] + - path: /proc/⋯/kernel/cap_last_cap + flags: ["O_RDONLY", "O_CLOEXEC"] + - path: /proc/⋯/mountinfo + flags: ["O_RDONLY", "O_CLOEXEC"] + - path: /proc/⋯/task/1/fd + flags: ["O_RDONLY", "O_DIRECTORY", "O_CLOEXEC"] + - path: /proc/⋯/task/7/fd + flags: ["O_RDONLY", "O_DIRECTORY", "O_CLOEXEC"] + - path: /runc + flags: ["O_RDONLY", "O_CLOEXEC"] + - path: /sys/fs/cgroup/cpu.max + flags: ["O_RDONLY", "O_CLOEXEC"] + - path: /sys/kernel/mm/transparent_hugepage/hpage_pmd_size + flags: ["O_RDONLY"] + - path: /usr/lib/libbrotlicommon.so.1.1.0 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/lib/libbrotlidec.so.1.1.0 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/lib/libcurl.so.4.8.0 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/lib/libgssapi_krb5.so.2.2 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/lib/libidn2.so.0.3.8 + flags: ["O_CLOEXEC", "O_RDONLY", "O_LARGEFILE"] + - path: /usr/lib/libk5crypto.so.3.1 + flags: ["O_CLOEXEC", "O_RDONLY", "O_LARGEFILE"] + - path: /usr/lib/libkeyutils.so.1.10 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/lib/libkrb5.so.3.3 + flags: ["O_CLOEXEC", "O_RDONLY", "O_LARGEFILE"] + - path: /usr/lib/libkrb5support.so.0.1 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/lib/libnghttp2.so.14.25.1 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/lib/libpsl.so.5.3.4 + flags: ["O_CLOEXEC", "O_RDONLY", "O_LARGEFILE"] + - path: /usr/lib/libssh2.so.1.0.1 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/lib/libunistring.so.5.0.0 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libbrotlicommon.so.1 + flags: ["O_LARGEFILE", "O_CLOEXEC", "O_RDONLY"] + - path: /usr/local/lib/libbrotlidec.so.1 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libcom_err.so.2 + flags: ["O_CLOEXEC", "O_RDONLY", "O_LARGEFILE"] + - path: /usr/local/lib/libcrypto.so.3 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libcurl.so.4 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libgssapi_krb5.so.2 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libidn2.so.0 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libk5crypto.so.3 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libkeyutils.so.1 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libkrb5.so.3 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libkrb5support.so.0 + flags: ["O_CLOEXEC", "O_RDONLY", "O_LARGEFILE"] + - path: /usr/local/lib/libnghttp2.so.14 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libpsl.so.5 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libssh2.so.1 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libssl.so.3 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libunistring.so.5 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + - path: /usr/local/lib/libz.so.1 + flags: ["O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"] + syscalls: + - arch_prctl + - bind + - brk + - capget + - capset + - chdir + - clone + - close + - close_range + - connect + - epoll_ctl + - epoll_pwait + - execve + - exit + - exit_group + - faccessat2 + - fchown + - fcntl + - fstat + - fstatfs + - futex + - getcwd + - getdents64 + - getegid + - geteuid + - getgid + - getpeername + - getppid + - getsockname + - getsockopt + - gettid + - getuid + - ioctl + - membarrier + - mmap + - mprotect + - munmap + - nanosleep + - newfstatat + - open + - openat + - openat2 + - pipe + - poll + - prctl + - read + - recvfrom + - recvmsg + - rt_sigaction + - rt_sigprocmask + - rt_sigreturn + - sendto + - set_tid_address + - setgid + - setgroups + - setsockopt + - setuid + - sigaltstack + - socket + - statx + - tkill + - unknown + - write + - writev + endpoints: + - endpoint: ":80/" + direction: outbound + methods: ["GET"] + internal: false + headers: '{"Host":["fusioncore.ai"]}' + seccompProfile: + spec: + defaultAction: "" + rulePolicies: {} + initContainers: [] + ephemeralContainers: [] +status: {} diff --git a/tests/resources/known-network-neighborhood.yaml b/tests/resources/known-network-neighborhood.yaml new file mode 100644 index 0000000000..0d4caa0c4e --- /dev/null +++ b/tests/resources/known-network-neighborhood.yaml @@ -0,0 +1,49 @@ +## +## User-defined NetworkNeighborhood for Test_28. +## +## Referenced directly from a pod via the label: +## kubescape.io/user-defined-network: fusioncore-network +## +## Carries "kubescape.io/managed-by: User" annotation and workload +## labels to match the schema the node-agent cache expects. +## +## Modeled after a real auto-learned NN from curlimages/curl:8.5.0. +## +## Usage: +## sed "s/{{NAMESPACE}}/$NS/g" known-network-neighborhood.yaml \ +## | kubectl apply -f - +## +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: fusioncore-network + namespace: "{{NAMESPACE}}" + annotations: + kubescape.io/managed-by: User + kubescape.io/status: completed + kubescape.io/completion: complete + labels: + kubescape.io/workload-api-group: apps + kubescape.io/workload-api-version: v1 + kubescape.io/workload-kind: Deployment + kubescape.io/workload-name: curl-fusioncore-deployment + kubescape.io/workload-namespace: "{{NAMESPACE}}" +spec: + matchLabels: + app: curl-fusioncore-28-1 + containers: + - name: curl + ingress: [] + egress: + - dns: fusioncore.ai. + dnsNames: + - fusioncore.ai. + identifier: a5e64ff1db824089b1706ac872303e55075f92cf6a652b5272f06c3a2b9e8d10 + ipAddress: 162.0.217.171 + namespaceSelector: null + podSelector: null + ports: + - name: TCP-80 + port: 80 + protocol: TCP + type: external diff --git a/tests/resources/network-wildcards/01-literal-ipv4.yaml b/tests/resources/network-wildcards/01-literal-ipv4.yaml new file mode 100644 index 0000000000..a9861986c4 --- /dev/null +++ b/tests/resources/network-wildcards/01-literal-ipv4.yaml @@ -0,0 +1,26 @@ +# Fixture 01 — IPv4 literal in ipAddresses[] +# +# Edge case: single IPv4 literal in the new plural field +# Expects: observed IP "162.0.217.171" matches; "162.0.217.172" does NOT +# Match path: networkmatch.MatchIP(["162.0.217.171"], observed) → true iff equal +# Spec ref: §5.7 "IPv4 / IPv6 literal" row +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-01-literal-ipv4 + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-01 + containers: + - name: client + egress: + - identifier: literal-ipv4 + type: external + ipAddresses: + - "162.0.217.171" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/02-literal-ipv6.yaml b/tests/resources/network-wildcards/02-literal-ipv6.yaml new file mode 100644 index 0000000000..b0856b33a8 --- /dev/null +++ b/tests/resources/network-wildcards/02-literal-ipv6.yaml @@ -0,0 +1,27 @@ +# Fixture 02 — IPv6 literal, canonicalisation +# +# Edge case: IPv6 literal, both compressed and expanded forms MUST compare equal +# Expects: "2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0001", +# and "2001:DB8::1" all match each other +# Match path: net.ParseIP(...) normalises before .Equal() — verifier responsibility +# Spec ref: §5.7 — "textual canonicalisation is the verifier's responsibility" +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-02-literal-ipv6 + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-02 + containers: + - name: client + egress: + - identifier: literal-ipv6 + type: external + ipAddresses: + - "2001:db8::1" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/03-cidr-ipv4.yaml b/tests/resources/network-wildcards/03-cidr-ipv4.yaml new file mode 100644 index 0000000000..cd803cbc00 --- /dev/null +++ b/tests/resources/network-wildcards/03-cidr-ipv4.yaml @@ -0,0 +1,28 @@ +# Fixture 03 — IPv4 CIDR +# +# Edge case: a single CIDR block covers a range of IPs +# Expects: observed "10.0.0.1" matches; "10.255.255.254" matches; +# "11.0.0.1" does NOT match +# Match path: net.ParseCIDR("10.0.0.0/8") → *IPNet; IPNet.Contains(observed) +# Perf: compile once at profile-load, reuse the *IPNet on every event +# Spec ref: §5.7 "CIDR" row +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-03-cidr-ipv4 + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-03 + containers: + - name: client + egress: + - identifier: rfc1918-class-a + type: internal + ipAddresses: + - "10.0.0.0/8" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/04-cidr-ipv6.yaml b/tests/resources/network-wildcards/04-cidr-ipv6.yaml new file mode 100644 index 0000000000..a885323c75 --- /dev/null +++ b/tests/resources/network-wildcards/04-cidr-ipv6.yaml @@ -0,0 +1,26 @@ +# Fixture 04 — IPv6 CIDR +# +# Edge case: IPv6 CIDR matching +# Expects: observed "2001:db8::1" matches; "2001:db9::1" does NOT +# Match path: same code path as IPv4 CIDR — net.ParseCIDR recognises both +# Spec ref: §5.7 "CIDR" row, second example +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-04-cidr-ipv6 + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-04 + containers: + - name: client + egress: + - identifier: rfc3849-doc-prefix + type: external + ipAddresses: + - "2001:db8::/32" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/05-any-ip-sentinel.yaml b/tests/resources/network-wildcards/05-any-ip-sentinel.yaml new file mode 100644 index 0000000000..035fd046a0 --- /dev/null +++ b/tests/resources/network-wildcards/05-any-ip-sentinel.yaml @@ -0,0 +1,31 @@ +# Fixture 05 — `*` sentinel for ANY IP +# +# Edge case: a single "*" entry — matches any IPv4 or IPv6 address +# Expects: every observed IP matches; this is permissive-mode profiling +# Match path: compileIP("*") returns isAny=true; runtime short-circuits +# Spec ref: §5.7 "* (any-IP sentinel)" row + the warning at the bottom +# Operations: strongly DISCOURAGED outside development profiles — +# equivalent to disabling egress filtering for this workload. +# Producers should normally enumerate concrete IPs/CIDRs. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-05-any-sentinel + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" + # Make the operational risk explicit: + sbob.io/discouraged-wildcards: "ipAddresses-any-sentinel" +spec: + matchLabels: + app: nw-05 + containers: + - name: client + egress: + - identifier: any-ip-development-profile + type: external + ipAddresses: + - "*" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/06-any-as-cidr.yaml b/tests/resources/network-wildcards/06-any-as-cidr.yaml new file mode 100644 index 0000000000..b897eb4ec8 --- /dev/null +++ b/tests/resources/network-wildcards/06-any-as-cidr.yaml @@ -0,0 +1,34 @@ +# Fixture 06 — RFC-aligned alternatives to the `*` sentinel +# +# Edge case: `0.0.0.0/0` (RFC 4632 — all IPv4) and `::/0` (RFC 4291 — all IPv6) +# MUST behave identically to `*` +# Expects: observed "1.2.3.4" matches via 0.0.0.0/0; +# observed "2001:db8::1" matches via ::/0 +# Match path: regular CIDR matching — no special casing needed +# Spec ref: §5.7 — "*" sentinel "is sugar for the union of 0.0.0.0/0 + ::/0" +# Why both forms exist: +# Producers who prefer standards-compliant CIDR over our `*` +# sugar can express "any IP" via these two CIDRs and the +# document will be accepted by tooling that doesn't recognise +# the `*` sentinel. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-06-any-as-cidr + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-06 + containers: + - name: client + egress: + - identifier: any-via-cidrs + type: external + ipAddresses: + - "0.0.0.0/0" + - "::/0" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/07-mixed-ip-list.yaml b/tests/resources/network-wildcards/07-mixed-ip-list.yaml new file mode 100644 index 0000000000..dc5d526fb4 --- /dev/null +++ b/tests/resources/network-wildcards/07-mixed-ip-list.yaml @@ -0,0 +1,36 @@ +# Fixture 07 — mixed list (literal + CIDR + sentinel) +# +# Edge case: a single ipAddresses[] list mixes all three forms +# Expects: +# "10.1.2.3" → matches via 10.0.0.0/8 +# "162.0.217.171" → matches via the literal +# "8.8.8.8" → matches via the `*` sentinel +# (this fixture intentionally has an unconstrained `*` because +# of the sentinel — the literal and CIDR are illustrative) +# Match path: ANY entry matches → match passes (logical OR) +# Spec ref: §5.7 algorithm "for each entry e in profile.ipAddresses" +# Test value: exercises the loop ordering and short-circuit-on-first-match +# behaviour +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-07-mixed-ip-list + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-07 + containers: + - name: client + egress: + - identifier: mixed-shapes + type: external + ipAddresses: + - "162.0.217.171" # IPv4 literal + - "10.0.0.0/8" # IPv4 CIDR + - "2001:db8::/32" # IPv6 CIDR + - "*" # any (sentinel — overrides everything; here for test) + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/08-deprecated-ipaddress.yaml b/tests/resources/network-wildcards/08-deprecated-ipaddress.yaml new file mode 100644 index 0000000000..5d56b271a2 --- /dev/null +++ b/tests/resources/network-wildcards/08-deprecated-ipaddress.yaml @@ -0,0 +1,32 @@ +# Fixture 08 — backward compatibility with deprecated singular `ipAddress` +# +# Edge case: only the deprecated singular field populated; ipAddresses absent +# Expects: observed "10.0.0.42" matches via the singular field; +# behaviour unchanged from v0.0.1 +# Match path: verifier walks BOTH singular and plural fields, treating them +# as a logical OR +# Spec ref: §4.7 ipAddress row — "Deprecated since v0.0.2 — kept for back-compat" +# Producer rule: MUST NOT populate both `ipAddress` (singular) and `ipAddresses` +# (plural) on the same entry — admission strategy rejects +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-08-deprecated-ipaddress + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" + # New profiles should use ipAddresses; this fixture exists only to pin + # back-compat behaviour for v0.0.1-era documents that haven't migrated yet. + sbob.io/migration-target: "ipAddresses" +spec: + matchLabels: + app: nw-08 + containers: + - name: legacy-client + egress: + - identifier: legacy-singular-ip + type: external + ipAddress: "10.0.0.42" # DEPRECATED — kept here on purpose to exercise back-compat + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/09-dns-literal.yaml b/tests/resources/network-wildcards/09-dns-literal.yaml new file mode 100644 index 0000000000..93b199d490 --- /dev/null +++ b/tests/resources/network-wildcards/09-dns-literal.yaml @@ -0,0 +1,29 @@ +# Fixture 09 — DNS literal +# +# Edge case: plain FQDN, byte-equality after trailing-dot normalisation +# Expects: observed "api.stripe.com." matches; "api.stripe.com" matches +# (both forms equivalent); "v1.api.stripe.com." does NOT +# Match path: normalise trailing dot on both profile entry and observed name, +# then byte-equality +# Spec ref: §5.8 "Literal" row, plus the trailing-dot normalisation paragraph +# RFC ref: RFC 1035 § 3.1 (FQDN syntax) +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-09-dns-literal + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-09 + containers: + - name: client + egress: + - identifier: stripe-api-literal + type: external + dnsNames: + - "api.stripe.com." + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/10-dns-leading-wildcard.yaml b/tests/resources/network-wildcards/10-dns-leading-wildcard.yaml new file mode 100644 index 0000000000..46802c4419 --- /dev/null +++ b/tests/resources/network-wildcards/10-dns-leading-wildcard.yaml @@ -0,0 +1,35 @@ +# Fixture 10 — DNS leading wildcard `*.` +# +# Edge case: RFC 4592 wildcard label — exactly ONE label before the suffix +# Expects: +# observed "api.example.com." → match (one label "api") +# observed "webhooks.example.com." → match (one label "webhooks") +# observed "v1.api.example.com." → NO match (two labels — leading * is exactly one) +# observed "example.com." → NO match (apex — leading * requires at least one) +# observed ".example.com." → NO match (empty label — invalid DNS) +# Match path: label-split + per-position match using the same recursive +# matcher as path wildcards (`compareLabels`) +# Spec ref: §5.8 "*." row + the rationale block +# RFC ref: RFC 4592 (DNS wildcard match) — "exactly one label" is the +# only ratified wildcard form; bind/coredns/cilium/k8s ingress +# all honour this convention +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-10-dns-leading-wildcard + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-10 + containers: + - name: client + egress: + - identifier: example-com-subdomains + type: external + dnsNames: + - "*.example.com." + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/11-dns-mid-ellipsis.yaml b/tests/resources/network-wildcards/11-dns-mid-ellipsis.yaml new file mode 100644 index 0000000000..3325329c6c --- /dev/null +++ b/tests/resources/network-wildcards/11-dns-mid-ellipsis.yaml @@ -0,0 +1,41 @@ +# Fixture 11 — DNS mid-label `⋯` (DynamicIdentifier) +# +# Edge case: exactly ONE label between two static segments +# (the user's `svc.*.kubernetes.io.` use case, spelt with ⋯ +# because mid-label `*` is non-standard) +# Expects: +# observed "svc.kube-system.cluster.local." → match +# observed "svc.default.cluster.local." → match +# observed "svc.cluster.local." → NO match (zero labels in slot) +# observed "svc.a.b.cluster.local." → NO match (two labels — ⋯ is exactly one) +# Match path: label-split + the existing dynamicpathdetector.CompareDynamic +# (DNS labels and path segments are structurally identical) +# Spec ref: §5.8 ".⋯." row — "DynamicIdentifier — exactly one label" +# Why this exists: +# RFC 4592 only standardises LEADING wildcards. Mid-label `*` is non-standard +# (cilium uses regex; bind/coredns reject it). v0.0.2 uses `⋯` (our token, +# from path/argv wildcards) for mid positions so the wire format never +# claims false RFC 4592 compliance. +# Token reminder: +# `⋯` is U+22EF (MIDLINE HORIZONTAL ELLIPSIS) — ONE Unicode codepoint. +# It is NOT three ASCII periods (`...`). +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-11-dns-mid-ellipsis + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-11 + containers: + - name: dns-client + egress: + - identifier: cluster-svc-resolution + type: internal + dnsNames: + - "svc.⋯.cluster.local." + ports: + - {name: UDP-53, protocol: UDP, port: 53} diff --git a/tests/resources/network-wildcards/12-dns-trailing-star.yaml b/tests/resources/network-wildcards/12-dns-trailing-star.yaml new file mode 100644 index 0000000000..b78723f10e --- /dev/null +++ b/tests/resources/network-wildcards/12-dns-trailing-star.yaml @@ -0,0 +1,46 @@ +# Fixture 12 — DNS trailing wildcard `.*` +# +# Edge case: one OR MORE labels after the prefix (NEVER zero) +# Expects: +# observed "mycorp.com.api." → match (one label after) +# observed "mycorp.com.api.v1." → match (two labels after) +# observed "mycorp.com.api.v1.eu-west-1." → match (three labels after) +# observed "mycorp.com." → NO match (apex — zero labels; +# trailing `*` requires ≥1) +# Match path: label-split + recursive matcher with one-or-more-segment +# semantic on trailing `*`. Same defensive arity rule as paths +# (§5.1) — closes the apex blind spot. +# Spec ref: §5.8 ".*" row, "one or more labels (never zero)" +# +# IMPORTANT clarification on label order: +# DNS names are read LEFT-TO-RIGHT but their label hierarchy goes +# RIGHT-TO-LEFT (the rightmost label is the TLD). So for `mycorp.com.*`, +# the `*` sits in the LEFTMOST positions of any matching name. This +# is opposite to the path convention. Both conventions agree that the +# `*` consumes "1+ tokens at the variable end" — they just differ on +# which end is variable. +# +# Producers should usually prefer `*.mycorp.com.` (leading-`*` per +# RFC 4592) for "any subdomain" intent, since that's the standardised +# form. The trailing form documented here is for cases where the +# variable hierarchy is on the LEFT of a fixed registry suffix. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-12-dns-trailing-star + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-12 + containers: + - name: client + egress: + - identifier: mycorp-anything-deeper + type: external + dnsNames: + - "mycorp.com.*" + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/13-dns-trailing-dot-normalisation.yaml b/tests/resources/network-wildcards/13-dns-trailing-dot-normalisation.yaml new file mode 100644 index 0000000000..fc7af7bdc9 --- /dev/null +++ b/tests/resources/network-wildcards/13-dns-trailing-dot-normalisation.yaml @@ -0,0 +1,39 @@ +# Fixture 13 — trailing-dot normalisation +# +# Edge case: DNS literals MUST compare equal whether or not the trailing +# dot is present, on either side +# Expects (with profile entry "api.stripe.com." — WITH dot): +# observed "api.stripe.com." → match +# observed "api.stripe.com" → match (verifier normalises) +# Expects (with profile entry "api.stripe.com" — WITHOUT dot): +# observed "api.stripe.com." → match +# observed "api.stripe.com" → match +# Match path: verifier MUST canonicalise both sides before comparison +# (e.g. always append "." if missing) +# Spec ref: §5.8 "Trailing-dot normalisation" paragraph +# Producer guidance: emit the trailing dot — it's the FQDN-canonical form per +# RFC 1035. But verifiers MUST accept either. +# +# This fixture deliberately mixes both forms in dnsNames[] to ensure the +# normalisation runs on profile-side entries, not just observed names. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-13-dns-trailing-dot + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-13 + containers: + - name: client + egress: + - identifier: mixed-trailing-dot-forms + type: external + dnsNames: + - "api.stripe.com." # canonical FQDN form + - "api.github.com" # without trailing dot — equivalent + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/14-recursive-star-rejected.yaml b/tests/resources/network-wildcards/14-recursive-star-rejected.yaml new file mode 100644 index 0000000000..703334fd8f --- /dev/null +++ b/tests/resources/network-wildcards/14-recursive-star-rejected.yaml @@ -0,0 +1,38 @@ +# Fixture 14 — `**` recursive wildcard MUST be rejected +# +# Edge case: a producer attempts to use the recursive `**` wildcard +# Expects: apiserver admission strategy REJECTS the document at write time +# (kubectl apply returns an error; nothing is persisted) +# Match path: N/A — never reaches a runtime matcher +# Spec ref: §5.8 last row "** (recursive zero-or-more) — NOT in v0.0.2" +# and "Empty / ** rejection" paragraph +# Why deferred to v0.0.3: +# `**` semantics need careful design — should it match zero labels? +# how does it interact with leading/trailing `*`? Reserve the syntax now +# so producers don't accidentally rely on a future behaviour change. +# +# This fixture is INTENTIONALLY INVALID. The component test should: +# 1. Attempt `kubectl apply -f 14-recursive-star-rejected.yaml` +# 2. Assert the command fails with a validation error +# 3. Assert no NetworkNeighborhood named `nw-14-recursive-rejected` exists +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-14-recursive-rejected + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" + sbob.io/expected-admission: "rejected" +spec: + matchLabels: + app: nw-14 + containers: + - name: client + egress: + - identifier: invalid-recursive + type: external + dnsNames: + - "**.example.com." # INVALID — admission MUST reject + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/15-egress-and-ingress.yaml b/tests/resources/network-wildcards/15-egress-and-ingress.yaml new file mode 100644 index 0000000000..348ffc182b --- /dev/null +++ b/tests/resources/network-wildcards/15-egress-and-ingress.yaml @@ -0,0 +1,46 @@ +# Fixture 15 — egress AND ingress on the same container +# +# Edge case: both directions populated; matchers MUST be independently scoped +# Expects: +# pktType=='OUTGOING' to "10.1.2.3" → match in egress (CIDR 10.0.0.0/8) +# pktType=='OUTGOING' to "192.0.2.1" → NO match in egress (NOT in CIDR) +# pktType=='INCOMING' from "192.168.1.42" → match in ingress (CIDR 192.168.0.0/16) +# pktType=='INCOMING' from "10.0.0.42" → NO match in ingress (NOT in 192.168/16) +# (even though 10.0.0.0/8 IS in egress — +# direction isolation is the contract) +# Match path: nn.was_address_in_egress() walks Spec.Egress only; +# nn.was_address_in_ingress() walks Spec.Ingress only +# Spec ref: §4.7 "egress and ingress" — direction isolation contract +# +# Note on current rule coverage: +# The default kubescape rule set (R0005, R0011, etc.) only fires on +# pktType=='OUTGOING'. The ingress block is fully matchable via the +# nn.was_address_in_ingress / nn.is_domain_in_ingress CEL functions, +# but no built-in rule consumes them as of v0.0.2. Custom rules MAY. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-15-egress-and-ingress + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-15 + containers: + - name: bidirectional + egress: + - identifier: outbound-class-a + type: internal + ipAddresses: + - "10.0.0.0/8" + ports: + - {name: TCP-443, protocol: TCP, port: 443} + ingress: + - identifier: inbound-rfc1918-class-c + type: internal + ipAddresses: + - "192.168.0.0/16" + ports: + - {name: TCP-8080, protocol: TCP, port: 8080} diff --git a/tests/resources/network-wildcards/16-egress-none.yaml b/tests/resources/network-wildcards/16-egress-none.yaml new file mode 100644 index 0000000000..113f873754 --- /dev/null +++ b/tests/resources/network-wildcards/16-egress-none.yaml @@ -0,0 +1,38 @@ +# Fixture 16 — NONE egress (declared zero-egress traffic) +# +# Edge case: egress: [] explicit empty list — declares "this workload +# makes ZERO outbound network connections" +# Expects: verifier emits net.egress_unexpected on the FIRST observed +# outgoing connection (any IP, any DNS, any port) +# Spec ref: §5.4 NONE semantic — "explicit empty list = declared +# zero-activity, hard violation on first observation" +# Distinction from absent: +# `egress:` MISSING from the doc = NULL (verifier-defined posture) +# `egress: []` = NONE (zero-traffic contract) +# This fixture pins the latter. +# +# Producer use case: +# A worker pod that should ONLY accept inbound work and never reach out. +# A locked-down database whose only legitimate traffic is the ingress +# replication stream. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-16-egress-none + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-16 + containers: + - name: locked-down-worker + egress: [] # NONE — any outbound traffic is a violation + ingress: + - identifier: control-plane-only + type: internal + ipAddresses: + - "10.0.0.1" + ports: + - {name: TCP-9000, protocol: TCP, port: 9000} diff --git a/tests/resources/network-wildcards/17-realistic-stripe-api.yaml b/tests/resources/network-wildcards/17-realistic-stripe-api.yaml new file mode 100644 index 0000000000..05ae61e2b9 --- /dev/null +++ b/tests/resources/network-wildcards/17-realistic-stripe-api.yaml @@ -0,0 +1,58 @@ +# Fixture 17 — realistic Stripe API integration +# +# Edge case: end-to-end realistic profile for a workload that calls +# Stripe (well-known external SaaS) plus cluster DNS +# Demonstrates: +# - egress[] with multiple entries (external + internal) +# - ipAddresses[] with both literal and CIDR +# - dnsNames[] with literal AND leading wildcard (RFC 4592) +# - selectors-based internal entry (auto-translated to NetworkPolicy; +# not consulted by R0005/R0011 runtime — see §4.7 caveat) +# - port specifications +# Expects: +# POST https://api.stripe.com (resolved to one of Stripe's IPs) → match +# POST https://files.stripe.com (matches *.stripe.com.) → match +# POST https://api.example.com → NO match +# UDP to kube-dns:53 → match (NetworkPolicy) +# but R0011/R0005 +# don't consult selectors +# — see §4.7 note +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-17-realistic-stripe + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: payment-service + containers: + - name: payment-app + egress: + - identifier: stripe-api + type: external + ipAddresses: + - "162.0.217.171" # Stripe public IP example + - "163.0.0.0/16" # Stripe routing range — for completeness + dnsNames: + - "api.stripe.com." + - "*.stripe.com." # leading-* RFC 4592 — covers files.stripe.com., + # webhooks.stripe.com., billing.stripe.com. + # but NOT v1.api.stripe.com. (two labels deep) + ports: + - {name: TCP-443, protocol: TCP, port: 443} + - identifier: cluster-dns + type: internal + # Selector-based entry — auto-translates to a NetworkPolicy egress rule + # that K8s enforces. Note: R0005/R0011 runtime matchers do NOT consult + # selectors as of v0.0.2 — they only walk ipAddresses/dnsNames. + namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - {name: UDP-53, protocol: UDP, port: 53} diff --git a/tests/resources/network-wildcards/18-cluster-dns-via-mid-ellipsis.yaml b/tests/resources/network-wildcards/18-cluster-dns-via-mid-ellipsis.yaml new file mode 100644 index 0000000000..c63df62a60 --- /dev/null +++ b/tests/resources/network-wildcards/18-cluster-dns-via-mid-ellipsis.yaml @@ -0,0 +1,55 @@ +# Fixture 18 — Kubernetes service-FQDN resolution via mid-`⋯` +# +# Edge case: The user's specific case from the v0.0.2 design discussion. +# In Kubernetes, services are resolved as +# ..svc.cluster.local. +# A workload that wants to permit "any namespace's +# service" should match exactly one label between fixed +# anchors. +# Expects: +# observed "redis.production.svc.cluster.local." → NO match (we anchored on `redis`, +# and only the namespace label is +# wildcarded) +# observed "redis.staging.svc.cluster.local." → NO match (same — we'd need +# a different fixture for "any svc +# in any ns") +# observed "kubernetes.default.svc.cluster.local." → match (one label "default") +# Match path: label-split + recursive matcher; `⋯` consumes exactly one +# label between two fixed segments +# Spec ref: §5.8 ".⋯." row, the example uses this exact pattern +# +# Why `⋯` and not `*`: +# RFC 4592 only standardises *.. Mid-label `*` is non-standard +# (cilium uses regex; bind/coredns reject it). v0.0.2 uses `⋯` (DynamicIdentifier, +# the project's existing token from path/argv wildcards) for mid positions. +# +# Hardcoded short-circuit removal candidate: +# The default rule R0005 currently has a hardcoded +# `!event.name.endsWith('.svc.cluster.local.')` short-circuit. With this +# fixture's mid-⋯ form, that hardcode becomes profile-expressible — a +# future PR can REMOVE the rule-side short-circuit and let producers +# declare the equivalent via this NN. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-18-cluster-dns-mid-ellipsis + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-18 + containers: + - name: dns-client + egress: + - identifier: any-namespace-kubernetes-svc + type: internal + dnsNames: + - "kubernetes.⋯.svc.cluster.local." + # ↑ matches kubernetes.default.svc.cluster.local. exactly, + # parametric on the namespace label. Use one entry per + # service the workload calls; the wildcard is on the + # namespace position, not the service name. + ports: + - {name: TCP-443, protocol: TCP, port: 443} diff --git a/tests/resources/network-wildcards/19-port-protocol-with-cidr.yaml b/tests/resources/network-wildcards/19-port-protocol-with-cidr.yaml new file mode 100644 index 0000000000..2135851abf --- /dev/null +++ b/tests/resources/network-wildcards/19-port-protocol-with-cidr.yaml @@ -0,0 +1,41 @@ +# Fixture 19 — port + protocol + CIDR composed match +# +# Edge case: nn.was_address_port_protocol_in_egress matcher — the granular +# variant that requires IP+port+protocol all to match within +# the same NetworkNeighbor entry +# Expects: +# observed (10.1.2.3, 443, TCP) → match (both CIDR and port match within entry) +# observed (10.1.2.3, 80, TCP) → NO match (CIDR ok but port mismatch) +# observed (192.168.1.1, 443, TCP)→ NO match (port ok but CIDR mismatch) +# observed (10.1.2.3, 443, UDP) → NO match (CIDR + port ok but protocol mismatch) +# Match path: for each NetworkNeighbor: +# if MatchIP(entry.IPs, observed) && entry contains matching +# (port, protocol) tuple → true +# Spec ref: §4.7 ports[] row — name + protocol + port (uint16 nullable) +# +# This fixture validates that the new IP-matcher integration preserves the +# port-protocol grouping contract — a CIDR match alone isn't sufficient +# unless the entry's ports list also contains the (port, protocol) pair. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-19-port-proto-cidr + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-19 + containers: + - name: client + egress: + - identifier: tls-only-class-a + type: internal + ipAddresses: + - "10.0.0.0/8" + ports: + - {name: TCP-443, protocol: TCP, port: 443} + # Note: no UDP entry, no port-80 entry — only TCP/443 within this CIDR. + # A request to (10.1.2.3, 80, TCP) should NOT match because the + # port-protocol filter is per-NetworkNeighbor-entry, not global. diff --git a/tests/resources/network-wildcards/20-multi-container-mixed-wildcards.yaml b/tests/resources/network-wildcards/20-multi-container-mixed-wildcards.yaml new file mode 100644 index 0000000000..bdc1e417d0 --- /dev/null +++ b/tests/resources/network-wildcards/20-multi-container-mixed-wildcards.yaml @@ -0,0 +1,54 @@ +# Fixture 20 — multi-container pod with different rules per container +# +# Edge case: a single NetworkNeighborhood applies to a multi-container pod; +# each container has its own egress/ingress block; the verifier +# MUST scope matching by container ID (not pod ID) +# Expects: +# container "frontend" can hit *.example.com. but NOT 10.0.0.0/8; +# container "sidecar" can hit 10.0.0.0/8 but NOT *.example.com.; +# if the verifier conflates containers, both restrictions collapse to "either" +# and the test fails +# Match path: nn.* CEL functions resolve the ContainerProfile by containerID, +# so the matchers operate on the ALREADY-scoped Spec.Egress slice +# Spec ref: §4.2 container entry — each container is independently profiled +# +# This is also the most realistic deployment shape: a frontend that calls +# external APIs plus an in-cluster sidecar that talks to DBs/caches. +# +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: NetworkNeighborhood +metadata: + name: nw-20-multi-container + namespace: "{{NAMESPACE}}" + annotations: + sbob.io/spec-version: "0.0.2" +spec: + matchLabels: + app: nw-20 + containers: + - name: frontend + egress: + - identifier: external-api + type: external + dnsNames: + - "*.example.com." # leading-* RFC 4592 + - "api.partner.io." # literal + ports: + - {name: TCP-443, protocol: TCP, port: 443} + - name: sidecar + egress: + - identifier: in-cluster-services + type: internal + ipAddresses: + - "10.0.0.0/8" # cluster pod CIDR + - "172.16.0.0/12" # alt cluster service CIDR + ports: + - {name: TCP-6379, protocol: TCP, port: 6379} # redis + - {name: TCP-5432, protocol: TCP, port: 5432} # postgres + ingress: + - identifier: from-frontend + type: internal + ipAddresses: + - "10.244.0.0/16" # narrower — only the frontend pod's CIDR + ports: + - {name: TCP-9090, protocol: TCP, port: 9090} # sidecar metrics diff --git a/tests/resources/nginx-user-defined-deployment.yaml b/tests/resources/nginx-user-defined-deployment.yaml new file mode 100644 index 0000000000..c21c6b080f --- /dev/null +++ b/tests/resources/nginx-user-defined-deployment.yaml @@ -0,0 +1,25 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: curl-28 + name: curl-28 +spec: + selector: + matchLabels: + app: curl-28 + replicas: 1 + template: + metadata: + labels: + app: curl-28 + # Upstream ContainerProfileCache (kubescape/node-agent#788) reads ONE + # label and uses its value as the name of BOTH the user-defined + # ApplicationProfile and the user-defined NetworkNeighborhood. The + # AP and NN below MUST share the same name as this label value. + kubescape.io/user-defined-profile: curl-28-overlay + spec: + containers: + - name: curl + image: docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058 + command: ["sleep", "infinity"] diff --git a/tests/resources/nginx-user-profile-deployment.yaml b/tests/resources/nginx-user-profile-deployment.yaml new file mode 100644 index 0000000000..218f956540 --- /dev/null +++ b/tests/resources/nginx-user-profile-deployment.yaml @@ -0,0 +1,22 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: nginx + name: nginx-deployment +spec: + selector: + matchLabels: + app: nginx + replicas: 1 + template: + metadata: + labels: + app: nginx + kubescape.io/user-defined-profile: nginx-regex-profile + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80 diff --git a/tests/resources/user-profile.yaml b/tests/resources/user-profile.yaml deleted file mode 100644 index 97a116f6d2..0000000000 --- a/tests/resources/user-profile.yaml +++ /dev/null @@ -1,47 +0,0 @@ -apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 -kind: ApplicationProfile -metadata: - name: {name} - namespace: {namespace} - resourceVersion: "1" # Start with "1" for new resources - annotations: - kubescape.io/managed-by: User -spec: - architectures: ["amd64"] - containers: - - name: nginx - imageID: "" - imageTag: "" - capabilities: [] - opens: [] - syscalls: [] - endpoints: [] - execs: - - path: /usr/bin/ls - args: - - /usr/bin/ls - - -l - seccompProfile: - spec: - defaultAction: "" - - name: server - imageID: "" - imageTag: "" - capabilities: [] - opens: [] - syscalls: [] - endpoints: [] - execs: - - path: /bin/ls - args: - - /bin/ls - - -l - - path: /bin/grpc_health_probe - args: - - "-addr=:9555" - seccompProfile: - spec: - defaultAction: "" - initContainers: [] - ephemeralContainers: [] -status: {} \ No newline at end of file From b6f09cd922ce7942a2b5ab094b8aadfcc1d04124 Mon Sep 17 00:00:00 2001 From: entlein Date: Sat, 16 May 2026 13:31:03 +0200 Subject: [PATCH 2/4] align NA umbrella with rc1 final state (tamper test, projection apply, cache, opens/exec CEL) Signed-off-by: entlein --- pkg/ebpf/gadgets/randomx/program.bpf.c | 174 ++++++++++++- .../containerprofilecache.go | 15 +- .../tamper_alert_test.go | 70 ++++++ pkg/rulebindingmanager/cache/cache.go | 31 +-- pkg/rulebindingmanager/cache/cache_test.go | 39 +-- .../cel/libraries/applicationprofile/ap.go | 44 ++++ .../cel/libraries/applicationprofile/exec.go | 30 ++- .../applicationprofile/integration_test.go | 2 +- .../cel/libraries/applicationprofile/open.go | 47 ++++ tests/resources/network-wildcards/README.md | 80 ++++++ tests/resources/run-test-28.sh | 163 ++++++++++++ tests/resources/test-28-iterate.sh | 232 ++++++++++++++++++ 12 files changed, 865 insertions(+), 62 deletions(-) create mode 100644 tests/resources/network-wildcards/README.md create mode 100755 tests/resources/run-test-28.sh create mode 100755 tests/resources/test-28-iterate.sh diff --git a/pkg/ebpf/gadgets/randomx/program.bpf.c b/pkg/ebpf/gadgets/randomx/program.bpf.c index ed9cd5812b..46e7425e29 100644 --- a/pkg/ebpf/gadgets/randomx/program.bpf.c +++ b/pkg/ebpf/gadgets/randomx/program.bpf.c @@ -166,4 +166,176 @@ int tracepoint__x86_fpu_regs_deactivated(struct trace_event_raw_x86_fpu *ctx) char LICENSE[] SEC("license") = "GPL"; -#endif // defined(__TARGET_ARCH_x86) \ No newline at end of file +#endif // defined(__TARGET_ARCH_x86) + +/* // Kernel types definitions +#include + +// eBPF helpers signatures +// Check https://man7.org/linux/man-pages/man7/bpf-helpers.7.html to learn +// more about different available helpers +#include +#include + +// Inspektor Gadget buffer +#include +// Helpers to handle common data +#include +// Inspektor Gadget macros +#include +// Inspektor Gadget filtering +#include +// Inspektor Gadget types +#include +// Inspektor Gadget mntns +#include + +#include "program.h" +#include "upper_layer.h" +#include "exe_path.h" + +#if defined(__TARGET_ARCH_x86) + +#define TARGET_RANDOMX_EVENTS_COUNT 5 +// 5 seconds in nanoseconds +#define MAX_NS_BETWEEN_EVENTS 5000000000ULL + +// This struct will hold the state for each mount namespace +struct mntns_cache { + u64 timestamp; + u64 events_count; + bool alerted; +}; + +// A map to store the cache per mntns_id. +// key: mntns_id (u64), value: struct mntns_cache +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 1024); + __type(key, u64); + __type(value, struct mntns_cache); +} mntns_event_count SEC(".maps"); + +// events is the name of the buffer map and 1024 * 256 (256KB) is its size. +GADGET_TRACER_MAP(events, 1024 * 256); + +// Define a tracer +GADGET_TRACER(randomx, events, event); + +// Utilize the kernel version provided by libbpf. (kconfig must be present). +extern int LINUX_KERNEL_VERSION __kconfig; + +#if LINUX_KERNEL_VERSION <= KERNEL_VERSION(5, 15, 0) +struct old_fpu { + unsigned int last_cpu; + unsigned char initialized; + long: 24; + long: 64; + long: 64; + long: 64; + long: 64; + long: 64; + long: 64; + long: 64; + union fpregs_state state; +}; +#endif + +SEC("tracepoint/x86_fpu/x86_fpu_regs_deactivated") +int tracepoint__x86_fpu_regs_deactivated(struct trace_event_raw_x86_fpu *ctx) +{ + if (gadget_should_discard_data_current()) { + return 0; + } + + u64 mntns_id; + mntns_id = gadget_get_current_mntns_id(); + struct mntns_cache *cache; + cache = bpf_map_lookup_elem(&mntns_event_count, &mntns_id); + + u64 now = bpf_ktime_get_ns(); + + if (!cache) { + // First event for this mntns. Create a new entry. + struct mntns_cache new_cache = {}; + new_cache.timestamp = now; + new_cache.events_count = 1; + new_cache.alerted = false; + bpf_map_update_elem(&mntns_event_count, &mntns_id, &new_cache, BPF_ANY); + return 0; // Don't send an event yet + } + + // If we have already sent an alert for this mntns, do nothing. + if (cache->alerted) { + return 0; + } + + // Check if the last event was too long ago and reset if necessary. + if (now - cache->timestamp > MAX_NS_BETWEEN_EVENTS) { + cache->timestamp = now; + cache->events_count = 1; + bpf_map_update_elem(&mntns_event_count, &mntns_id, cache, BPF_ANY); + return 0; // Don't send an event yet + } + + // Increment the count. Using bpf_map_update_elem is not atomic, but for + // this use case (a single CPU tracepoint), it's safe. + cache->events_count++; + cache->timestamp = now; // Update timestamp with the latest event + + // Check if we have seen enough events + if (cache->events_count <= TARGET_RANDOMX_EVENTS_COUNT) { + // Not enough events yet, just update the map and exit. + bpf_map_update_elem(&mntns_event_count, &mntns_id, cache, BPF_ANY); + return 0; + } + + // --- Threshold has been reached! --- + // We only reach this point ONCE per mntns. + + // Mark as alerted to prevent sending more events for this mntns. + cache->alerted = true; + bpf_map_update_elem(&mntns_event_count, &mntns_id, cache, BPF_ANY); + + struct event *event; + event = gadget_reserve_buf(&events, sizeof(*event)); + if (!event) { + return 0; + } + + // Populate the event with data. This code is the same as before. + gadget_process_populate(&event->proc); + + void *fpu = BPF_CORE_READ(ctx, fpu); + if (fpu == NULL) { + gadget_discard_buf(event); + return 0; + } + + u32 mxcsr; + if(LINUX_KERNEL_VERSION <= KERNEL_VERSION(5, 15, 0)) { + bpf_probe_read_kernel(&mxcsr, sizeof(mxcsr), &((struct old_fpu*)fpu)->state.xsave.i387.mxcsr); + } else { + mxcsr = BPF_CORE_READ((struct fpu*)fpu, fpstate, regs.xsave.i387.mxcsr); + } + + int fpcr = (mxcsr & 0x6000) >> 13; + if (fpcr != 0) { + event->upper_layer = has_upper_layer(); + read_exe_path(event->exepath, sizeof(event->exepath)); + + event->timestamp_raw = bpf_ktime_get_boot_ns(); + + gadget_submit_buf(ctx, &events, event, sizeof(*event)); + } else { + gadget_discard_buf(event); + } + + return 0; +} + +char LICENSE[] SEC("license") = "GPL"; + +#endif // defined(__TARGET_ARCH_x86) + + */ \ No newline at end of file diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache.go b/pkg/objectcache/containerprofilecache/containerprofilecache.go index c539fad0e1..eeb401586c 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache.go @@ -409,8 +409,17 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // when a signed overlay's signature no longer matches (i.e. content // has been mutated post-sign). No-op when the overlay is unsigned or // the tamper-alert exporter has not been wired. + // CodeRabbit upstream PR #808 / containerprofilecache.go:414 (Major): + // when EnableSignatureVerification=true and the overlay fails + // verification, verifyUserApplicationProfile returns false. Drop the + // failed overlay before merging so a tampered profile does not + // silently project into the cache. In permissive mode the verifier + // always returns true and the overlay still merges (alert-only + // behaviour preserved). if userAP != nil { - c.verifyUserApplicationProfile(userAP, sharedData.Wlid) + if !c.verifyUserApplicationProfile(userAP, sharedData.Wlid) { + userAP = nil + } } var userNNErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { @@ -426,7 +435,9 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( userNN = nil } if userNN != nil { - c.verifyUserNetworkNeighborhood(userNN, sharedData.Wlid) + if !c.verifyUserNetworkNeighborhood(userNN, sharedData.Wlid) { + userNN = nil + } } } diff --git a/pkg/objectcache/containerprofilecache/tamper_alert_test.go b/pkg/objectcache/containerprofilecache/tamper_alert_test.go index 03fa7b0a88..c8af3ce9b4 100644 --- a/pkg/objectcache/containerprofilecache/tamper_alert_test.go +++ b/pkg/objectcache/containerprofilecache/tamper_alert_test.go @@ -17,6 +17,7 @@ import ( "sync" "testing" + "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" @@ -279,3 +280,72 @@ func TestVerifyAP_OperationalError_DoesNotEmit(t *testing.T) { t.Errorf("unsigned AP produced %d R1016 alerts; want 0", got) } } + +// TestVerifyAP_StrictMode_ReturnsFalseOnTamper pins CodeRabbit upstream +// PR #808 / containerprofilecache.go:414 (Major). The fix wires the +// verifyUserApplicationProfile boolean result into the caller so that +// in EnableSignatureVerification=true (strict) mode a tampered overlay +// is NOT merged into the projected profile. This unit-level test pins +// the verifier's strict-mode return contract; the call-site honors the +// return value (drop tampered overlay → userAP = nil). +func TestVerifyAP_StrictMode_ReturnsFalseOnTamper(t *testing.T) { + profile := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tampered-strict", + Namespace: "test-ns", + ResourceVersion: "1", + UID: "ap-uid-strict", + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{{Name: "test"}}, + }, + } + + adapter := profiles.NewApplicationProfileAdapter(profile) + if err := signature.SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("sign profile: %v", err) + } + profile.Spec.Containers[0].Name = "TAMPERED" + + // Strict mode: EnableSignatureVerification = true + c := &ContainerProfileCacheImpl{ + cfg: config.Config{EnableSignatureVerification: true}, + } + ok := c.verifyUserApplicationProfile(profile, "wlid://test/cluster/ns/Pod/p") + if ok { + t.Errorf("verify returned true on tampered profile in strict mode; expected false (caller drops overlay)") + } +} + +// TestVerifyNN_StrictMode_ReturnsFalseOnTamper — symmetric pin for the +// NetworkNeighborhood overlay verification path. +func TestVerifyNN_StrictMode_ReturnsFalseOnTamper(t *testing.T) { + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tampered-strict-nn", + Namespace: "test-ns", + ResourceVersion: "1", + UID: "nn-uid-strict", + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + Containers: []v1beta1.NetworkNeighborhoodContainer{{Name: "test"}}, + }, + } + + adapter := profiles.NewNetworkNeighborhoodAdapter(nn) + if err := signature.SignObjectDisableKeyless(adapter); err != nil { + t.Fatalf("sign nn: %v", err) + } + nn.Spec.Containers[0].Name = "TAMPERED" + + c := &ContainerProfileCacheImpl{ + cfg: config.Config{EnableSignatureVerification: true}, + } + ok := c.verifyUserNetworkNeighborhood(nn, "wlid://test/cluster/ns/Pod/p") + if ok { + t.Errorf("verify returned true on tampered nn in strict mode; expected false (caller drops overlay)") + } +} + +// cfgRef is a minimal config shim for the strict-mode tests. Mirrors the +// concrete config.Config struct shape only in the field the verifier reads. diff --git a/pkg/rulebindingmanager/cache/cache.go b/pkg/rulebindingmanager/cache/cache.go index 5572ea3403..80384c61b5 100644 --- a/pkg/rulebindingmanager/cache/cache.go +++ b/pkg/rulebindingmanager/cache/cache.go @@ -137,7 +137,7 @@ func (c *RBCache) AddHandler(ctx context.Context, obj runtime.Object) { c.mutex.Unlock() return } - rbs = c.addRuleBinding(ctx, ruleBinding) + rbs = c.addRuleBinding(ruleBinding) } notifiers := c.snapshotNotifiersLocked() c.mutex.Unlock() @@ -156,20 +156,20 @@ func (c *RBCache) ModifyHandler(ctx context.Context, obj runtime.Object) { c.mutex.Unlock() return } - rbs = c.modifiedRuleBinding(ctx, ruleBinding) + rbs = c.modifiedRuleBinding(ruleBinding) } notifiers := c.snapshotNotifiersLocked() c.mutex.Unlock() dispatchNonBlocking(notifiers, rbs, "ModifyHandler notify") } -func (c *RBCache) DeleteHandler(ctx context.Context, obj runtime.Object) { +func (c *RBCache) DeleteHandler(_ context.Context, obj runtime.Object) { c.mutex.Lock() var rbs []rulebindingmanager.RuleBindingNotify if pod, ok := obj.(*corev1.Pod); ok { c.deletePod(uniqueName(pod)) } else if un, ok := obj.(*unstructured.Unstructured); ok { - rbs = c.deleteRuleBinding(ctx, uniqueName(un)) + rbs = c.deleteRuleBinding(uniqueName(un)) } notifiers := c.snapshotNotifiersLocked() c.mutex.Unlock() @@ -234,11 +234,7 @@ func indexOfNotifier(notifiers []*chan rulebindingmanager.RuleBindingNotify, n * // ----------------- RuleBinding manager methods ----------------- // AddRuleBinding adds a rule binding to the cache -// addRuleBinding propagates ctx through the K8s List calls so the -// watcher can cancel in-flight work. CodeRabbit PR #43 cache.go:176 -// (Major): previously used context.Background() for the namespaces + -// pods list, which leaked goroutines past watch-context cancellation. -func (c *RBCache) addRuleBinding(ctx context.Context, ruleBinding *typesv1.RuntimeAlertRuleBinding) []rulebindingmanager.RuleBindingNotify { +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)) @@ -267,7 +263,7 @@ func (c *RBCache) addRuleBinding(ctx context.Context, ruleBinding *typesv1.Runti var namespaces *corev1.NamespaceList // if ruleBinding.GetNamespace() == "" { - namespaces, err = c.k8sClient.GetKubernetesClient().CoreV1().Namespaces().List(ctx, metav1.ListOptions{LabelSelector: nsSelectorStr}) + 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 @@ -282,7 +278,7 @@ func (c *RBCache) addRuleBinding(ctx context.Context, ruleBinding *typesv1.Runti LabelSelector: podSelectorStr, FieldSelector: "spec.nodeName=" + c.nodeName, } - pods, err := c.k8sClient.GetKubernetesClient().CoreV1().Pods(ns.GetName()).List(ctx, lp) + 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 @@ -309,12 +305,7 @@ func (c *RBCache) addRuleBinding(ctx context.Context, ruleBinding *typesv1.Runti } return rbs } -// deleteRuleBinding accepts ctx for parity with addRuleBinding (uniform -// handler signatures) and future-proofs against the helper growing K8s -// API calls. RuleBindingNotifierImplWithK8s currently uses an internal -// context; if it ever takes one, ctx is already threaded. -// CodeRabbit PR #43 cache.go:176. -func (c *RBCache) deleteRuleBinding(_ context.Context, uniqueName string) []rulebindingmanager.RuleBindingNotify { +func (c *RBCache) deleteRuleBinding(uniqueName string) []rulebindingmanager.RuleBindingNotify { logger.L().Info("RBCache - ruleBinding deleted", helpers.String("name", uniqueName)) var rbs []rulebindingmanager.RuleBindingNotify @@ -349,9 +340,9 @@ func (c *RBCache) deleteRuleBinding(_ context.Context, uniqueName string) []rule return rbs } -func (c *RBCache) modifiedRuleBinding(ctx context.Context, ruleBinding *typesv1.RuntimeAlertRuleBinding) []rulebindingmanager.RuleBindingNotify { - rbsD := c.deleteRuleBinding(ctx, uniqueName(ruleBinding)) - rbsA := c.addRuleBinding(ctx, ruleBinding) +func (c *RBCache) modifiedRuleBinding(ruleBinding *typesv1.RuntimeAlertRuleBinding) []rulebindingmanager.RuleBindingNotify { + rbsD := c.deleteRuleBinding(uniqueName(ruleBinding)) + rbsA := c.addRuleBinding(ruleBinding) return diff(rbsD, rbsA) } diff --git a/pkg/rulebindingmanager/cache/cache_test.go b/pkg/rulebindingmanager/cache/cache_test.go index fbcde80b8d..8db2bb5a3b 100644 --- a/pkg/rulebindingmanager/cache/cache_test.go +++ b/pkg/rulebindingmanager/cache/cache_test.go @@ -3,7 +3,6 @@ package cache import ( "context" "fmt" - "reflect" "slices" "sync" "testing" @@ -106,38 +105,6 @@ func TestRefreshRuleBindingsRules_NonBlockingFanout(t *testing.T) { require.Len(t, ch3, 1, "ch3 should have received the refresh pulse") } -// TestRBCacheHelpers_CtxFirstArg pins the contract from the CodeRabbit -// PR #43 review (cache.go:176, Major): the three RBCache helpers that -// AddHandler / ModifyHandler / DeleteHandler delegate to MUST accept a -// context.Context as their first argument so the watcher's cancellation -// signal propagates into K8s API List calls. A previous regression used -// `context.Background()` inside addRuleBinding, leaking goroutines past -// watch-context cancellation. Compile-time assignment to a typed -// function variable: if anyone removes ctx, this file no longer compiles. -func TestRBCacheHelpers_CtxFirstArg(t *testing.T) { - c := &RBCache{} - - // Compile-time guards: these assignments fail to compile if the - // signatures drift away from (ctx, ...). The reflect read is only - // to silence the unused-variable check. - var addFn func(context.Context, *typesv1.RuntimeAlertRuleBinding) []rulebindingmanager.RuleBindingNotify = c.addRuleBinding - var delFn func(context.Context, string) []rulebindingmanager.RuleBindingNotify = c.deleteRuleBinding - var modFn func(context.Context, *typesv1.RuntimeAlertRuleBinding) []rulebindingmanager.RuleBindingNotify = c.modifiedRuleBinding - - // Runtime sanity: function values are non-nil + first param is ctx. - require.NotNil(t, addFn, "addRuleBinding bound value should be non-nil") - require.NotNil(t, delFn, "deleteRuleBinding bound value should be non-nil") - require.NotNil(t, modFn, "modifiedRuleBinding bound value should be non-nil") - ctxType := reflect.TypeOf((*context.Context)(nil)).Elem() - for name, fn := range map[string]any{"addRuleBinding": addFn, "deleteRuleBinding": delFn, "modifiedRuleBinding": modFn} { - ft := reflect.TypeOf(fn) - require.GreaterOrEqualf(t, ft.NumIn(), 1, "%s must take at least one parameter (ctx)", name) - require.Truef(t, ft.In(0).Implements(ctxType) || ft.In(0) == ctxType, - "%s first param must be context.Context, got %s — ctx-propagation contract regressed (CodeRabbit PR #43 cache.go:176)", - name, ft.In(0).String()) - } -} - func TestRuntimeObjAddHandler(t *testing.T) { type rules struct { ruleID string @@ -277,7 +244,7 @@ func TestRuntimeObjAddHandler(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { for i := range tt.args.rb { - tt.args.c.addRuleBinding(context.Background(), &tt.args.rb[i]) + tt.args.c.addRuleBinding(&tt.args.rb[i]) } tt.args.c.addPod(context.Background(), tt.args.pod) r := tt.args.c.ListRulesForPod(tt.args.pod.GetNamespace(), tt.args.pod.GetName()) @@ -695,7 +662,7 @@ func TestDeleteRuleBinding(t *testing.T) { } - c.deleteRuleBinding(context.Background(), tt.uniqueName) + c.deleteRuleBinding(tt.uniqueName) assert.False(t, c.rbNameToPods.Has(tt.uniqueName)) assert.False(t, c.rbNameToRB.Has(tt.uniqueName)) @@ -1000,7 +967,7 @@ func TestAddRuleBinding(t *testing.T) { c := NewCacheMock("") c.k8sClient = k8sClient - c.addRuleBinding(context.Background(), tt.rb) + c.addRuleBinding(tt.rb) rbName := uniqueName(tt.rb) diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/ap.go b/pkg/rulemanager/cel/libraries/applicationprofile/ap.go index fabf311c2e..99ab118315 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/ap.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/ap.go @@ -111,6 +111,47 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { }), ), }, + "ap.was_path_opened_with_flags": { + cel.Overload( + "ap_was_path_opened_with_flags", []*cel.Type{cel.StringType, cel.StringType, cel.ListType(cel.StringType)}, cel.BoolType, + cel.FunctionBinding(func(values ...ref.Val) ref.Val { + if len(values) != 3 { + return types.NewErr("expected 3 arguments, got %d", len(values)) + } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_path_opened_with_flags") + } + wrapperFunc := func(args ...ref.Val) ref.Val { + return l.wasPathOpenedWithFlags(args[0], args[1], args[2]) + } + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_path_opened_with_flags", cache.HashForContainerProfile(l.objectCache)) + result := cachedFunc(values[0], values[1], values[2]) + return cache.ConvertProfileNotAvailableErrToBool(result, false) + }), + ), + }, + // ap.was_path_opened_with_suffix and ap.was_path_opened_with_prefix + // — rule-author contract (CodeRabbit upstream PR #807 finding #7): + // + // These helpers answer "did any RECORDED concrete path open match + // this suffix/prefix?". When the profile-projection cache is in + // pass-through mode (no rule declared an Opens-projection slice, + // so cp.Opens.All == true), wildcard patterns in cp.Opens.Patterns + // are NOT scanned via string-level HasSuffix/HasPrefix because the + // pattern text contains '*' / '⋯' tokens whose string shape doesn't + // safely answer suffix/prefix questions (see open.go comment). + // Concrete-only Values are scanned. + // + // False-negative gap: if a profile entry is `/var/log/pods/*/foo.log`, + // the runtime path `/var/log/pods/web-7d6f/volumes/foo.log` actually + // matches this pattern, but `was_path_opened_with_suffix("/foo.log")` + // returns FALSE because the pattern text doesn't end in `/foo.log` + // literally. Rule authors who need wildcard-aware coverage should + // either: (a) declare an Opens projection slice in the rule's + // ProfileDataRequired (then SuffixHits/PrefixHits become authoritative + // and the projector pre-computes the hit map for wildcard entries), + // or (b) use ap.was_path_opened(path) which DOES run dynamic-segment + // matching over Patterns via CompareDynamic. "ap.was_path_opened_with_suffix": { cel.Overload( "ap_was_path_opened_with_suffix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, @@ -335,6 +376,9 @@ func (e *apCostEstimator) EstimateCallCost(function, overloadID string, target * case "ap.was_path_opened": // Cache lookup + O(n) linear search + dynamic path comparison cost = 25 + case "ap.was_path_opened_with_flags": + // Cache lookup + O(n) search + dynamic path comparison + O(f*p) flag comparison + cost = 40 case "ap.was_path_opened_with_suffix": // Cache lookup + O(n) linear search + O(n*len(suffix)) string suffix checks cost = 20 diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go index 5f57369227..10e8d4c978 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go @@ -92,14 +92,40 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val // Exact path match: walk the profile's Args for that path via // CompareExecArgs (handles ⋯ single-arg and * zero-or-more tokens). + // + // ExecsByPath absent-vs-empty asymmetry — CodeRabbit upstream PR + // #807 finding #8. Three states to distinguish: + // + // 1. Path absent from cp.Execs.Values: + // Profile doesn't allow this exec at all → fall through to + // the pattern-match loop, then to false. + // + // 2. Path in Values, ABSENT from ExecsByPath (map lookup ok=false): + // Legacy / pre-args-projection profiles. Treated as + // "no argv constraint" — back-compat MATCH any args. + // This is the intentional fallback for profiles compiled + // against older storage versions that didn't populate the + // composite ExecsByPath surface. + // + // 3. Path in Values, PRESENT in ExecsByPath with an EMPTY arg + // list ([]): + // Profile explicitly captured "this path ran with no args". + // CompareExecArgs matches only when runtimeArgs is also + // empty. NOT a back-compat fallback — a deliberately tight + // constraint authored by the profile producer. + // + // The distinction matters for rule-author intuition: producing a + // signed profile that lists `{Path: /usr/bin/foo, Args: []}` is a + // CONSTRAINT, not a wildcard. Authors who want "any args" must + // omit the ExecsByPath entry (rare) or use an explicit `*` + // wildcard token in Args. if _, ok := cp.Execs.Values[pathStr]; ok { if profileArgs, ok := cp.ExecsByPath[pathStr]; ok { if dynamicpathdetector.CompareExecArgs(profileArgs, runtimeArgs) { return types.Bool(true) } } else { - // No ExecsByPath entry for this path — back-compat: treat as - // "no argv constraint", match. + // State 2: ExecsByPath absent → back-compat "no argv constraint". return types.Bool(true) } } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go index 46784e7b84..885ace3f4c 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go @@ -86,7 +86,7 @@ func TestIntegrationWithAllFunctions(t *testing.T) { }, { name: "Check file access pattern", - expression: `ap.was_path_opened(containerID, "/etc/passwd")`, + expression: `ap.was_path_opened_with_flags(containerID, "/etc/passwd", ["O_RDONLY"])`, expectedResult: true, }, { diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open.go b/pkg/rulemanager/cel/libraries/applicationprofile/open.go index fccf19a10d..62a4abedfa 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/open.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/open.go @@ -6,6 +6,7 @@ import ( "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/celparse" "github.com/kubescape/node-agent/pkg/rulemanager/profilehelper" "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" ) @@ -45,6 +46,52 @@ func (l *apLibrary) wasPathOpened(containerID, path ref.Val) ref.Val { return types.Bool(false) } +// wasPathOpenedWithFlags answers whether the projected ApplicationProfile +// contains an open-entry whose path matches the given path. The flags +// argument is parsed and validated for shape but is not used for matching +// in v1 — the OpenFlagsByPath projection slice is out of scope for v1 +// (composite-key projection would balloon the cache footprint). When the +// flags-projection slice is added in a future spec revision, this helper +// becomes the path-AND-flag matcher and v1 callers continue to work. +func (l *apLibrary) wasPathOpenedWithFlags(containerID, path, flags ref.Val) ref.Val { + if l.objectCache == nil { + return types.NewErr("objectCache is nil") + } + + containerIDStr, ok := containerID.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(containerID) + } + + pathStr, ok := path.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(path) + } + + // flags projection (OpenFlagsByPath) is out of scope for v1; degrade to path-only matching. + if _, err := celparse.ParseList[string](flags); err != nil { + return types.NewErr("failed to parse flags: %v", err) + } + + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) + if err != nil { + return cache.NewProfileNotAvailableErr("%v", err) + } + + for openPath := range cp.Opens.Values { + if dynamicpathdetector.CompareDynamic(openPath, pathStr) { + return types.Bool(true) + } + } + for _, openPath := range cp.Opens.Patterns { + if dynamicpathdetector.CompareDynamic(openPath, pathStr) { + return types.Bool(true) + } + } + + return types.Bool(false) +} + func (l *apLibrary) wasPathOpenedWithSuffix(containerID, suffix ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") diff --git a/tests/resources/network-wildcards/README.md b/tests/resources/network-wildcards/README.md new file mode 100644 index 0000000000..305e8f9140 --- /dev/null +++ b/tests/resources/network-wildcards/README.md @@ -0,0 +1,80 @@ +# Network-wildcards test fixtures + +Living documentation for the `feat/network-wildcards` work. + +Each `*.yaml` here is a complete `NetworkNeighborhood` document that exercises +ONE edge case in the v0.0.2 wildcard surface. The fixture-walk test +(`TestFixturesParse` + `TestFixturesMatchExpectedBehaviour` in +`pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go`, +plus the lab-side `Test_34_NetworkWildcardSurface`) consumes them +directly; users learning the syntax can copy-paste them as authoritative +examples. + +**Note on `14-recursive-star-rejected.yaml`:** this fixture is intentionally +**rejected at admission** — it carries `dnsNames: ["**"]` to demonstrate +that the recursive-wildcard token is invalid v0.0.2 syntax. Don't `kubectl +apply` it; the apiserver will return a 400. The runtime matcher also +defends by silently dropping it on read, so a broken admission layer +won't accidentally let it through. + +## Wildcard token vocabulary (matches paths + argv vocabulary) + +| Token | Meaning | +|---|---| +| `⋯` (U+22EF, MIDLINE HORIZONTAL ELLIPSIS — single Unicode codepoint, NOT three ASCII periods) | Exactly one segment / argv position / **DNS label** | +| `*` leading | RFC 4592 wildcard — exactly one DNS label before the suffix | +| `*` mid-path | NOT used in DNS — use `⋯` instead | +| `*` trailing | One or more labels after the prefix (never zero — closes the apex blind spot) | +| `*` as `ipAddresses[i]` | Sugar for `0.0.0.0/0` ∪ `::/0` (any IP) | + +## Field summary + +| Field on `NetworkNeighbor` | v0.0.2 status | Match form | +|---|---|---| +| `ipAddress` (string) | **deprecated** — kept for back-compat | byte-equality only | +| `ipAddresses` (list of strings) | **new** | each entry: literal IP / CIDR / `*` sentinel; matches if ANY entry matches | +| `dnsNames` (list of strings) | normative | each entry: literal / leading-`*` / mid-`⋯` / trailing-`*`; matches if ANY entry matches | +| `dns` (single string) | **deprecated** since v0.0.1 | byte-equality only | +| `ports[]` | normative | name + protocol + port (uint16, nullable per §5.4) | +| `podSelector`, `namespaceSelector` | schema-level (passed through to auto-generated NetworkPolicy) | NOT consulted by the runtime CEL matchers — see §4.7 caveat | + +## Fixture index + +| # | File | Edge case | +|---|------|-----------| +| 01 | `01-literal-ipv4.yaml` | Single IPv4 literal in `ipAddresses[]` | +| 02 | `02-literal-ipv6.yaml` | IPv6 literal — verifier MUST canonicalise | +| 03 | `03-cidr-ipv4.yaml` | IPv4 CIDR — `10.0.0.0/8` covers a /8 range | +| 04 | `04-cidr-ipv6.yaml` | IPv6 CIDR — `2001:db8::/32` | +| 05 | `05-any-ip-sentinel.yaml` | The `*` sentinel — discouraged outside dev | +| 06 | `06-any-as-cidr.yaml` | `0.0.0.0/0` + `::/0` (RFC-aligned alternatives to `*`) | +| 07 | `07-mixed-ip-list.yaml` | Mixed list: literal + CIDR + sentinel — first match wins | +| 08 | `08-deprecated-ipaddress.yaml` | Backward compat — singular `ipAddress` field | +| 09 | `09-dns-literal.yaml` | Plain DNS literal with trailing dot | +| 10 | `10-dns-leading-wildcard.yaml` | `*.example.com.` — RFC 4592, exactly ONE label | +| 11 | `11-dns-mid-ellipsis.yaml` | `svc.⋯.cluster.local.` — exactly ONE label between | +| 12 | `12-dns-trailing-star.yaml` | `mycorp.com.*` — ONE OR MORE labels (never zero) | +| 13 | `13-dns-trailing-dot-normalisation.yaml` | `example.com` and `example.com.` MUST be equivalent | +| 14 | `14-recursive-star-rejected.yaml` | `**` — MUST be rejected by apiserver write strategy | +| 15 | `15-egress-and-ingress.yaml` | Both directions populated on same container | +| 16 | `16-egress-none.yaml` | NONE (`egress: []`) — declared zero-egress | +| 17 | `17-realistic-stripe-api.yaml` | Realistic external API call (Stripe) | +| 18 | `18-cluster-dns-via-mid-ellipsis.yaml` | The user's `svc.⋯.kubernetes.io.` use case | +| 19 | `19-port-protocol-with-cidr.yaml` | Ports + protocol + CIDR composed | +| 20 | `20-multi-container-mixed-wildcards.yaml` | Pod with multiple containers, each with different rules — combined real-world example | + +## Expected behaviour matrix + +The accompanying `expectations.json` (generated alongside) lists, per fixture, +the `(observedIP, observedDNS) → expected match result` triples that +`Test_34_NetworkWildcardSurface` walks. + +## Migration note + +Producers writing v0.0.2-conformant SBoBs SHOULD use `ipAddresses` (plural). +The singular `ipAddress` is retained ONLY for back-compat with v0.0.1-era +profiles; producers MUST NOT populate both on the same entry (the apiserver +admission strategy rejects this). + +The deprecated `dns` (single string) field is retained for v0 compatibility; +v0.0.2 producers MUST emit `dnsNames` (list). diff --git a/tests/resources/run-test-28.sh b/tests/resources/run-test-28.sh new file mode 100755 index 0000000000..582aac83cd --- /dev/null +++ b/tests/resources/run-test-28.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# +# run-test-28.sh — Manual execution of Test_28_UserDefinedNetworkNeighborhood +# +# Applies user-defined NN, deploys curl, waits for AP to auto-learn, +# triggers allowed + unknown traffic, checks for alerts. +# +# Usage: +# ./run-test-28.sh # run the test +# ./run-test-28.sh learn # learn NN from scratch (debug) +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ALERTMANAGER_URL="${ALERTMANAGER_URL:-http://localhost:9093}" +SCENARIO="${1:-test}" + +# Ensure alertmanager port-forward is active. +if ! curl -s --max-time 2 "$ALERTMANAGER_URL/api/v2/alerts" >/dev/null 2>&1; then + echo "Alertmanager not reachable at $ALERTMANAGER_URL — starting port-forward..." + kubectl port-forward svc/alertmanager-operated 9093:9093 -n monitoring & + ALERT_PF_PID=$! + sleep 3 + if ! curl -s --max-time 2 "$ALERTMANAGER_URL/api/v2/alerts" >/dev/null 2>&1; then + echo "ERROR: alertmanager still not reachable after port-forward (pid=$ALERT_PF_PID)" + kill "$ALERT_PF_PID" 2>/dev/null || true + exit 1 + fi + trap 'kill $ALERT_PF_PID 2>/dev/null || true' EXIT +fi + +get_all_alerts() { + local ns="$1" + curl -s "$ALERTMANAGER_URL/api/v2/alerts" | \ + jq "[.[] | select(.labels.namespace==\"$ns\")]" +} + +wait_for_pod() { + local ns="$1" + kubectl rollout status deployment/curl-fusioncore-deployment -n "$ns" --timeout=120s +} + +get_pod() { + local ns="$1" + kubectl get pods -n "$ns" -l app=curl-fusioncore-28-1 \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null +} + +# ================================================================= +# Main test: apply NN manifest, deploy curl, trigger traffic, check alerts +# ================================================================= +run_test() { + local NS="t28-$(head -c4 /dev/urandom | xxd -p)" + local NET="fusioncore-network-$NS" + echo "" + echo "=== Test 28: User-Defined NN (ns=$NS, net=$NET) ===" + + kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - + + # 1. Apply NN manifest with unique name + sed -e "s/{{NAMESPACE}}/$NS/g" \ + -e "s/fusioncore-network/$NET/g" \ + "$SCRIPT_DIR/known-network-neighborhood.yaml" | kubectl apply -f - + echo " NN $NET created" + + # 2. Deploy curl with user-defined-network label + sed "s/{{NETWORK_NAME}}/$NET/g" \ + "$SCRIPT_DIR/curl-user-network-deployment.yaml" | kubectl apply -n "$NS" -f - + wait_for_pod "$NS" + local POD; POD=$(get_pod "$NS") + echo " Pod: $POD" + + # 3. Wait for AP to auto-learn + echo " Waiting for AP to complete..." + for i in $(seq 1 80); do + AP_STATUS=$(kubectl get applicationprofiles -n "$NS" \ + -o jsonpath='{.items[0].metadata.annotations.kubescape\.io/status}' 2>/dev/null || true) + [ "$AP_STATUS" = "completed" ] && break + sleep 10 + done + echo " AP status: $AP_STATUS" + + # 4. Trigger traffic + echo " Triggering traffic..." + echo " nslookup fusioncore.ai (allowed)" + kubectl exec -n "$NS" "$POD" -c curl -- nslookup fusioncore.ai 2>&1 || true + echo " curl fusioncore.ai (allowed)" + kubectl exec -n "$NS" "$POD" -c curl -- curl -sm2 http://fusioncore.ai >/dev/null 2>&1 || true + echo " nslookup evil.example.com (unknown)" + kubectl exec -n "$NS" "$POD" -c curl -- nslookup evil.example.com 2>&1 || true + echo " curl evil.example.com (unknown)" + kubectl exec -n "$NS" "$POD" -c curl -- curl -sm2 http://evil.example.com >/dev/null 2>&1 || true + + echo " Waiting 30s for alerts..." + sleep 30 + + # 5. Check alerts + echo "" + echo " === All alerts in namespace $NS ===" + ALERTS=$(get_all_alerts "$NS") + ALERT_COUNT=$(echo "$ALERTS" | jq 'length') + echo "$ALERTS" | jq -r '.[] | " [\(.labels.rule_name)] container=\(.labels.container_name // "n/a")"' + echo " Total: $ALERT_COUNT" + echo " ======================================" + + if [ "$ALERT_COUNT" -eq 0 ]; then + echo " FAIL: expected at least one alert (R0005 for evil.example.com), got ZERO" + echo " Namespace $NS left for inspection" + exit 1 + else + echo " PASS: got $ALERT_COUNT alert(s)" + echo " Cleanup: kubectl delete namespace $NS" + fi +} + +# ================================================================= +# Learn scenario: no user-defined labels, learn NN from scratch +# ================================================================= +run_learn() { + local NS="t28-learn-$(head -c4 /dev/urandom | xxd -p)" + echo "" + echo "=== LEARN NN from scratch (ns=$NS) ===" + + kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - + kubectl apply -n "$NS" -f "$SCRIPT_DIR/curl-plain-deployment.yaml" + wait_for_pod "$NS" + local POD; POD=$(kubectl get pods -n "$NS" -l app=curl-fusioncore-28-0 \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) + echo " Pod: $POD" + + echo " Triggering traffic during learning window..." + kubectl exec -n "$NS" "$POD" -c curl -- nslookup fusioncore.ai 2>&1 || true + kubectl exec -n "$NS" "$POD" -c curl -- curl -sm5 http://fusioncore.ai >/dev/null 2>&1 || true + sleep 5 + kubectl exec -n "$NS" "$POD" -c curl -- nslookup fusioncore.ai 2>&1 || true + kubectl exec -n "$NS" "$POD" -c curl -- curl -sm5 http://fusioncore.ai >/dev/null 2>&1 || true + + echo " Waiting for NN to complete..." + for i in $(seq 1 80); do + NN_STATUS=$(kubectl get networkneighborhoods -n "$NS" \ + -o jsonpath='{.items[0].metadata.annotations.kubescape\.io/status}' 2>/dev/null || true) + [ "$NN_STATUS" = "completed" ] && break + sleep 10 + done + echo " NN status: $NN_STATUS" + + echo "" + echo " === Learned NetworkNeighborhood ===" + kubectl get networkneighborhoods -n "$NS" -o yaml 2>&1 + echo " ===================================" + echo "" + echo " Namespace $NS left for inspection" + echo " Cleanup: kubectl delete namespace $NS" +} + +case "$SCENARIO" in + test) run_test ;; + learn) run_learn ;; + *) + echo "Usage: $0 [test|learn]" + exit 1 + ;; +esac diff --git a/tests/resources/test-28-iterate.sh b/tests/resources/test-28-iterate.sh new file mode 100755 index 0000000000..355fdf65ff --- /dev/null +++ b/tests/resources/test-28-iterate.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# +# test-28-iterate.sh — Self-contained test for user-defined NN alerts +# +# Prerequisites: +# - R0011 must be enabled with isTriggerAlert=true in the Rules CRD +# - R0005 should have isTriggerAlert=true for DNS alerts +# - Alertmanager port-forward active on localhost:9093 +# +# Usage: ./test-28-iterate.sh +# +set -euo pipefail + +ALERTMANAGER_URL="${ALERTMANAGER_URL:-http://localhost:9093}" +NA_POD=$(kubectl get pods -n kubescape -l app=node-agent -o jsonpath='{.items[0].metadata.name}') + +# Ensure alertmanager reachable +if ! curl -s --max-time 2 "$ALERTMANAGER_URL/api/v2/alerts" >/dev/null 2>&1; then + echo "ERROR: alertmanager not reachable at $ALERTMANAGER_URL" + exit 1 +fi + +get_alerts() { + local ns="$1" + curl -s "$ALERTMANAGER_URL/api/v2/alerts" | jq "[.[] | select(.labels.namespace==\"$ns\")]" +} + +cleanup_ns() { + kubectl delete namespace "$1" --wait=false 2>/dev/null || true +} + +# ================================================================ +# Ensure R0005 and R0011 are enabled with isTriggerAlert=true +# ================================================================ +echo "Patching rules: R0005 isTriggerAlert=true, R0011 enabled+isTriggerAlert=true" +kubectl get rules -n kubescape default-rules -o json | jq ' + .spec.rules = [ + .spec.rules[] | + if .id == "R0005" then .isTriggerAlert = true + elif .id == "R0011" then .enabled = true | .isTriggerAlert = true + else . + end + ] +' | kubectl apply -f - >/dev/null 2>&1 +echo " Done" + +# ================================================================ +# TEST: User-defined AP + NN → R0011 for anomalous TCP egress +# ================================================================ +NS="t28-$(head -c4 /dev/urandom | xxd -p)" +echo "" +echo "============================================================" +echo "TEST: User-defined AP + NN → R0011 Unexpected Egress Traffic" +echo " ns=$NS" +echo "============================================================" + +kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - + +# Create user-defined ApplicationProfile +cat <&1 \ + | grep -c "added user-defined network neighborhood" || true) + GOT_AP=$(kubectl logs "$NA_POD" -n kubescape -c node-agent --since=30s 2>&1 \ + | grep -c "added user-defined application profile\|user defined profile" || true) + echo " NN=$GOT_NN AP=$GOT_AP" + [ "$GOT_NN" -gt 0 ] && [ "$GOT_AP" -gt 0 ] && break + sleep 3 +done + +# Trigger anomalous TCP egress (R0011) +echo "" +echo " [anomaly] curl -sm5 http://8.8.8.8 (NOT in NN egress)" +kubectl exec -n "$NS" "$POD" -c nginx -- curl -sm5 http://8.8.8.8 2>&1 || true +echo " [anomaly] curl -sm5 http://1.1.1.1 (NOT in NN egress)" +kubectl exec -n "$NS" "$POD" -c nginx -- curl -sm5 http://1.1.1.1 2>&1 || true + +# Poll for alerts +echo "" +echo " Polling for alerts..." +R0011_ALERTS=0 +for i in 1 2 3 4; do + sleep 5 + ALERTS=$(get_alerts "$NS") + ALERT_COUNT=$(echo "$ALERTS" | jq 'length') + R0011_ALERTS=$(echo "$ALERTS" | jq '[.[] | select(.labels.rule_id=="R0011")] | length') + echo " poll $i: total=$ALERT_COUNT R0011=$R0011_ALERTS" + [ "$R0011_ALERTS" -gt 0 ] && break +done + +echo "" +echo " === All alerts in $NS ===" +echo "$ALERTS" | jq -r '.[] | " [\(.labels.rule_id)] \(.labels.rule_name) | comm=\(.labels.comm // "?")"' 2>/dev/null || true +echo " Total: $ALERT_COUNT R0011: $R0011_ALERTS" +echo " ========================" + +if [ "$R0011_ALERTS" -gt 0 ]; then + echo "" + echo " >>> RESULT: PASS — R0011 fires for user-defined AP+NN" + cleanup_ns "$NS" + exit 0 +else + echo "" + echo " >>> RESULT: FAIL — no R0011 alerts" + echo " >>> Namespace $NS left for inspection" + exit 1 +fi From fbb1d8cf9fe444687d71e1826aeb22a064e2f043 Mon Sep 17 00:00:00 2001 From: entlein Date: Sat, 16 May 2026 13:39:27 +0200 Subject: [PATCH 3/4] remove randomx program.bpf.c from umbrella scope Signed-off-by: entlein --- pkg/ebpf/gadgets/randomx/program.bpf.c | 174 +------------------------ 1 file changed, 1 insertion(+), 173 deletions(-) diff --git a/pkg/ebpf/gadgets/randomx/program.bpf.c b/pkg/ebpf/gadgets/randomx/program.bpf.c index 46e7425e29..ed9cd5812b 100644 --- a/pkg/ebpf/gadgets/randomx/program.bpf.c +++ b/pkg/ebpf/gadgets/randomx/program.bpf.c @@ -166,176 +166,4 @@ int tracepoint__x86_fpu_regs_deactivated(struct trace_event_raw_x86_fpu *ctx) char LICENSE[] SEC("license") = "GPL"; -#endif // defined(__TARGET_ARCH_x86) - -/* // Kernel types definitions -#include - -// eBPF helpers signatures -// Check https://man7.org/linux/man-pages/man7/bpf-helpers.7.html to learn -// more about different available helpers -#include -#include - -// Inspektor Gadget buffer -#include -// Helpers to handle common data -#include -// Inspektor Gadget macros -#include -// Inspektor Gadget filtering -#include -// Inspektor Gadget types -#include -// Inspektor Gadget mntns -#include - -#include "program.h" -#include "upper_layer.h" -#include "exe_path.h" - -#if defined(__TARGET_ARCH_x86) - -#define TARGET_RANDOMX_EVENTS_COUNT 5 -// 5 seconds in nanoseconds -#define MAX_NS_BETWEEN_EVENTS 5000000000ULL - -// This struct will hold the state for each mount namespace -struct mntns_cache { - u64 timestamp; - u64 events_count; - bool alerted; -}; - -// A map to store the cache per mntns_id. -// key: mntns_id (u64), value: struct mntns_cache -struct { - __uint(type, BPF_MAP_TYPE_LRU_HASH); - __uint(max_entries, 1024); - __type(key, u64); - __type(value, struct mntns_cache); -} mntns_event_count SEC(".maps"); - -// events is the name of the buffer map and 1024 * 256 (256KB) is its size. -GADGET_TRACER_MAP(events, 1024 * 256); - -// Define a tracer -GADGET_TRACER(randomx, events, event); - -// Utilize the kernel version provided by libbpf. (kconfig must be present). -extern int LINUX_KERNEL_VERSION __kconfig; - -#if LINUX_KERNEL_VERSION <= KERNEL_VERSION(5, 15, 0) -struct old_fpu { - unsigned int last_cpu; - unsigned char initialized; - long: 24; - long: 64; - long: 64; - long: 64; - long: 64; - long: 64; - long: 64; - long: 64; - union fpregs_state state; -}; -#endif - -SEC("tracepoint/x86_fpu/x86_fpu_regs_deactivated") -int tracepoint__x86_fpu_regs_deactivated(struct trace_event_raw_x86_fpu *ctx) -{ - if (gadget_should_discard_data_current()) { - return 0; - } - - u64 mntns_id; - mntns_id = gadget_get_current_mntns_id(); - struct mntns_cache *cache; - cache = bpf_map_lookup_elem(&mntns_event_count, &mntns_id); - - u64 now = bpf_ktime_get_ns(); - - if (!cache) { - // First event for this mntns. Create a new entry. - struct mntns_cache new_cache = {}; - new_cache.timestamp = now; - new_cache.events_count = 1; - new_cache.alerted = false; - bpf_map_update_elem(&mntns_event_count, &mntns_id, &new_cache, BPF_ANY); - return 0; // Don't send an event yet - } - - // If we have already sent an alert for this mntns, do nothing. - if (cache->alerted) { - return 0; - } - - // Check if the last event was too long ago and reset if necessary. - if (now - cache->timestamp > MAX_NS_BETWEEN_EVENTS) { - cache->timestamp = now; - cache->events_count = 1; - bpf_map_update_elem(&mntns_event_count, &mntns_id, cache, BPF_ANY); - return 0; // Don't send an event yet - } - - // Increment the count. Using bpf_map_update_elem is not atomic, but for - // this use case (a single CPU tracepoint), it's safe. - cache->events_count++; - cache->timestamp = now; // Update timestamp with the latest event - - // Check if we have seen enough events - if (cache->events_count <= TARGET_RANDOMX_EVENTS_COUNT) { - // Not enough events yet, just update the map and exit. - bpf_map_update_elem(&mntns_event_count, &mntns_id, cache, BPF_ANY); - return 0; - } - - // --- Threshold has been reached! --- - // We only reach this point ONCE per mntns. - - // Mark as alerted to prevent sending more events for this mntns. - cache->alerted = true; - bpf_map_update_elem(&mntns_event_count, &mntns_id, cache, BPF_ANY); - - struct event *event; - event = gadget_reserve_buf(&events, sizeof(*event)); - if (!event) { - return 0; - } - - // Populate the event with data. This code is the same as before. - gadget_process_populate(&event->proc); - - void *fpu = BPF_CORE_READ(ctx, fpu); - if (fpu == NULL) { - gadget_discard_buf(event); - return 0; - } - - u32 mxcsr; - if(LINUX_KERNEL_VERSION <= KERNEL_VERSION(5, 15, 0)) { - bpf_probe_read_kernel(&mxcsr, sizeof(mxcsr), &((struct old_fpu*)fpu)->state.xsave.i387.mxcsr); - } else { - mxcsr = BPF_CORE_READ((struct fpu*)fpu, fpstate, regs.xsave.i387.mxcsr); - } - - int fpcr = (mxcsr & 0x6000) >> 13; - if (fpcr != 0) { - event->upper_layer = has_upper_layer(); - read_exe_path(event->exepath, sizeof(event->exepath)); - - event->timestamp_raw = bpf_ktime_get_boot_ns(); - - gadget_submit_buf(ctx, &events, event, sizeof(*event)); - } else { - gadget_discard_buf(event); - } - - return 0; -} - -char LICENSE[] SEC("license") = "GPL"; - -#endif // defined(__TARGET_ARCH_x86) - - */ \ No newline at end of file +#endif // defined(__TARGET_ARCH_x86) \ No newline at end of file From 90cfaaa8b5f577b233adc32bdc55130d44ec42d8 Mon Sep 17 00:00:00 2001 From: entlein Date: Sat, 16 May 2026 13:40:38 +0200 Subject: [PATCH 4/4] umbrella: drop fork-only Signed-off-by: entlein --- tests/resources/run-test-28.sh | 163 -------------------- tests/resources/test-28-iterate.sh | 232 ----------------------------- 2 files changed, 395 deletions(-) delete mode 100755 tests/resources/run-test-28.sh delete mode 100755 tests/resources/test-28-iterate.sh diff --git a/tests/resources/run-test-28.sh b/tests/resources/run-test-28.sh deleted file mode 100755 index 582aac83cd..0000000000 --- a/tests/resources/run-test-28.sh +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env bash -# -# run-test-28.sh — Manual execution of Test_28_UserDefinedNetworkNeighborhood -# -# Applies user-defined NN, deploys curl, waits for AP to auto-learn, -# triggers allowed + unknown traffic, checks for alerts. -# -# Usage: -# ./run-test-28.sh # run the test -# ./run-test-28.sh learn # learn NN from scratch (debug) -# -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ALERTMANAGER_URL="${ALERTMANAGER_URL:-http://localhost:9093}" -SCENARIO="${1:-test}" - -# Ensure alertmanager port-forward is active. -if ! curl -s --max-time 2 "$ALERTMANAGER_URL/api/v2/alerts" >/dev/null 2>&1; then - echo "Alertmanager not reachable at $ALERTMANAGER_URL — starting port-forward..." - kubectl port-forward svc/alertmanager-operated 9093:9093 -n monitoring & - ALERT_PF_PID=$! - sleep 3 - if ! curl -s --max-time 2 "$ALERTMANAGER_URL/api/v2/alerts" >/dev/null 2>&1; then - echo "ERROR: alertmanager still not reachable after port-forward (pid=$ALERT_PF_PID)" - kill "$ALERT_PF_PID" 2>/dev/null || true - exit 1 - fi - trap 'kill $ALERT_PF_PID 2>/dev/null || true' EXIT -fi - -get_all_alerts() { - local ns="$1" - curl -s "$ALERTMANAGER_URL/api/v2/alerts" | \ - jq "[.[] | select(.labels.namespace==\"$ns\")]" -} - -wait_for_pod() { - local ns="$1" - kubectl rollout status deployment/curl-fusioncore-deployment -n "$ns" --timeout=120s -} - -get_pod() { - local ns="$1" - kubectl get pods -n "$ns" -l app=curl-fusioncore-28-1 \ - -o jsonpath='{.items[0].metadata.name}' 2>/dev/null -} - -# ================================================================= -# Main test: apply NN manifest, deploy curl, trigger traffic, check alerts -# ================================================================= -run_test() { - local NS="t28-$(head -c4 /dev/urandom | xxd -p)" - local NET="fusioncore-network-$NS" - echo "" - echo "=== Test 28: User-Defined NN (ns=$NS, net=$NET) ===" - - kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - - - # 1. Apply NN manifest with unique name - sed -e "s/{{NAMESPACE}}/$NS/g" \ - -e "s/fusioncore-network/$NET/g" \ - "$SCRIPT_DIR/known-network-neighborhood.yaml" | kubectl apply -f - - echo " NN $NET created" - - # 2. Deploy curl with user-defined-network label - sed "s/{{NETWORK_NAME}}/$NET/g" \ - "$SCRIPT_DIR/curl-user-network-deployment.yaml" | kubectl apply -n "$NS" -f - - wait_for_pod "$NS" - local POD; POD=$(get_pod "$NS") - echo " Pod: $POD" - - # 3. Wait for AP to auto-learn - echo " Waiting for AP to complete..." - for i in $(seq 1 80); do - AP_STATUS=$(kubectl get applicationprofiles -n "$NS" \ - -o jsonpath='{.items[0].metadata.annotations.kubescape\.io/status}' 2>/dev/null || true) - [ "$AP_STATUS" = "completed" ] && break - sleep 10 - done - echo " AP status: $AP_STATUS" - - # 4. Trigger traffic - echo " Triggering traffic..." - echo " nslookup fusioncore.ai (allowed)" - kubectl exec -n "$NS" "$POD" -c curl -- nslookup fusioncore.ai 2>&1 || true - echo " curl fusioncore.ai (allowed)" - kubectl exec -n "$NS" "$POD" -c curl -- curl -sm2 http://fusioncore.ai >/dev/null 2>&1 || true - echo " nslookup evil.example.com (unknown)" - kubectl exec -n "$NS" "$POD" -c curl -- nslookup evil.example.com 2>&1 || true - echo " curl evil.example.com (unknown)" - kubectl exec -n "$NS" "$POD" -c curl -- curl -sm2 http://evil.example.com >/dev/null 2>&1 || true - - echo " Waiting 30s for alerts..." - sleep 30 - - # 5. Check alerts - echo "" - echo " === All alerts in namespace $NS ===" - ALERTS=$(get_all_alerts "$NS") - ALERT_COUNT=$(echo "$ALERTS" | jq 'length') - echo "$ALERTS" | jq -r '.[] | " [\(.labels.rule_name)] container=\(.labels.container_name // "n/a")"' - echo " Total: $ALERT_COUNT" - echo " ======================================" - - if [ "$ALERT_COUNT" -eq 0 ]; then - echo " FAIL: expected at least one alert (R0005 for evil.example.com), got ZERO" - echo " Namespace $NS left for inspection" - exit 1 - else - echo " PASS: got $ALERT_COUNT alert(s)" - echo " Cleanup: kubectl delete namespace $NS" - fi -} - -# ================================================================= -# Learn scenario: no user-defined labels, learn NN from scratch -# ================================================================= -run_learn() { - local NS="t28-learn-$(head -c4 /dev/urandom | xxd -p)" - echo "" - echo "=== LEARN NN from scratch (ns=$NS) ===" - - kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - - kubectl apply -n "$NS" -f "$SCRIPT_DIR/curl-plain-deployment.yaml" - wait_for_pod "$NS" - local POD; POD=$(kubectl get pods -n "$NS" -l app=curl-fusioncore-28-0 \ - -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) - echo " Pod: $POD" - - echo " Triggering traffic during learning window..." - kubectl exec -n "$NS" "$POD" -c curl -- nslookup fusioncore.ai 2>&1 || true - kubectl exec -n "$NS" "$POD" -c curl -- curl -sm5 http://fusioncore.ai >/dev/null 2>&1 || true - sleep 5 - kubectl exec -n "$NS" "$POD" -c curl -- nslookup fusioncore.ai 2>&1 || true - kubectl exec -n "$NS" "$POD" -c curl -- curl -sm5 http://fusioncore.ai >/dev/null 2>&1 || true - - echo " Waiting for NN to complete..." - for i in $(seq 1 80); do - NN_STATUS=$(kubectl get networkneighborhoods -n "$NS" \ - -o jsonpath='{.items[0].metadata.annotations.kubescape\.io/status}' 2>/dev/null || true) - [ "$NN_STATUS" = "completed" ] && break - sleep 10 - done - echo " NN status: $NN_STATUS" - - echo "" - echo " === Learned NetworkNeighborhood ===" - kubectl get networkneighborhoods -n "$NS" -o yaml 2>&1 - echo " ===================================" - echo "" - echo " Namespace $NS left for inspection" - echo " Cleanup: kubectl delete namespace $NS" -} - -case "$SCENARIO" in - test) run_test ;; - learn) run_learn ;; - *) - echo "Usage: $0 [test|learn]" - exit 1 - ;; -esac diff --git a/tests/resources/test-28-iterate.sh b/tests/resources/test-28-iterate.sh deleted file mode 100755 index 355fdf65ff..0000000000 --- a/tests/resources/test-28-iterate.sh +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env bash -# -# test-28-iterate.sh — Self-contained test for user-defined NN alerts -# -# Prerequisites: -# - R0011 must be enabled with isTriggerAlert=true in the Rules CRD -# - R0005 should have isTriggerAlert=true for DNS alerts -# - Alertmanager port-forward active on localhost:9093 -# -# Usage: ./test-28-iterate.sh -# -set -euo pipefail - -ALERTMANAGER_URL="${ALERTMANAGER_URL:-http://localhost:9093}" -NA_POD=$(kubectl get pods -n kubescape -l app=node-agent -o jsonpath='{.items[0].metadata.name}') - -# Ensure alertmanager reachable -if ! curl -s --max-time 2 "$ALERTMANAGER_URL/api/v2/alerts" >/dev/null 2>&1; then - echo "ERROR: alertmanager not reachable at $ALERTMANAGER_URL" - exit 1 -fi - -get_alerts() { - local ns="$1" - curl -s "$ALERTMANAGER_URL/api/v2/alerts" | jq "[.[] | select(.labels.namespace==\"$ns\")]" -} - -cleanup_ns() { - kubectl delete namespace "$1" --wait=false 2>/dev/null || true -} - -# ================================================================ -# Ensure R0005 and R0011 are enabled with isTriggerAlert=true -# ================================================================ -echo "Patching rules: R0005 isTriggerAlert=true, R0011 enabled+isTriggerAlert=true" -kubectl get rules -n kubescape default-rules -o json | jq ' - .spec.rules = [ - .spec.rules[] | - if .id == "R0005" then .isTriggerAlert = true - elif .id == "R0011" then .enabled = true | .isTriggerAlert = true - else . - end - ] -' | kubectl apply -f - >/dev/null 2>&1 -echo " Done" - -# ================================================================ -# TEST: User-defined AP + NN → R0011 for anomalous TCP egress -# ================================================================ -NS="t28-$(head -c4 /dev/urandom | xxd -p)" -echo "" -echo "============================================================" -echo "TEST: User-defined AP + NN → R0011 Unexpected Egress Traffic" -echo " ns=$NS" -echo "============================================================" - -kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - - -# Create user-defined ApplicationProfile -cat <&1 \ - | grep -c "added user-defined network neighborhood" || true) - GOT_AP=$(kubectl logs "$NA_POD" -n kubescape -c node-agent --since=30s 2>&1 \ - | grep -c "added user-defined application profile\|user defined profile" || true) - echo " NN=$GOT_NN AP=$GOT_AP" - [ "$GOT_NN" -gt 0 ] && [ "$GOT_AP" -gt 0 ] && break - sleep 3 -done - -# Trigger anomalous TCP egress (R0011) -echo "" -echo " [anomaly] curl -sm5 http://8.8.8.8 (NOT in NN egress)" -kubectl exec -n "$NS" "$POD" -c nginx -- curl -sm5 http://8.8.8.8 2>&1 || true -echo " [anomaly] curl -sm5 http://1.1.1.1 (NOT in NN egress)" -kubectl exec -n "$NS" "$POD" -c nginx -- curl -sm5 http://1.1.1.1 2>&1 || true - -# Poll for alerts -echo "" -echo " Polling for alerts..." -R0011_ALERTS=0 -for i in 1 2 3 4; do - sleep 5 - ALERTS=$(get_alerts "$NS") - ALERT_COUNT=$(echo "$ALERTS" | jq 'length') - R0011_ALERTS=$(echo "$ALERTS" | jq '[.[] | select(.labels.rule_id=="R0011")] | length') - echo " poll $i: total=$ALERT_COUNT R0011=$R0011_ALERTS" - [ "$R0011_ALERTS" -gt 0 ] && break -done - -echo "" -echo " === All alerts in $NS ===" -echo "$ALERTS" | jq -r '.[] | " [\(.labels.rule_id)] \(.labels.rule_name) | comm=\(.labels.comm // "?")"' 2>/dev/null || true -echo " Total: $ALERT_COUNT R0011: $R0011_ALERTS" -echo " ========================" - -if [ "$R0011_ALERTS" -gt 0 ]; then - echo "" - echo " >>> RESULT: PASS — R0011 fires for user-defined AP+NN" - cleanup_ns "$NS" - exit 0 -else - echo "" - echo " >>> RESULT: FAIL — no R0011 alerts" - echo " >>> Namespace $NS left for inspection" - exit 1 -fi