Skip to content
Open
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
2 changes: 1 addition & 1 deletion charts/nudgebee-agent/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion runner/pkg/scanners/scanners.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
13 changes: 13 additions & 0 deletions runner/pkg/scanners/scanners_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions runner/pkg/triggers/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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 {
Expand Down
33 changes: 32 additions & 1 deletion runner/pkg/triggers/owner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
// `-<unix-minutes>` (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
}
Comment on lines +117 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The stripJobGeneratedSuffix function is called on a hot path (for every kubewatch event matching a Job). Using a regular expression here introduces unnecessary overhead and allocations. We can optimize this by performing a simple string scan to check if the name ends with a hyphen followed by 8 to 10 digits. This approach is significantly faster and avoids any heap allocations.

// 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 {
    idx := strings.LastIndexByte(name, '-')
    if idx <= 0 {
        return name
    }
    suffix := name[idx+1:]
    if len(suffix) < 8 || len(suffix) > 10 {
        return name
    }
    for i := 0; i < len(suffix); i++ {
        if suffix[i] < '0' || suffix[i] > '9' {
            return name
        }
    }
    return name[:idx]
}


// 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.
Expand Down
97 changes: 97 additions & 0 deletions runner/pkg/triggers/predicates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, `{
Expand Down Expand Up @@ -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 {
Expand Down
Loading