diff --git a/charts/nudgebee-agent/values.yaml b/charts/nudgebee-agent/values.yaml index bbf14b10..e3beaa5d 100644 --- a/charts/nudgebee-agent/values.yaml +++ b/charts/nudgebee-agent/values.yaml @@ -65,7 +65,7 @@ runnerServiceAccount: runner: image: repository: ghcr.io/nudgebee/nudgebee-agent - tag: 2026-08-12T06-01-54_7ec58f7701909a3ce172ad2a9235f8b15255e363 + tag: 2026-08-12T08-10-04_e13ce92bc8c238e883d050b1276c6128b2c8fb44 # Image template the pod_profiler action launches debugger pods from. # The agent substitutes `{}` for the variant (bpf, jvm, python, perf, ruby). # Surfaces as PROFILER_IMAGE; leave empty to fall back to the binary default. diff --git a/runner/pkg/scanners/scanners.go b/runner/pkg/scanners/scanners.go index 1d4f1f9d..c2b99732 100644 --- a/runner/pkg/scanners/scanners.go +++ b/runner/pkg/scanners/scanners.go @@ -180,7 +180,15 @@ func (r *Runner) BuildJob(spec JobSpec, jobName, jobUUID string) *batchv1.Job { ActiveDeadlineSeconds: &deadline, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{jobNameSelectorLabel: jobName}, + // managed-by/orchestrator are propagated to the pod so the + // trigger engine can recognize (and skip) the agent's own + // scan pods — the Job-level labels are invisible to a + // pod-scoped matcher, which walks owners by name only. + Labels: map[string]string{ + jobNameSelectorLabel: jobName, + managedByLabel: managedByValue, + orchestratorLabel: orchestratorValue, + }, }, Spec: podSpec, }, diff --git a/runner/pkg/scanners/scanners_test.go b/runner/pkg/scanners/scanners_test.go index f03bce38..c0a0ee3a 100644 --- a/runner/pkg/scanners/scanners_test.go +++ b/runner/pkg/scanners/scanners_test.go @@ -71,6 +71,19 @@ func TestBuildJob_HygieneInvariants(t *testing.T) { if job.Spec.Template.Spec.RestartPolicy != corev1.RestartPolicyNever { t.Errorf("RestartPolicy = %v; want Never", job.Spec.Template.Spec.RestartPolicy) } + // The pod template must carry the managed-by/orchestrator labels too — + // the trigger engine identifies (and skips) the agent's own scan pods + // by them; Job-level labels are invisible to a pod-scoped matcher. + podLabels := job.Spec.Template.Labels + if podLabels[jobNameSelectorLabel] != "popeye-scan-abcd1234" { + t.Errorf("pod template missing job-name label: %v", podLabels) + } + if podLabels[managedByLabel] != managedByValue { + t.Errorf("pod template missing managed-by label: %v", podLabels) + } + if podLabels[orchestratorLabel] != orchestratorValue { + t.Errorf("pod template missing orchestrator label: %v", podLabels) + } } // A scanner that declares a budget longer than the hygiene floor keeps it — the diff --git a/runner/pkg/triggers/engine.go b/runner/pkg/triggers/engine.go index 45e2ceda..ba4b6100 100644 --- a/runner/pkg/triggers/engine.go +++ b/runner/pkg/triggers/engine.go @@ -105,6 +105,33 @@ func (e *Engine) fetchNamespaceEvents(namespace string) []EvidenceBlock { namespace, "", "", "Recent events in namespace "+namespace, supplementaryEventsLimit) } +// Labels stamped by the agent's own job scheduler (pkg/scanners) on the +// Jobs it creates and their pods. Duplicated here rather than imported — +// scanners and triggers are independent packages and the label contract +// is stable. +const ( + agentManagedByLabel = "app.kubernetes.io/managed-by" + agentManagedByValue = "nudgebee-agent" +) + +// isAgentManaged reports whether the object is one of the agent's own +// scheduled workloads (scan Jobs and their pods). These are internal +// plumbing: their failures are tracked by the scan orchestrator's own +// run accounting, and surfacing them as customer-facing Findings only +// produces noise (e.g. an ImagePullBackOff on a trivy scan Job). +func isAgentManaged(obj map[string]any) bool { + meta, _ := obj["metadata"].(map[string]any) + if meta == nil { + return false + } + labels, _ := meta["labels"].(map[string]any) + if labels == nil { + return false + } + v, _ := labels[agentManagedByLabel].(string) + return v == agentManagedByValue +} + // Match runs every matcher against the event and returns one Match per // fired trigger. A single event can fire several matchers (a Pod can be // both ImagePullBackOff and CrashLoopBackOff). @@ -124,6 +151,10 @@ func (e *Engine) Match(ev IncomingK8sEvent) []Match { if ev.Obj == nil { return nil } + // The agent's own scheduled workloads never produce Findings. + if isAgentManaged(ev.Obj) { + return nil + } matches := make([]Match, 0, 1) ec := e.enrichContext() for i := range e.specs { diff --git a/runner/pkg/triggers/owner.go b/runner/pkg/triggers/owner.go index 9e2a044c..f94ef9b2 100644 --- a/runner/pkg/triggers/owner.go +++ b/runner/pkg/triggers/owner.go @@ -73,7 +73,14 @@ func canonicalOwner(kind, name string, labels map[string]any) OwnerRef { } // "web-7f9d8c5b6" → "web". return OwnerRef{Name: stripPodTemplateHash(name), Kind: "deployment"} - case "deployment", "daemonset", "statefulset", "job", "cronjob", + case "job": + // "mycron-29123456" → "mycron". Same rationale as the ReplicaSet + // strip: every run of a CronJob gets a Job named with the scheduled + // time appended, and without normalization each run is a distinct + // owner → distinct fingerprint → a new Finding that never dedupes + // against the previous run's. + return OwnerRef{Name: stripJobGeneratedSuffix(name), Kind: lk} + case "deployment", "daemonset", "statefulset", "cronjob", "rollout", "horizontalpodautoscaler", "node": return OwnerRef{Name: name, Kind: lk} default: @@ -95,6 +102,30 @@ func stripPodTemplateHash(name string) string { return podTemplateHashSuffix.ReplaceAllString(name, "") } +// cronJobScheduleSuffix matches the scheduled-time suffix the CronJob +// controller appends to the Jobs it creates — getJobName is +// `fmt.Sprintf("%s-%d", cronJob.Name, scheduledTime.Unix()/60)`, i.e. +// `-` (currently 8 digits; 10 tolerates unix-seconds +// variants in older/forked controllers). This is a controller-defined +// format, not a naming-convention guess — the same trust level as the +// ReplicaSet pod-template-hash strip above. We deliberately do NOT try +// to recognize other generated-name shapes (random hex tails etc.): +// those are conventions, not contracts, and stripping them risks +// merging genuinely distinct hand-named Jobs. The agent's own scan Jobs +// don't need it — they are skipped wholesale via their managed-by label +// (see Engine.Match). +var cronJobScheduleSuffix = regexp.MustCompile(`-\d{8,10}$`) + +// stripJobGeneratedSuffix normalizes a CronJob-created Job name to the +// CronJob's own name, so every scheduled run resolves to the same owner. +// Names without the scheduled-time suffix pass through unchanged. +func stripJobGeneratedSuffix(name string) string { + if stripped := cronJobScheduleSuffix.ReplaceAllString(name, ""); stripped != name && stripped != "" { + return stripped + } + return name +} + // SubjectFromObj extracts the (name, namespace, lowercased-kind, node) // for an obj. node is "" when not a Pod or when not yet scheduled. // Used by Engine.Match to populate Match.Subject* fields uniformly. diff --git a/runner/pkg/triggers/predicates_test.go b/runner/pkg/triggers/predicates_test.go index 26cacb9f..4838e19b 100644 --- a/runner/pkg/triggers/predicates_test.go +++ b/runner/pkg/triggers/predicates_test.go @@ -977,6 +977,56 @@ func TestEngine_KindFilter(t *testing.T) { } } +func TestEngine_SkipsAgentManagedObjects(t *testing.T) { + // A pod stamped with the agent's own managed-by label (a scan Job pod) + // must produce zero matches even in a firing state — its failures are + // the scan orchestrator's run accounting, not a customer Finding. + pod := asObj(t, `{ + "metadata":{"name":"trivy-image-scan-b0690686-z95th","namespace":"prod", + "labels":{"app.kubernetes.io/managed-by":"nudgebee-agent","job-name":"trivy-image-scan-b0690686"}, + "ownerReferences":[{"kind":"Job","name":"trivy-image-scan-b0690686","controller":true}]}, + "status":{"containerStatuses":[ + {"name":"app","image":"registry.example.com/big:1.0", + "state":{"waiting":{"reason":"ImagePullBackOff"}}} + ]} + }`) + eng := NewEngine(Builtins(), time.Now().Add(-time.Hour)) + matches := eng.Match(IncomingK8sEvent{Operation: "update", Kind: "Pod", Obj: pod}) + if len(matches) != 0 { + t.Errorf("agent-managed pod must be skipped; got %v", matchNames(matches)) + } + + // Same for the Job object itself transitioning to Failed. + oldJob := asObj(t, `{ + "metadata":{"name":"trivy-image-scan-b0690686","namespace":"prod", + "labels":{"app.kubernetes.io/managed-by":"nudgebee-agent"}}, + "status":{} + }`) + failedJob := asObj(t, `{ + "metadata":{"name":"trivy-image-scan-b0690686","namespace":"prod", + "labels":{"app.kubernetes.io/managed-by":"nudgebee-agent"}}, + "status":{"conditions":[{"type":"Failed","status":"True"}]} + }`) + matches = eng.Match(IncomingK8sEvent{Operation: "update", Kind: "Job", Obj: failedJob, OldObj: oldJob}) + if len(matches) != 0 { + t.Errorf("agent-managed Job must be skipped; got %v", matchNames(matches)) + } + + // A foreign managed-by value must NOT be skipped (helm-managed pods etc.). + helmPod := asObj(t, `{ + "metadata":{"name":"web-0","namespace":"prod", + "labels":{"app.kubernetes.io/managed-by":"Helm"}}, + "status":{"containerStatuses":[ + {"name":"app","image":"registry.example.com/web:1.0", + "state":{"waiting":{"reason":"ImagePullBackOff"}}} + ]} + }`) + matches = eng.Match(IncomingK8sEvent{Operation: "update", Kind: "Pod", Obj: helmPod}) + if !contains(matchNames(matches), "image_pull_backoff") { + t.Errorf("helm-managed pod must still match; got %v", matchNames(matches)) + } +} + func TestEngine_ReturnsEmptyForNoMatch(t *testing.T) { // Healthy Pod → no matchers fire → no Findings emitted (the whole point). pod := asObj(t, `{ @@ -1058,6 +1108,53 @@ func TestResolveOwner_PrefersControllerRef(t *testing.T) { } } +func TestResolveOwner_JobStripsCronJobTimestamp(t *testing.T) { + // CronJob-created Job: pod's one-level owner is the Job with the + // scheduled-time suffix. Every run must resolve to the same owner. + pod := asObj(t, `{ + "metadata":{"ownerReferences":[ + {"kind":"Job","name":"nightly-backup-29123456","controller":true} + ]} + }`) + o := ResolveOwner(pod) + if o.Name != "nightly-backup" || o.Kind != "job" { + t.Errorf("owner = %+v; want {nightly-backup job}", o) + } +} + +func TestResolveOwner_JobKeepsNonCronName(t *testing.T) { + // Only the CronJob controller's scheduled-time suffix is stripped. + // Other generated-name shapes (e.g. a random hex tail) are naming + // conventions, not contracts — they pass through unchanged. The + // agent's own scan Jobs don't rely on this: they are skipped + // wholesale via their managed-by label. + pod := asObj(t, `{ + "metadata":{"ownerReferences":[ + {"kind":"Job","name":"trivy-image-scan-b0690686","controller":true} + ]} + }`) + o := ResolveOwner(pod) + if o.Name != "trivy-image-scan-b0690686" || o.Kind != "job" { + t.Errorf("owner = %+v; want {trivy-image-scan-b0690686 job}", o) + } +} + +func TestStripJobGeneratedSuffix(t *testing.T) { + cases := []struct{ in, want string }{ + {"nightly-backup-29123456", "nightly-backup"}, // CronJob unix-minutes + {"nightly-backup-1751964000", "nightly-backup"}, // unix-seconds variant + {"manual-migration", "manual-migration"}, // hand-named: unchanged + {"load-test-2024", "load-test-2024"}, // short numeric tail: not a schedule suffix + {"db-seed-abcdef12", "db-seed-abcdef12"}, // hex tail: convention, not stripped + {"-29123456", "-29123456"}, // strip would leave nothing: keep original + } + for _, c := range cases { + if got := stripJobGeneratedSuffix(c.in); got != c.want { + t.Errorf("stripJobGeneratedSuffix(%q) = %q; want %q", c.in, got, c.want) + } + } +} + // ---------- helpers ---------- func matchNames(ms []Match) []string {