diff --git a/api/v1alpha1/nucleiscan_types.go b/api/v1alpha1/nucleiscan_types.go index 0f9521b..e40ef4e 100644 --- a/api/v1alpha1/nucleiscan_types.go +++ b/api/v1alpha1/nucleiscan_types.go @@ -77,6 +77,9 @@ type JobReference struct { // Name of the Job Name string `json:"name"` + // Namespace of the Job (may differ from NucleiScan namespace) + Namespace string `json:"namespace"` + // UID of the Job UID string `json:"uid"` diff --git a/charts/nuclei-operator/templates/crds/nucleiscan-crd.yaml b/charts/nuclei-operator/templates/crds/nucleiscan-crd.yaml index 7da4408..8dcf30c 100644 --- a/charts/nuclei-operator/templates/crds/nucleiscan-crd.yaml +++ b/charts/nuclei-operator/templates/crds/nucleiscan-crd.yaml @@ -5,8 +5,6 @@ metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 name: nucleiscans.nuclei.homelab.mortenolsen.pro - labels: - {{- include "nuclei-operator.labels" . | nindent 4 }} spec: group: nuclei.homelab.mortenolsen.pro names: @@ -14,310 +12,456 @@ spec: listKind: NucleiScanList plural: nucleiscans shortNames: - - ns - - nscan + - ns + - nscan singular: nucleiscan scope: Namespaced versions: - - additionalPrinterColumns: - - jsonPath: .status.phase - name: Phase - type: string - - jsonPath: .status.summary.totalFindings - name: Findings - type: integer - - jsonPath: .spec.sourceRef.kind - name: Source - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: NucleiScan is the Schema for the nucleiscans API - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: NucleiScanSpec defines the desired state of NucleiScan - properties: - schedule: - description: |- - Schedule for periodic rescanning in cron format - If empty, scan runs once - type: string - severity: - description: Severity filters scan results by severity level - items: + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.summary.totalFindings + name: Findings + type: integer + - jsonPath: .spec.sourceRef.kind + name: Source + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: NucleiScan is the Schema for the nucleiscans API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: NucleiScanSpec defines the desired state of NucleiScan + properties: + scannerConfig: + description: ScannerConfig allows overriding scanner settings for + this scan + properties: + image: + description: Image overrides the default scanner image type: string - type: array - sourceRef: - description: - SourceRef references the Ingress or VirtualService being - scanned - properties: - apiVersion: - description: APIVersion of the source resource + nodeSelector: + additionalProperties: type: string - kind: - description: Kind of the source resource - Ingress or VirtualService - enum: - - Ingress - - VirtualService - type: string - name: - description: Name of the source resource - type: string - namespace: - description: Namespace of the source resource - type: string - uid: - description: UID of the source resource for owner reference - type: string - required: - - apiVersion - - kind - - name - - namespace - - uid - type: object - suspend: - description: Suspend prevents scheduled scans from running - type: boolean - targets: - description: - Targets is the list of URLs to scan, extracted from the - source resource - items: - type: string - minItems: 1 - type: array - templates: - description: |- - Templates specifies which Nuclei templates to use - If empty, uses default templates - items: - type: string - type: array - required: - - sourceRef - - targets - type: object - status: - description: NucleiScanStatus defines the observed state of NucleiScan - properties: - completionTime: - description: CompletionTime is when the last scan completed - format: date-time - type: string - conditions: - description: Conditions represent the latest available observations - items: - description: - Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type + description: NodeSelector for scanner pod scheduling type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - findings: - description: |- - Findings contains the array of scan results from Nuclei JSONL output - Each element is a parsed JSON object from Nuclei output - items: - description: Finding represents a single Nuclei scan finding + resources: + description: Resources defines resource requirements for the scanner + pod properties: - description: - description: Description provides details about the finding - type: string - extractedResults: - description: - ExtractedResults contains any data extracted by - the template + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. items: - type: string + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object type: array - host: - description: Host that was scanned - type: string - matchedAt: - description: - MatchedAt is the specific URL or endpoint where - the issue was found - type: string - metadata: - description: Metadata contains additional template metadata + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ type: object - x-kubernetes-preserve-unknown-fields: true - reference: - description: - Reference contains URLs to additional information - about the finding - items: - type: string - type: array - severity: - description: Severity of the finding - type: string - tags: - description: Tags associated with the finding - items: - type: string - type: array - templateId: - description: TemplateID is the Nuclei template identifier - type: string - templateName: - description: TemplateName is the human-readable template name - type: string - timestamp: - description: Timestamp when the finding was discovered - format: date-time - type: string - type: - description: Type of the finding - http, dns, ssl, etc. - type: string - required: - - host - - severity - - templateId - - timestamp type: object - type: array - lastError: - description: LastError contains the error message if the scan failed - type: string - lastRetryTime: - description: - LastRetryTime is when the last availability check retry - occurred - format: date-time - type: string - lastScanTime: - description: LastScanTime is when the last scan was initiated - format: date-time - type: string - nextScheduledTime: - description: - NextScheduledTime is when the next scheduled scan will - run - format: date-time - type: string - observedGeneration: - description: - ObservedGeneration is the generation observed by the - controller - format: int64 - type: integer - phase: - description: Phase represents the current scan phase - enum: - - Pending - - Running - - Completed - - Failed - type: string - retryCount: - description: |- - RetryCount tracks the number of consecutive availability check retries - Used for exponential backoff when waiting for targets - type: integer - summary: - description: Summary provides aggregated scan statistics - properties: - durationSeconds: - description: DurationSeconds is the duration of the scan in seconds - format: int64 - type: integer - findingsBySeverity: - additionalProperties: - type: integer - description: - FindingsBySeverity breaks down findings by severity - level + templateURLs: + description: TemplateURLs specifies additional template repositories + to clone + items: + type: string + type: array + timeout: + description: Timeout overrides the default scan timeout + type: string + tolerations: + description: Tolerations for scanner pod scheduling + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string type: object - targetsScanned: - description: - TargetsScanned is the number of targets that were - scanned - type: integer - totalFindings: - description: TotalFindings is the total number of findings + type: array + type: object + schedule: + description: |- + Schedule for periodic rescanning in cron format + If empty, scan runs once + type: string + severity: + description: Severity filters scan results by severity level + enum: + - info + - low + - medium + - high + - critical + items: + type: string + type: array + sourceRef: + description: SourceRef references the Ingress or VirtualService being + scanned + properties: + apiVersion: + description: APIVersion of the source resource + type: string + kind: + description: Kind of the source resource - Ingress or VirtualService + enum: + - Ingress + - VirtualService + type: string + name: + description: Name of the source resource + type: string + namespace: + description: Namespace of the source resource + type: string + uid: + description: UID of the source resource for owner reference + type: string + required: + - apiVersion + - kind + - name + - namespace + - uid + type: object + suspend: + description: Suspend prevents scheduled scans from running + type: boolean + targets: + description: Targets is the list of URLs to scan, extracted from the + source resource + items: + type: string + minItems: 1 + type: array + templates: + description: |- + Templates specifies which Nuclei templates to use + If empty, uses default templates + items: + type: string + type: array + required: + - sourceRef + - targets + type: object + status: + description: NucleiScanStatus defines the observed state of NucleiScan + properties: + completionTime: + description: CompletionTime is when the last scan completed + format: date-time + type: string + conditions: + description: Conditions represent the latest available observations + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string required: - - targetsScanned - - totalFindings + - lastTransitionTime + - message + - reason + - status + - type type: object - type: object - type: object - served: true - storage: true - subresources: - status: {} + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + findings: + description: |- + Findings contains the array of scan results from Nuclei JSONL output + Each element is a parsed JSON object from Nuclei output + items: + description: Finding represents a single Nuclei scan finding + properties: + description: + description: Description provides details about the finding + type: string + extractedResults: + description: ExtractedResults contains any data extracted by + the template + items: + type: string + type: array + host: + description: Host that was scanned + type: string + matchedAt: + description: MatchedAt is the specific URL or endpoint where + the issue was found + type: string + metadata: + description: Metadata contains additional template metadata + type: object + x-kubernetes-preserve-unknown-fields: true + reference: + description: Reference contains URLs to additional information + about the finding + items: + type: string + type: array + severity: + description: Severity of the finding + type: string + tags: + description: Tags associated with the finding + items: + type: string + type: array + templateId: + description: TemplateID is the Nuclei template identifier + type: string + templateName: + description: TemplateName is the human-readable template name + type: string + timestamp: + description: Timestamp when the finding was discovered + format: date-time + type: string + type: + description: Type of the finding - http, dns, ssl, etc. + type: string + required: + - host + - severity + - templateId + - timestamp + type: object + type: array + jobRef: + description: JobRef references the current or last scanner job + properties: + name: + description: Name of the Job + type: string + namespace: + description: Namespace of the Job (may differ from NucleiScan + namespace) + type: string + podName: + description: PodName is the name of the scanner pod (for log retrieval) + type: string + startTime: + description: StartTime when the job was created + format: date-time + type: string + uid: + description: UID of the Job + type: string + required: + - name + - namespace + - uid + type: object + lastError: + description: LastError contains the error message if the scan failed + type: string + lastRetryTime: + description: LastRetryTime is when the last availability check retry + occurred + format: date-time + type: string + lastScanTime: + description: LastScanTime is when the last scan was initiated + format: date-time + type: string + nextScheduledTime: + description: NextScheduledTime is when the next scheduled scan will + run + format: date-time + type: string + observedGeneration: + description: ObservedGeneration is the generation observed by the + controller + format: int64 + type: integer + phase: + description: Phase represents the current scan phase + enum: + - Pending + - Running + - Completed + - Failed + type: string + retryCount: + description: |- + RetryCount tracks the number of consecutive availability check retries + Used for exponential backoff when waiting for targets + type: integer + scanStartTime: + description: ScanStartTime is when the scanner pod actually started + scanning + format: date-time + type: string + summary: + description: Summary provides aggregated scan statistics + properties: + durationSeconds: + description: DurationSeconds is the duration of the scan in seconds + format: int64 + type: integer + findingsBySeverity: + additionalProperties: + type: integer + description: FindingsBySeverity breaks down findings by severity + level + type: object + targetsScanned: + description: TargetsScanned is the number of targets that were + scanned + type: integer + totalFindings: + description: TotalFindings is the total number of findings + type: integer + required: + - targetsScanned + - totalFindings + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/charts/nuclei-operator/templates/deployment.yaml b/charts/nuclei-operator/templates/deployment.yaml index d2eacd4..59ffbe1 100644 --- a/charts/nuclei-operator/templates/deployment.yaml +++ b/charts/nuclei-operator/templates/deployment.yaml @@ -70,6 +70,8 @@ spec: value: {{ .Values.scanner.ttlAfterFinished | quote }} - name: SCANNER_SERVICE_ACCOUNT value: {{ include "nuclei-operator.fullname" . }}-scanner + - name: OPERATOR_NAMESPACE + value: {{ .Release.Namespace | quote }} {{- if .Values.scanner.defaultTemplates }} - name: DEFAULT_TEMPLATES value: {{ join "," .Values.scanner.defaultTemplates | quote }} diff --git a/cmd/main.go b/cmd/main.go index cb9ebd7..50bd02b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -241,6 +241,16 @@ func main() { scannerServiceAccount = "nuclei-scanner" } + operatorNamespace := os.Getenv("OPERATOR_NAMESPACE") + if operatorNamespace == "" { + // Try to read from the downward API file + if data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil { + operatorNamespace = string(data) + } else { + operatorNamespace = "nuclei-operator-system" + } + } + defaultTemplates := []string{} if v := os.Getenv("DEFAULT_TEMPLATES"); v != "" { defaultTemplates = strings.Split(v, ",") @@ -259,6 +269,7 @@ func main() { BackoffLimit: 2, MaxConcurrent: maxConcurrentScans, ServiceAccountName: scannerServiceAccount, + OperatorNamespace: operatorNamespace, DefaultResources: jobmanager.DefaultConfig().DefaultResources, DefaultTemplates: defaultTemplates, DefaultSeverity: defaultSeverity, diff --git a/config/crd/bases/nuclei.homelab.mortenolsen.pro_nucleiscans.yaml b/config/crd/bases/nuclei.homelab.mortenolsen.pro_nucleiscans.yaml index 74b7a5e..8dcf30c 100644 --- a/config/crd/bases/nuclei.homelab.mortenolsen.pro_nucleiscans.yaml +++ b/config/crd/bases/nuclei.homelab.mortenolsen.pro_nucleiscans.yaml @@ -376,6 +376,10 @@ spec: name: description: Name of the Job type: string + namespace: + description: Namespace of the Job (may differ from NucleiScan + namespace) + type: string podName: description: PodName is the name of the scanner pod (for log retrieval) type: string @@ -388,6 +392,7 @@ spec: type: string required: - name + - namespace - uid type: object lastError: diff --git a/internal/controller/nucleiscan_controller.go b/internal/controller/nucleiscan_controller.go index 285982e..85836d1 100644 --- a/internal/controller/nucleiscan_controller.go +++ b/internal/controller/nucleiscan_controller.go @@ -257,8 +257,13 @@ func (r *NucleiScanReconciler) handleDeletion(ctx context.Context, nucleiScan *n // Clean up any running scanner job if nucleiScan.Status.JobRef != nil { - log.Info("Deleting scanner job", "job", nucleiScan.Status.JobRef.Name) - if err := r.JobManager.DeleteJob(ctx, nucleiScan.Status.JobRef.Name, nucleiScan.Namespace); err != nil { + jobNamespace := nucleiScan.Status.JobRef.Namespace + if jobNamespace == "" { + // Fallback for backwards compatibility + jobNamespace = nucleiScan.Namespace + } + log.Info("Deleting scanner job", "job", nucleiScan.Status.JobRef.Name, "namespace", jobNamespace) + if err := r.JobManager.DeleteJob(ctx, nucleiScan.Status.JobRef.Name, jobNamespace); err != nil { if !apierrors.IsNotFound(err) { log.Error(err, "Failed to delete scanner job", "job", nucleiScan.Status.JobRef.Name) } @@ -324,6 +329,7 @@ func (r *NucleiScanReconciler) handlePendingPhase(ctx context.Context, nucleiSca nucleiScan.Status.Phase = nucleiv1alpha1.ScanPhaseRunning nucleiScan.Status.JobRef = &nucleiv1alpha1.JobReference{ Name: job.Name, + Namespace: job.Namespace, UID: string(job.UID), StartTime: &now, } @@ -401,8 +407,13 @@ func (r *NucleiScanReconciler) handleRunningPhase(ctx context.Context, nucleiSca return ctrl.Result{Requeue: true}, nil } - // Get the job - job, err := r.JobManager.GetJob(ctx, nucleiScan.Status.JobRef.Name, nucleiScan.Namespace) + // Get the job - use namespace from JobRef (may be different from scan namespace) + jobNamespace := nucleiScan.Status.JobRef.Namespace + if jobNamespace == "" { + // Fallback for backwards compatibility + jobNamespace = nucleiScan.Namespace + } + job, err := r.JobManager.GetJob(ctx, nucleiScan.Status.JobRef.Name, jobNamespace) if err != nil { if apierrors.IsNotFound(err) { logger.Info("Scanner job not found, resetting to Pending") diff --git a/internal/jobmanager/jobmanager.go b/internal/jobmanager/jobmanager.go index 69e0162..cbcf6e2 100644 --- a/internal/jobmanager/jobmanager.go +++ b/internal/jobmanager/jobmanager.go @@ -82,6 +82,9 @@ type Config struct { // ServiceAccountName is the service account to use for scanner pods ServiceAccountName string + // OperatorNamespace is the namespace where the operator runs and where scanner jobs will be created + OperatorNamespace string + // DefaultResources are the default resource requirements for scanner pods DefaultResources corev1.ResourceRequirements @@ -101,6 +104,7 @@ func DefaultConfig() Config { BackoffLimit: DefaultBackoffLimit, MaxConcurrent: 5, ServiceAccountName: "nuclei-scanner", + OperatorNamespace: "nuclei-operator-system", DefaultResources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("100m"), @@ -136,14 +140,22 @@ func (m *JobManager) CreateScanJob(ctx context.Context, scan *nucleiv1alpha1.Nuc job := m.buildJob(scan) - // Set owner reference so the job is garbage collected when the scan is deleted - if err := controllerutil.SetControllerReference(scan, job, m.Scheme); err != nil { - return nil, fmt.Errorf("failed to set controller reference: %w", err) + // Only set owner reference if the job is in the same namespace as the scan + // Cross-namespace owner references are not allowed in Kubernetes + if job.Namespace == scan.Namespace { + if err := controllerutil.SetControllerReference(scan, job, m.Scheme); err != nil { + return nil, fmt.Errorf("failed to set controller reference: %w", err) + } } + // When job is in a different namespace (operator namespace), we rely on: + // 1. TTLSecondsAfterFinished for automatic cleanup of completed jobs + // 2. Labels (LabelScanName, LabelScanNamespace) to track which scan the job belongs to + // 3. CleanupOrphanedJobs to clean up jobs whose scans no longer exist logger.Info("Creating scanner job", "job", job.Name, - "namespace", job.Namespace, + "jobNamespace", job.Namespace, + "scanNamespace", scan.Namespace, "image", job.Spec.Template.Spec.Containers[0].Image, "targets", len(scan.Spec.Targets)) @@ -277,14 +289,41 @@ func (m *JobManager) CleanupOrphanedJobs(ctx context.Context) error { } for _, job := range jobList.Items { - // Check if owner reference exists and the owner still exists - ownerRef := metav1.GetControllerOf(&job) - if ownerRef == nil { - logger.Info("Deleting orphaned job without owner", "job", job.Name, "namespace", job.Namespace) - if err := m.DeleteJob(ctx, job.Name, job.Namespace); err != nil && !apierrors.IsNotFound(err) { - logger.Error(err, "Failed to delete orphaned job", "job", job.Name) + // Check if the associated NucleiScan still exists using labels + scanName := job.Labels[LabelScanName] + scanNamespace := job.Labels[LabelScanNamespace] + + if scanName != "" && scanNamespace != "" { + // Try to get the associated NucleiScan + scan := &nucleiv1alpha1.NucleiScan{} + err := m.Get(ctx, types.NamespacedName{Name: scanName, Namespace: scanNamespace}, scan) + if err != nil { + if apierrors.IsNotFound(err) { + // The scan no longer exists - delete the job + logger.Info("Deleting orphaned job (scan not found)", + "job", job.Name, + "namespace", job.Namespace, + "scanName", scanName, + "scanNamespace", scanNamespace) + if err := m.DeleteJob(ctx, job.Name, job.Namespace); err != nil && !apierrors.IsNotFound(err) { + logger.Error(err, "Failed to delete orphaned job", "job", job.Name) + } + continue + } + // Other error - log and continue + logger.Error(err, "Failed to check if scan exists", "scanName", scanName, "scanNamespace", scanNamespace) + continue + } + } else { + // Job doesn't have proper labels - check owner reference as fallback + ownerRef := metav1.GetControllerOf(&job) + if ownerRef == nil { + logger.Info("Deleting orphaned job without owner or labels", "job", job.Name, "namespace", job.Namespace) + if err := m.DeleteJob(ctx, job.Name, job.Namespace); err != nil && !apierrors.IsNotFound(err) { + logger.Error(err, "Failed to delete orphaned job", "job", job.Name) + } + continue } - continue } // Check if the job is stuck (running longer than 2x the timeout) @@ -305,12 +344,18 @@ func (m *JobManager) CleanupOrphanedJobs(ctx context.Context) error { // buildJob creates a Job specification for the given NucleiScan func (m *JobManager) buildJob(scan *nucleiv1alpha1.NucleiScan) *batchv1.Job { - // Generate a unique job name - jobName := fmt.Sprintf("nucleiscan-%s-%d", scan.Name, time.Now().Unix()) + // Generate a unique job name that includes the scan namespace to avoid collisions + jobName := fmt.Sprintf("nucleiscan-%s-%s-%d", scan.Namespace, scan.Name, time.Now().Unix()) if len(jobName) > 63 { jobName = jobName[:63] } + // Determine the namespace for the job - use operator namespace if configured + jobNamespace := m.Config.OperatorNamespace + if jobNamespace == "" { + jobNamespace = scan.Namespace + } + // Determine the scanner image image := m.Config.ScannerImage if scan.Spec.ScannerConfig != nil && scan.Spec.ScannerConfig.Image != "" { @@ -360,7 +405,7 @@ func (m *JobManager) buildJob(scan *nucleiv1alpha1.NucleiScan) *batchv1.Job { job := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: jobName, - Namespace: scan.Namespace, + Namespace: jobNamespace, Labels: labels, }, Spec: batchv1.JobSpec{ diff --git a/internal/jobmanager/jobmanager_test.go b/internal/jobmanager/jobmanager_test.go index 471f07f..4218e59 100644 --- a/internal/jobmanager/jobmanager_test.go +++ b/internal/jobmanager/jobmanager_test.go @@ -43,14 +43,22 @@ func TestBuildJob(t *testing.T) { job := manager.buildJob(scan) - // Verify job name prefix + // Verify job name prefix - should include scan namespace to avoid collisions if len(job.Name) == 0 { t.Error("Job name should not be empty") } - // Verify namespace - if job.Namespace != "default" { - t.Errorf("Expected namespace 'default', got '%s'", job.Namespace) + // Verify namespace - job should be created in operator namespace + if job.Namespace != config.OperatorNamespace { + t.Errorf("Expected namespace '%s', got '%s'", config.OperatorNamespace, job.Namespace) + } + + // Verify scan labels are set correctly for cross-namespace tracking + if job.Labels[LabelScanName] != scan.Name { + t.Errorf("Expected scan name label '%s', got '%s'", scan.Name, job.Labels[LabelScanName]) + } + if job.Labels[LabelScanNamespace] != scan.Namespace { + t.Errorf("Expected scan namespace label '%s', got '%s'", scan.Namespace, job.Labels[LabelScanNamespace]) } // Verify labels @@ -115,3 +123,29 @@ func TestBuildJobWithCustomConfig(t *testing.T) { t.Errorf("Expected deadline %d, got %d", expectedDeadline, *job.Spec.ActiveDeadlineSeconds) } } + +func TestBuildJobInSameNamespace(t *testing.T) { + config := DefaultConfig() + // Clear operator namespace to test same-namespace behavior + config.OperatorNamespace = "" + manager := &JobManager{ + Config: config, + } + + scan := &nucleiv1alpha1.NucleiScan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-scan", + Namespace: "my-namespace", + }, + Spec: nucleiv1alpha1.NucleiScanSpec{ + Targets: []string{"https://example.com"}, + }, + } + + job := manager.buildJob(scan) + + // Verify namespace - when operator namespace is empty, job should be in scan's namespace + if job.Namespace != scan.Namespace { + t.Errorf("Expected namespace '%s', got '%s'", scan.Namespace, job.Namespace) + } +}