diff --git a/instanceidhandler/v1/containerinstance/initializers.go b/instanceidhandler/v1/containerinstance/initializers.go index 0a78cd0..09c6efe 100644 --- a/instanceidhandler/v1/containerinstance/initializers.go +++ b/instanceidhandler/v1/containerinstance/initializers.go @@ -2,16 +2,23 @@ package containerinstance import ( "fmt" + "strings" + + "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" ) -// GenerateInstanceIDFromString generates instance ID from string +// GenerateInstanceIDFromString generates instance ID from string. // The string format is: apiVersion-/namespace-/kind-/name-/containerName- +// +// For Job workloads (Kind == "Job"), this function uses a best-effort heuristic to recover AlternateName +// and TemplateHash when the Job name ends in a suffix produced by the CronJob pod template hasher (rand.SafeEncodeString). +// Because the serialized string format does not preserve provenance metadata, standalone literal Jobs whose names +// happen to end in matching suffixes (such as "backup-db" or "backup-4") will also have those suffixes parsed as +// template hashes. func GenerateInstanceIDFromString(input string) (*InstanceID, error) { instanceID := &InstanceID{} - // TODO add case for CronJobs here, or deprecate - if fields := RegexFormatted.FindStringSubmatch(input); fields != nil { instanceID.ApiVersion = fields[1] instanceID.Namespace = fields[2] @@ -28,6 +35,22 @@ func GenerateInstanceIDFromString(input string) (*InstanceID, error) { return nil, fmt.Errorf("invalid format: %s", input) } + // Contextual recovery contract: + // For Job instance IDs (which may represent Pods owned by CronJob child Jobs where GetStringFormatted() + // serialized AlternateName into the name field), infer AlternateName and TemplateHash if the name suffix + // matches the producer's template hash domain. + // Ordinary CronJob workloads (Kind == "CronJob") and standard Jobs without a valid template hash suffix + // remain literal with empty AlternateName and TemplateHash. + // As documented above, standalone literal Jobs whose names end in a matching producer suffix (e.g. "backup-db" + // or "backup-4") are inferred as template hashes under this best-effort tradeoff. + if instanceID.Kind == "Job" { + s := strings.Split(instanceID.Name, "-") + if len(s) > 1 && helpers.IsTemplateHash(s[len(s)-1]) { + instanceID.AlternateName = instanceID.Name + instanceID.TemplateHash = s[len(s)-1] + } + } + if err := validateInstanceID(instanceID); err != nil { return nil, err } diff --git a/instanceidhandler/v1/containerinstance/initializers_test.go b/instanceidhandler/v1/containerinstance/initializers_test.go index 742e848..460cbf3 100644 --- a/instanceidhandler/v1/containerinstance/initializers_test.go +++ b/instanceidhandler/v1/containerinstance/initializers_test.go @@ -71,7 +71,7 @@ func TestGenerateInstanceIDFromString(t *testing.T) { wantErr: false, }, { - name: "valid input - CronJob", + name: "valid input - CronJob child Job", args: args{ input: "apiVersion-batch/v1/namespace-kubescape/kind-Job/name-kubevuln-scheduler-b449cf78f/containerName-kubevuln-scheduler", }, @@ -79,10 +79,197 @@ func TestGenerateInstanceIDFromString(t *testing.T) { ApiVersion: "batch/v1", Namespace: "kubescape", Kind: "Job", - Name: "kubevuln-scheduler-b449cf78f", // should be kubevuln-scheduler-28677846 + Name: "kubevuln-scheduler-b449cf78f", ContainerName: "kubevuln-scheduler", InstanceType: container, - AlternateName: "", // should be kubevuln-scheduler-b449cf78f + AlternateName: "kubevuln-scheduler-b449cf78f", + TemplateHash: "b449cf78f", + }, + wantErr: false, + }, + { + name: "valid input - CronJob child Job, no container", + args: args{ + input: "apiVersion-batch/v1/namespace-kubescape/kind-Job/name-kubevuln-scheduler-b449cf78f", + }, + want: &InstanceID{ + ApiVersion: "batch/v1", + Namespace: "kubescape", + Kind: "Job", + Name: "kubevuln-scheduler-b449cf78f", + AlternateName: "kubevuln-scheduler-b449cf78f", + TemplateHash: "b449cf78f", + }, + wantErr: false, + }, + { + name: "valid input - CronJob workload", + args: args{ + input: "apiVersion-batch/v1/namespace-kubescape/kind-CronJob/name-kubevuln-scheduler/containerName-kubevuln-scheduler", + }, + want: &InstanceID{ + ApiVersion: "batch/v1", + Namespace: "kubescape", + Kind: "CronJob", + Name: "kubevuln-scheduler", + ContainerName: "kubevuln-scheduler", + InstanceType: container, + }, + wantErr: false, + }, + { + name: "valid input - CronJob workload, no container", + args: args{ + input: "apiVersion-batch/v1/namespace-kubescape/kind-CronJob/name-kubevuln-scheduler", + }, + want: &InstanceID{ + ApiVersion: "batch/v1", + Namespace: "kubescape", + Kind: "CronJob", + Name: "kubevuln-scheduler", + }, + wantErr: false, + }, + { + name: "valid input - CronJob child Job with numeric template hash", + args: args{ + input: "apiVersion-batch/v1/namespace-default/kind-Job/name-backup-8698448884/containerName-backup", + }, + want: &InstanceID{ + ApiVersion: "batch/v1", + Namespace: "default", + Kind: "Job", + Name: "backup-8698448884", + ContainerName: "backup", + InstanceType: container, + AlternateName: "backup-8698448884", + TemplateHash: "8698448884", + }, + wantErr: false, + }, + { + name: "valid input - CronJob child Job with short template hash", + args: args{ + input: "apiVersion-batch/v1/namespace-default/kind-Job/name-backup-5678/containerName-backup", + }, + want: &InstanceID{ + ApiVersion: "batch/v1", + Namespace: "default", + Kind: "Job", + Name: "backup-5678", + ContainerName: "backup", + InstanceType: container, + AlternateName: "backup-5678", + TemplateHash: "5678", + }, + wantErr: false, + }, + { + name: "valid input - ordinary CronJob named backup-xxxxxxxx preserves literal name", + args: args{ + input: "apiVersion-batch/v1/namespace-default/kind-CronJob/name-backup-xxxxxxxx/containerName-backup", + }, + want: &InstanceID{ + ApiVersion: "batch/v1", + Namespace: "default", + Kind: "CronJob", + Name: "backup-xxxxxxxx", + ContainerName: "backup", + InstanceType: container, + }, + wantErr: false, + }, + { + name: "valid input - ordinary CronJob named backup-8698448884 preserves literal name", + args: args{ + input: "apiVersion-batch/v1/namespace-default/kind-CronJob/name-backup-8698448884/containerName-backup", + }, + want: &InstanceID{ + ApiVersion: "batch/v1", + Namespace: "default", + Kind: "CronJob", + Name: "backup-8698448884", + ContainerName: "backup", + InstanceType: container, + }, + wantErr: false, + }, + { + name: "valid input - standard Job with timestamp suffix preserves literal name", + args: args{ + input: "apiVersion-batch/v1/namespace-default/kind-Job/name-backup-28677846/containerName-backup", + }, + want: &InstanceID{ + ApiVersion: "batch/v1", + Namespace: "default", + Kind: "Job", + Name: "backup-28677846", + ContainerName: "backup", + InstanceType: container, + }, + wantErr: false, + }, + { + name: "valid input - standard Job named backup-xxxxxxxx preserves literal name", + args: args{ + input: "apiVersion-batch/v1/namespace-default/kind-Job/name-backup-xxxxxxxx/containerName-backup", + }, + want: &InstanceID{ + ApiVersion: "batch/v1", + Namespace: "default", + Kind: "Job", + Name: "backup-xxxxxxxx", + ContainerName: "backup", + InstanceType: container, + }, + wantErr: false, + }, + { + name: "valid input - literal Job backup-db parsed as template hash acknowledging tradeoff", + args: args{ + input: "apiVersion-batch/v1/namespace-default/kind-Job/name-backup-db/containerName-backup", + }, + want: &InstanceID{ + ApiVersion: "batch/v1", + Namespace: "default", + Kind: "Job", + Name: "backup-db", + ContainerName: "backup", + InstanceType: container, + AlternateName: "backup-db", + TemplateHash: "db", + }, + wantErr: false, + }, + { + name: "valid input - literal Job backup-4 parsed as template hash acknowledging tradeoff", + args: args{ + input: "apiVersion-batch/v1/namespace-default/kind-Job/name-backup-4/containerName-backup", + }, + want: &InstanceID{ + ApiVersion: "batch/v1", + Namespace: "default", + Kind: "Job", + Name: "backup-4", + ContainerName: "backup", + InstanceType: container, + AlternateName: "backup-4", + TemplateHash: "4", + }, + wantErr: false, + }, + { + name: "valid input - standard Job without hash", + args: args{ + input: "apiVersion-batch/v1/namespace-default/kind-Job/name-nginx-job/containerName-nginx-job", + }, + want: &InstanceID{ + ApiVersion: "batch/v1", + Namespace: "default", + Kind: "Job", + Name: "nginx-job", + ContainerName: "nginx-job", + InstanceType: container, }, wantErr: false, }, diff --git a/instanceidhandler/v1/helpers/keys.go b/instanceidhandler/v1/helpers/keys.go index 50785cb..fb2808e 100644 --- a/instanceidhandler/v1/helpers/keys.go +++ b/instanceidhandler/v1/helpers/keys.go @@ -1,6 +1,12 @@ package helpers -import "github.com/kubescape/k8s-interface/k8sinterface" +import ( + "math" + "regexp" + "strconv" + + "github.com/kubescape/k8s-interface/k8sinterface" +) type InstanceType string @@ -117,3 +123,72 @@ func IgnoreOwnerReference(ownerKind string) bool { } return false } + +// TemplateHashRegex matches the output domain of rand.SafeEncodeString(fmt.Sprint(podTemplateSpecHasher.Sum32())). +// In k8s.io/apimachinery/pkg/util/rand, SafeEncodeString encodes decimal digits '0'-'9' using alphanums = "bcdfghjklmnpqrstvwxz2456789". +// Because (int('0')+d) % 27 maps decimal digits 0..9 strictly to '4','5','6','7','8','9','b','c','d','f', +// the producer domain consists exclusively of characters [4-9bcdf], with length 1 to 10 (decimal representation of uint32). +var TemplateHashRegex = regexp.MustCompile(`^[4-9bcdf]{1,10}$`) + +// IsUnixTimeInMinutes checks if a string represents a Unix timestamp in minutes. +// In Kubernetes, CronJob controller appends the schedule timestamp (minutes since epoch) to the child Job name. +func IsUnixTimeInMinutes(s string) bool { + if i, err := strconv.Atoi(s); err == nil { + return i > 0 && i < math.MaxInt64/60 + } + return false +} + +// IsTemplateHash returns true if s matches the template hash domain produced by +// rand.SafeEncodeString(fmt.Sprint(podTemplateSpecHasher.Sum32())). +// +// Best-Effort Contextual Recovery & Tradeoff Contract: +// In Kubernetes, CronJob child Jobs are originally named "-" where timestamp +// is the scheduled Unix time in minutes (e.g. "backup-28677846"). When Kubescape generates an InstanceID +// for a Pod under such a Job, it computes a template hash via SafeEncodeString and sets AlternateName +// to "-" (e.g. "backup-8698448884" or "kubevuln-scheduler-b449cf78f"). +// When serialized via GetStringFormatted(), AlternateName replaces the timestamp-bearing name in the name field. +// +// Because the legacy serialized string format does not retain provenance metadata: +// 1. Literal names or timestamps containing non-encoder characters (e.g. '0', '1', '2', '3' present in +// standard Unix timestamps like "28677846", or 'x' in literal names like "backup-xxxxxxxx") +// are rejected and preserved literally. +// 2. Alphanumeric suffixes containing 'b', 'c', 'd', 'f' cannot be Unix timestamps (which are purely decimal). +// However, standalone literal Jobs whose names happen to end in matching suffixes (e.g. "backup-db") +// will be inferred as having template hashes by this best-effort heuristic. +// 3. Purely numeric suffixes (digits '4'-'9', such as "8698448884" or "backup-4") that represent valid +// canonical uint32 encodings are recovered as template hashes, accepting the tradeoff that literal +// Jobs with identical suffixes are indistinguishable without external provenance. +func IsTemplateHash(s string) bool { + if !TemplateHashRegex.MatchString(s) { + return false + } + // Validate canonical uint32 encoding: + // '4' decodes to '0'. Canonical fmt.Sprint(uint32) has no leading zero unless s == "4" (0). + if len(s) > 1 && s[0] == '4' { + return false + } + // Decode back to decimal digits to ensure the value does not exceed math.MaxUint32 (4294967295) + if len(s) == 10 { + decoded := make([]byte, 10) + for i := 0; i < 10; i++ { + c := s[i] + switch { + case c >= '4' && c <= '9': + decoded[i] = c - 4 + case c == 'b': + decoded[i] = '6' + case c == 'c': + decoded[i] = '7' + case c == 'd': + decoded[i] = '8' + case c == 'f': + decoded[i] = '9' + } + } + if _, err := strconv.ParseUint(string(decoded), 10, 32); err != nil { + return false + } + } + return true +} diff --git a/instanceidhandler/v1/helpers/keys_test.go b/instanceidhandler/v1/helpers/keys_test.go index 64aa674..2fc385b 100644 --- a/instanceidhandler/v1/helpers/keys_test.go +++ b/instanceidhandler/v1/helpers/keys_test.go @@ -76,3 +76,55 @@ func Test_ignoreOwnerReference(t *testing.T) { }) } } + +func TestIsUnixTimeInMinutes(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"28677846", true}, + {"0", false}, + {"-1", false}, + {"b449cf78f", false}, + {"job", false}, + } + for _, tt := range tests { + if got := IsUnixTimeInMinutes(tt.input); got != tt.want { + t.Errorf("IsUnixTimeInMinutes(%s) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestIsTemplateHash(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"b449cf78f", true}, + {"8698448884", true}, // entirely numeric hash generated from SafeEncodeString + {"5678", true}, // short hash (< 8 chars) + {"4", true}, // single character hash (decodes to 0) + {"db", true}, // short alphanumeric hash (decodes to 86) + {"56789bcdf4", true}, // 10-character canonical hash (decodes to 1234567890 <= MaxUint32) + {"5f99858564", true}, + {"77bdd46fc5", true}, + {"84f5585d68", true}, + {"44", false}, // non-canonical leading zero (decodes to "00") + {"ffffffffff", false}, // exceeds math.MaxUint32 (decodes to 9999999999 > 4294967295) + {"28677846", false}, // timestamp with digits '2' (not in producer domain) + {"xxxxxxxx", false}, // non-domain character 'x' + {"backup", false}, // non-domain word + {"job", false}, // non-domain word + {"nginx", false}, // non-domain word + {"worker", false}, // non-domain word + {"1234", false}, // digits '1','2','3' not in producer domain + {"0456", false}, // digit '0' not in producer domain + {"456789bcdf4", false}, // exceeds uint32 max length (11 chars) + {"", false}, // empty string + } + for _, tt := range tests { + if got := IsTemplateHash(tt.input); got != tt.want { + t.Errorf("IsTemplateHash(%s) = %v, want %v", tt.input, got, tt.want) + } + } +} diff --git a/instanceidhandler/v1/initializers2_test.go b/instanceidhandler/v1/initializers2_test.go index abc0693..8a25d50 100644 --- a/instanceidhandler/v1/initializers2_test.go +++ b/instanceidhandler/v1/initializers2_test.go @@ -6,6 +6,7 @@ import ( "github.com/kubescape/k8s-interface/instanceidhandler" "github.com/kubescape/k8s-interface/instanceidhandler/v1/containerinstance" + "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" @@ -347,3 +348,78 @@ func TestGenerateInstanceIDFromRuntimeObj_PreservesInput(t *testing.T) { // Verify that the caller-owned runtime object was not mutated by hashing or sanitization assert.Equal(t, podCopy, pod, "GenerateInstanceIDFromRuntimeObj must not mutate the input runtime.Object") } + +func TestGeneratorToParserRoundTrip(t *testing.T) { + // Test 1: CronJob child Job with template hash generated from Pod + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backup-28677846-xyz12", + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "batch/v1", + Kind: "Job", + Name: "backup-28677846", + }, + }, + }, + Spec: v1.PodSpec{ + Containers: []v1.Container{ + { + Name: "backup", + Image: "backup:196", + }, + }, + }, + } + + ids, err := GenerateInstanceIDFromRuntimeObj(pod, nil) + require.NoError(t, err) + require.NotEmpty(t, ids) + + generated := ids[0] + hash := generated.GetTemplateHash() + require.NotEmpty(t, hash, "generated instance ID must have template hash") + + formattedStr := generated.GetStringFormatted() + parsed, err := containerinstance.GenerateInstanceIDFromString(formattedStr) + require.NoError(t, err) + assert.Equal(t, hash, parsed.GetTemplateHash(), "parsed template hash must match generated template hash") + assert.Equal(t, hash, parsed.GetLabels()[helpers.TemplateHashKey], "parsed template hash label must match") + assert.Equal(t, formattedStr, parsed.GetStringFormatted(), "serialized string must match") + + // Test 2: Ordinary CronJob workload without hash + cronjob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "backup-xxxxxxxx", + Namespace: "default", + }, + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + Containers: []v1.Container{ + { + Name: "backup", + }, + }, + }, + }, + }, + }, + }, + } + + cronJobIDs, err := GenerateInstanceIDFromRuntimeObj(cronjob, nil) + require.NoError(t, err) + require.NotEmpty(t, cronJobIDs) + + cronJobGenerated := cronJobIDs[0] + assert.Empty(t, cronJobGenerated.GetTemplateHash()) + + cronJobParsed, err := containerinstance.GenerateInstanceIDFromString(cronJobGenerated.GetStringFormatted()) + require.NoError(t, err) + assert.Empty(t, cronJobParsed.GetTemplateHash(), "ordinary CronJob must not have a template hash") + assert.Equal(t, "backup-xxxxxxxx", cronJobParsed.GetName()) +}