Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions instanceidhandler/v1/containerinstance/initializers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<apiVersion>/namespace-<namespace>/kind-<kind>/name-<name>/containerName-<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]
Expand All @@ -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
}
Expand Down
193 changes: 190 additions & 3 deletions instanceidhandler/v1/containerinstance/initializers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,205 @@ 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",
},
want: &InstanceID{
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,
},
Expand Down
77 changes: 76 additions & 1 deletion instanceidhandler/v1/helpers/keys.go
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 "<cronjob>-<timestamp>" 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 "<cronjob>-<templateHash>" (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
}
Loading
Loading