1 Commits

Author SHA1 Message Date
Morten Olsen
5c3f7f3869 chore: use prebuilt nuclei execuable 2025-12-12 21:06:36 +01:00
12 changed files with 325 additions and 718 deletions

View File

@@ -87,7 +87,7 @@ jobs:
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
# platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}

View File

@@ -27,7 +27,7 @@ FROM alpine:3.19 AS final
# Build arguments for nuclei version and architecture # Build arguments for nuclei version and architecture
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
ARG NUCLEI_VERSION=3.6.0 ARG NUCLEI_VERSION=3.3.7
# Install ca-certificates for HTTPS requests, curl for downloading, and create non-root user # Install ca-certificates for HTTPS requests, curl for downloading, and create non-root user
RUN apk --no-cache add ca-certificates tzdata curl unzip && \ RUN apk --no-cache add ca-certificates tzdata curl unzip && \

View File

@@ -77,9 +77,6 @@ type JobReference struct {
// Name of the Job // Name of the Job
Name string `json:"name"` Name string `json:"name"`
// Namespace of the Job (may differ from NucleiScan namespace)
Namespace string `json:"namespace"`
// UID of the Job // UID of the Job
UID string `json:"uid"` UID string `json:"uid"`

View File

@@ -5,6 +5,8 @@ metadata:
annotations: annotations:
controller-gen.kubebuilder.io/version: v0.19.0 controller-gen.kubebuilder.io/version: v0.19.0
name: nucleiscans.nuclei.homelab.mortenolsen.pro name: nucleiscans.nuclei.homelab.mortenolsen.pro
labels:
{{- include "nuclei-operator.labels" . | nindent 4 }}
spec: spec:
group: nuclei.homelab.mortenolsen.pro group: nuclei.homelab.mortenolsen.pro
names: names:
@@ -12,456 +14,310 @@ spec:
listKind: NucleiScanList listKind: NucleiScanList
plural: nucleiscans plural: nucleiscans
shortNames: shortNames:
- ns - ns
- nscan - nscan
singular: nucleiscan singular: nucleiscan
scope: Namespaced scope: Namespaced
versions: versions:
- additionalPrinterColumns: - additionalPrinterColumns:
- jsonPath: .status.phase - jsonPath: .status.phase
name: Phase name: Phase
type: string type: string
- jsonPath: .status.summary.totalFindings - jsonPath: .status.summary.totalFindings
name: Findings name: Findings
type: integer type: integer
- jsonPath: .spec.sourceRef.kind - jsonPath: .spec.sourceRef.kind
name: Source name: Source
type: string type: string
- jsonPath: .metadata.creationTimestamp - jsonPath: .metadata.creationTimestamp
name: Age name: Age
type: date type: date
name: v1alpha1 name: v1alpha1
schema: schema:
openAPIV3Schema: openAPIV3Schema:
description: NucleiScan is the Schema for the nucleiscans API description: NucleiScan is the Schema for the nucleiscans API
properties: properties:
apiVersion: apiVersion:
description: |- description: |-
APIVersion defines the versioned schema of this representation of an object. APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values. may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string type: string
kind: kind:
description: |- description: |-
Kind is a string value representing the REST resource this object represents. Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to. Servers may infer this from the endpoint the client submits requests to.
Cannot be updated. Cannot be updated.
In CamelCase. In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string type: string
metadata: metadata:
type: object type: object
spec: spec:
description: NucleiScanSpec defines the desired state of NucleiScan description: NucleiScanSpec defines the desired state of NucleiScan
properties: properties:
scannerConfig: schedule:
description: ScannerConfig allows overriding scanner settings for description: |-
this scan Schedule for periodic rescanning in cron format
properties: If empty, scan runs once
image:
description: Image overrides the default scanner image
type: string
nodeSelector:
additionalProperties:
type: string
description: NodeSelector for scanner pod scheduling
type: object
resources:
description: Resources defines resource requirements for the scanner
pod
properties:
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:
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
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
type: object
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 <key,value,effect> using the matching operator <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
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: string
type: array severity:
sourceRef: description: Severity filters scan results by severity level
description: SourceRef references the Ingress or VirtualService being items:
scanned
properties:
apiVersion:
description: APIVersion of the source resource
type: string type: string
kind: type: array
description: Kind of the source resource - Ingress or VirtualService sourceRef:
enum: description:
- Ingress SourceRef references the Ingress or VirtualService being
- VirtualService scanned
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: properties:
lastTransitionTime: apiVersion:
description: |- description: APIVersion of the source resource
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 type: string
message: kind:
description: |- description: Kind of the source resource - Ingress or VirtualService
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: enum:
- "True" - Ingress
- "False" - VirtualService
- Unknown
type: string type: string
type: name:
description: type of condition in CamelCase or in foo.example.com/CamelCase. description: Name of the source resource
maxLength: 316 type: string
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])$ namespace:
description: Namespace of the source resource
type: string
uid:
description: UID of the source resource for owner reference
type: string type: string
required: required:
- lastTransitionTime - apiVersion
- message - kind
- reason - name
- status - namespace
- type - uid
type: object type: object
type: array suspend:
x-kubernetes-list-map-keys: description: Suspend prevents scheduled scans from running
- type type: boolean
x-kubernetes-list-type: map targets:
findings: description:
description: |- Targets is the list of URLs to scan, extracted from the
Findings contains the array of scan results from Nuclei JSONL output source resource
Each element is a parsed JSON object from Nuclei output items:
items: type: string
description: Finding represents a single Nuclei scan finding minItems: 1
properties: 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: description:
description: Description provides details about the finding Condition contains details for one aspect of the current
type: string state of this API Resource.
extractedResults: properties:
description: ExtractedResults contains any data extracted by lastTransitionTime:
the template description: |-
items: 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 type: string
type: array message:
host: description: |-
description: Host that was scanned message is a human readable message indicating details about the transition.
type: string This may be an empty string.
matchedAt: maxLength: 32768
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: string
type: array observedGeneration:
severity: description: |-
description: Severity of the finding observedGeneration represents the .metadata.generation that the condition was set based upon.
type: string For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
tags: with respect to the current state of the instance.
description: Tags associated with the finding format: int64
items: 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 type: string
type: array status:
templateId: description: status of the condition, one of True, False, Unknown.
description: TemplateID is the Nuclei template identifier enum:
type: string - "True"
templateName: - "False"
description: TemplateName is the human-readable template name - Unknown
type: string type: string
timestamp: type:
description: Timestamp when the finding was discovered description: type of condition in CamelCase or in foo.example.com/CamelCase.
format: date-time maxLength: 316
type: string 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: type: string
description: Type of the finding - http, dns, ssl, etc. required:
type: string - lastTransitionTime
required: - message
- host - reason
- severity - status
- templateId - type
- 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 type: object
targetsScanned: type: array
description: TargetsScanned is the number of targets that were x-kubernetes-list-map-keys:
scanned - type
type: integer x-kubernetes-list-type: map
totalFindings: findings:
description: TotalFindings is the total number of findings description: |-
type: integer Findings contains the array of scan results from Nuclei JSONL output
required: Each element is a parsed JSON object from Nuclei output
- targetsScanned items:
- totalFindings description: Finding represents a single Nuclei scan finding
type: object properties:
type: object description:
type: object description: Description provides details about the finding
served: true type: string
storage: true extractedResults:
subresources: description:
status: {} 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
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
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: {}

View File

@@ -70,8 +70,6 @@ spec:
value: {{ .Values.scanner.ttlAfterFinished | quote }} value: {{ .Values.scanner.ttlAfterFinished | quote }}
- name: SCANNER_SERVICE_ACCOUNT - name: SCANNER_SERVICE_ACCOUNT
value: {{ include "nuclei-operator.fullname" . }}-scanner value: {{ include "nuclei-operator.fullname" . }}-scanner
- name: OPERATOR_NAMESPACE
value: {{ .Release.Namespace | quote }}
{{- if .Values.scanner.defaultTemplates }} {{- if .Values.scanner.defaultTemplates }}
- name: DEFAULT_TEMPLATES - name: DEFAULT_TEMPLATES
value: {{ join "," .Values.scanner.defaultTemplates | quote }} value: {{ join "," .Values.scanner.defaultTemplates | quote }}

View File

@@ -241,16 +241,6 @@ func main() {
scannerServiceAccount = "nuclei-scanner" 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{} defaultTemplates := []string{}
if v := os.Getenv("DEFAULT_TEMPLATES"); v != "" { if v := os.Getenv("DEFAULT_TEMPLATES"); v != "" {
defaultTemplates = strings.Split(v, ",") defaultTemplates = strings.Split(v, ",")
@@ -269,7 +259,6 @@ func main() {
BackoffLimit: 2, BackoffLimit: 2,
MaxConcurrent: maxConcurrentScans, MaxConcurrent: maxConcurrentScans,
ServiceAccountName: scannerServiceAccount, ServiceAccountName: scannerServiceAccount,
OperatorNamespace: operatorNamespace,
DefaultResources: jobmanager.DefaultConfig().DefaultResources, DefaultResources: jobmanager.DefaultConfig().DefaultResources,
DefaultTemplates: defaultTemplates, DefaultTemplates: defaultTemplates,
DefaultSeverity: defaultSeverity, DefaultSeverity: defaultSeverity,

View File

@@ -376,10 +376,6 @@ spec:
name: name:
description: Name of the Job description: Name of the Job
type: string type: string
namespace:
description: Namespace of the Job (may differ from NucleiScan
namespace)
type: string
podName: podName:
description: PodName is the name of the scanner pod (for log retrieval) description: PodName is the name of the scanner pod (for log retrieval)
type: string type: string
@@ -392,7 +388,6 @@ spec:
type: string type: string
required: required:
- name - name
- namespace
- uid - uid
type: object type: object
lastError: lastError:

View File

@@ -257,13 +257,8 @@ func (r *NucleiScanReconciler) handleDeletion(ctx context.Context, nucleiScan *n
// Clean up any running scanner job // Clean up any running scanner job
if nucleiScan.Status.JobRef != nil { if nucleiScan.Status.JobRef != nil {
jobNamespace := nucleiScan.Status.JobRef.Namespace log.Info("Deleting scanner job", "job", nucleiScan.Status.JobRef.Name)
if jobNamespace == "" { if err := r.JobManager.DeleteJob(ctx, nucleiScan.Status.JobRef.Name, nucleiScan.Namespace); err != nil {
// 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) { if !apierrors.IsNotFound(err) {
log.Error(err, "Failed to delete scanner job", "job", nucleiScan.Status.JobRef.Name) log.Error(err, "Failed to delete scanner job", "job", nucleiScan.Status.JobRef.Name)
} }
@@ -329,7 +324,6 @@ func (r *NucleiScanReconciler) handlePendingPhase(ctx context.Context, nucleiSca
nucleiScan.Status.Phase = nucleiv1alpha1.ScanPhaseRunning nucleiScan.Status.Phase = nucleiv1alpha1.ScanPhaseRunning
nucleiScan.Status.JobRef = &nucleiv1alpha1.JobReference{ nucleiScan.Status.JobRef = &nucleiv1alpha1.JobReference{
Name: job.Name, Name: job.Name,
Namespace: job.Namespace,
UID: string(job.UID), UID: string(job.UID),
StartTime: &now, StartTime: &now,
} }
@@ -407,13 +401,8 @@ func (r *NucleiScanReconciler) handleRunningPhase(ctx context.Context, nucleiSca
return ctrl.Result{Requeue: true}, nil return ctrl.Result{Requeue: true}, nil
} }
// Get the job - use namespace from JobRef (may be different from scan namespace) // Get the job
jobNamespace := nucleiScan.Status.JobRef.Namespace job, err := r.JobManager.GetJob(ctx, nucleiScan.Status.JobRef.Name, nucleiScan.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 err != nil {
if apierrors.IsNotFound(err) { if apierrors.IsNotFound(err) {
logger.Info("Scanner job not found, resetting to Pending") logger.Info("Scanner job not found, resetting to Pending")

View File

@@ -82,9 +82,6 @@ type Config struct {
// ServiceAccountName is the service account to use for scanner pods // ServiceAccountName is the service account to use for scanner pods
ServiceAccountName string 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 are the default resource requirements for scanner pods
DefaultResources corev1.ResourceRequirements DefaultResources corev1.ResourceRequirements
@@ -104,7 +101,6 @@ func DefaultConfig() Config {
BackoffLimit: DefaultBackoffLimit, BackoffLimit: DefaultBackoffLimit,
MaxConcurrent: 5, MaxConcurrent: 5,
ServiceAccountName: "nuclei-scanner", ServiceAccountName: "nuclei-scanner",
OperatorNamespace: "nuclei-operator-system",
DefaultResources: corev1.ResourceRequirements{ DefaultResources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{ Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("100m"), corev1.ResourceCPU: resource.MustParse("100m"),
@@ -140,22 +136,14 @@ func (m *JobManager) CreateScanJob(ctx context.Context, scan *nucleiv1alpha1.Nuc
job := m.buildJob(scan) job := m.buildJob(scan)
// Only set owner reference if the job is in the same namespace as the scan // Set owner reference so the job is garbage collected when the scan is deleted
// Cross-namespace owner references are not allowed in Kubernetes if err := controllerutil.SetControllerReference(scan, job, m.Scheme); err != nil {
if job.Namespace == scan.Namespace { return nil, fmt.Errorf("failed to set controller reference: %w", err)
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", logger.Info("Creating scanner job",
"job", job.Name, "job", job.Name,
"jobNamespace", job.Namespace, "namespace", job.Namespace,
"scanNamespace", scan.Namespace,
"image", job.Spec.Template.Spec.Containers[0].Image, "image", job.Spec.Template.Spec.Containers[0].Image,
"targets", len(scan.Spec.Targets)) "targets", len(scan.Spec.Targets))
@@ -289,41 +277,14 @@ func (m *JobManager) CleanupOrphanedJobs(ctx context.Context) error {
} }
for _, job := range jobList.Items { for _, job := range jobList.Items {
// Check if the associated NucleiScan still exists using labels // Check if owner reference exists and the owner still exists
scanName := job.Labels[LabelScanName] ownerRef := metav1.GetControllerOf(&job)
scanNamespace := job.Labels[LabelScanNamespace] if ownerRef == nil {
logger.Info("Deleting orphaned job without owner", "job", job.Name, "namespace", job.Namespace)
if scanName != "" && scanNamespace != "" { if err := m.DeleteJob(ctx, job.Name, job.Namespace); err != nil && !apierrors.IsNotFound(err) {
// Try to get the associated NucleiScan logger.Error(err, "Failed to delete orphaned job", "job", job.Name)
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) // Check if the job is stuck (running longer than 2x the timeout)
@@ -344,18 +305,12 @@ func (m *JobManager) CleanupOrphanedJobs(ctx context.Context) error {
// buildJob creates a Job specification for the given NucleiScan // buildJob creates a Job specification for the given NucleiScan
func (m *JobManager) buildJob(scan *nucleiv1alpha1.NucleiScan) *batchv1.Job { func (m *JobManager) buildJob(scan *nucleiv1alpha1.NucleiScan) *batchv1.Job {
// Generate a unique job name that includes the scan namespace to avoid collisions // Generate a unique job name
jobName := fmt.Sprintf("nucleiscan-%s-%s-%d", scan.Namespace, scan.Name, time.Now().Unix()) jobName := fmt.Sprintf("nucleiscan-%s-%d", scan.Name, time.Now().Unix())
if len(jobName) > 63 { if len(jobName) > 63 {
jobName = 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 // Determine the scanner image
image := m.Config.ScannerImage image := m.Config.ScannerImage
if scan.Spec.ScannerConfig != nil && scan.Spec.ScannerConfig.Image != "" { if scan.Spec.ScannerConfig != nil && scan.Spec.ScannerConfig.Image != "" {
@@ -405,7 +360,7 @@ func (m *JobManager) buildJob(scan *nucleiv1alpha1.NucleiScan) *batchv1.Job {
job := &batchv1.Job{ job := &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: jobName, Name: jobName,
Namespace: jobNamespace, Namespace: scan.Namespace,
Labels: labels, Labels: labels,
}, },
Spec: batchv1.JobSpec{ Spec: batchv1.JobSpec{
@@ -460,14 +415,6 @@ func (m *JobManager) buildJob(scan *nucleiv1alpha1.NucleiScan) *batchv1.Job {
}, },
}, },
}, },
{
Name: "NUCLEI_BINARY_PATH",
Value: "/usr/local/bin/nuclei",
},
{
Name: "NUCLEI_TEMPLATES_PATH",
Value: "", // Empty means use default location (~/.nuclei/templates)
},
}, },
}, },
}, },

View File

@@ -43,22 +43,14 @@ func TestBuildJob(t *testing.T) {
job := manager.buildJob(scan) job := manager.buildJob(scan)
// Verify job name prefix - should include scan namespace to avoid collisions // Verify job name prefix
if len(job.Name) == 0 { if len(job.Name) == 0 {
t.Error("Job name should not be empty") t.Error("Job name should not be empty")
} }
// Verify namespace - job should be created in operator namespace // Verify namespace
if job.Namespace != config.OperatorNamespace { if job.Namespace != "default" {
t.Errorf("Expected namespace '%s', got '%s'", config.OperatorNamespace, job.Namespace) t.Errorf("Expected namespace 'default', got '%s'", 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 // Verify labels
@@ -123,29 +115,3 @@ func TestBuildJobWithCustomConfig(t *testing.T) {
t.Errorf("Expected deadline %d, got %d", expectedDeadline, *job.Spec.ActiveDeadlineSeconds) 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)
}
}

View File

@@ -118,11 +118,8 @@ func (r *Runner) Run(ctx context.Context) error {
logger.Info("Starting scan", logger.Info("Starting scan",
"targets", len(scan.Spec.Targets), "targets", len(scan.Spec.Targets),
"targetList", scan.Spec.Targets,
"templates", scan.Spec.Templates, "templates", scan.Spec.Templates,
"templatesCount", len(scan.Spec.Templates), "severity", scan.Spec.Severity)
"severity", scan.Spec.Severity,
"severityCount", len(scan.Spec.Severity))
// Update status to indicate scan has started // Update status to indicate scan has started
startTime := metav1.Now() startTime := metav1.Now()

View File

@@ -26,8 +26,6 @@ import (
"strings" "strings"
"time" "time"
"sigs.k8s.io/controller-runtime/pkg/log"
nucleiv1alpha1 "github.com/mortenolsen/nuclei-operator/api/v1alpha1" nucleiv1alpha1 "github.com/mortenolsen/nuclei-operator/api/v1alpha1"
) )
@@ -97,8 +95,6 @@ func NewNucleiScannerWithDefaults() *NucleiScanner {
// Scan executes a Nuclei scan against the given targets // Scan executes a Nuclei scan against the given targets
func (s *NucleiScanner) Scan(ctx context.Context, targets []string, options ScanOptions) (*ScanResult, error) { func (s *NucleiScanner) Scan(ctx context.Context, targets []string, options ScanOptions) (*ScanResult, error) {
logger := log.FromContext(ctx).WithName("nuclei-scanner")
if len(targets) == 0 { if len(targets) == 0 {
return nil, fmt.Errorf("no targets provided for scan") return nil, fmt.Errorf("no targets provided for scan")
} }
@@ -114,66 +110,10 @@ func (s *NucleiScanner) Scan(ctx context.Context, targets []string, options Scan
// Write targets to a file // Write targets to a file
targetsFile := filepath.Join(tmpDir, "targets.txt") targetsFile := filepath.Join(tmpDir, "targets.txt")
targetsContent := strings.Join(targets, "\n") if err := os.WriteFile(targetsFile, []byte(strings.Join(targets, "\n")), 0600); err != nil {
if err := os.WriteFile(targetsFile, []byte(targetsContent), 0600); err != nil {
return nil, fmt.Errorf("failed to write targets file: %w", err) return nil, fmt.Errorf("failed to write targets file: %w", err)
} }
logger.Info("Targets file created", "targetsFile", targetsFile, "targetCount", len(targets))
// Check if nuclei binary exists and is executable
// First try the exact path
if _, err := os.Stat(s.nucleiBinaryPath); os.IsNotExist(err) {
// If not found at exact path, try to find it in PATH
if path, err := exec.LookPath(s.nucleiBinaryPath); err == nil {
logger.Info("Found nuclei binary in PATH", "path", path, "originalPath", s.nucleiBinaryPath)
s.nucleiBinaryPath = path
} else {
return nil, fmt.Errorf("nuclei binary not found at %s and not in PATH: %w", s.nucleiBinaryPath, err)
}
}
// Verify nuclei is executable by running version command
if err := exec.Command(s.nucleiBinaryPath, "-version").Run(); err != nil {
logger.Error(err, "Failed to execute nuclei -version, nuclei may not be properly installed", "path", s.nucleiBinaryPath)
// Don't fail here, just log - the actual scan will fail if nuclei is truly broken
} else {
logger.Info("Nuclei binary verified", "path", s.nucleiBinaryPath)
}
// Check templates availability if templates path is set
templatesAvailable := false
if s.templatesPath != "" {
if info, err := os.Stat(s.templatesPath); err != nil || !info.IsDir() {
logger.Info("Templates path does not exist or is not a directory, nuclei will use default templates",
"templatesPath", s.templatesPath,
"error", err)
} else {
// Count template files
entries, err := os.ReadDir(s.templatesPath)
if err == nil {
templateCount := 0
for _, entry := range entries {
if !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".yaml") || strings.HasSuffix(entry.Name(), ".yml")) {
templateCount++
}
}
templatesAvailable = templateCount > 0
logger.Info("Templates directory found", "templatesPath", s.templatesPath, "templateCount", templateCount)
if templateCount == 0 {
logger.Info("Templates directory is empty, nuclei will download templates on first run or use default location")
}
}
}
} else {
logger.Info("No templates path configured, nuclei will use default template location (~/.nuclei/templates)")
}
// If no specific templates are provided and templates path is empty, warn
if len(options.Templates) == 0 && !templatesAvailable && s.templatesPath != "" {
logger.Info("Warning: No templates specified and templates directory appears empty. Nuclei may not run any scans.")
}
// Build the nuclei command arguments // Build the nuclei command arguments
args := s.buildArgs(targetsFile, options) args := s.buildArgs(targetsFile, options)
@@ -183,16 +123,6 @@ func (s *NucleiScanner) Scan(ctx context.Context, targets []string, options Scan
timeout = 30 * time.Minute timeout = 30 * time.Minute
} }
// Log the command being executed
fullCommand := fmt.Sprintf("%s %s", s.nucleiBinaryPath, strings.Join(args, " "))
logger.Info("Executing nuclei scan",
"command", fullCommand,
"timeout", timeout,
"templates", len(options.Templates),
"templatesList", options.Templates,
"severity", options.Severity,
"templatesPath", s.templatesPath)
// Create context with timeout // Create context with timeout
scanCtx, cancel := context.WithTimeout(ctx, timeout) scanCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
@@ -204,31 +134,14 @@ func (s *NucleiScanner) Scan(ctx context.Context, targets []string, options Scan
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
logger.Info("Starting nuclei execution")
err = cmd.Run() err = cmd.Run()
duration := time.Since(startTime) duration := time.Since(startTime)
// Log stderr output (nuclei often outputs warnings/info to stderr)
stderrStr := stderr.String()
if stderrStr != "" {
logger.Info("Nuclei stderr output", "stderr", stderrStr)
}
// Log stdout size for debugging
stdoutSize := len(stdout.Bytes())
logger.Info("Nuclei execution completed",
"duration", duration,
"exitCode", cmd.ProcessState.ExitCode(),
"stdoutSize", stdoutSize,
"stderrSize", len(stderrStr))
// Check for context cancellation // Check for context cancellation
if scanCtx.Err() == context.DeadlineExceeded { if scanCtx.Err() == context.DeadlineExceeded {
logger.Error(nil, "Scan timed out", "timeout", timeout, "stderr", stderrStr)
return nil, fmt.Errorf("scan timed out after %v", timeout) return nil, fmt.Errorf("scan timed out after %v", timeout)
} }
if scanCtx.Err() == context.Canceled { if scanCtx.Err() == context.Canceled {
logger.Error(nil, "Scan was cancelled", "stderr", stderrStr)
return nil, fmt.Errorf("scan was cancelled") return nil, fmt.Errorf("scan was cancelled")
} }
@@ -238,40 +151,22 @@ func (s *NucleiScanner) Scan(ctx context.Context, targets []string, options Scan
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
// Exit code 1 can mean "no results found" which is not an error // Exit code 1 can mean "no results found" which is not an error
if exitErr.ExitCode() != 1 { if exitErr.ExitCode() != 1 {
logger.Error(err, "Nuclei execution failed", return nil, fmt.Errorf("nuclei execution failed: %w, stderr: %s", err, stderr.String())
"exitCode", exitErr.ExitCode(),
"stderr", stderrStr,
"stdout", stdout.String())
return nil, fmt.Errorf("nuclei execution failed: %w, stderr: %s", err, stderrStr)
} }
logger.Info("Nuclei exited with code 1 (no results found)", "stderr", stderrStr)
} else { } else {
logger.Error(err, "Failed to execute nuclei", "stderr", stderrStr)
return nil, fmt.Errorf("failed to execute nuclei: %w", err) return nil, fmt.Errorf("failed to execute nuclei: %w", err)
} }
} }
// Parse the JSONL output // Parse the JSONL output
stdoutBytes := stdout.Bytes() findings, err := ParseJSONLOutput(stdout.Bytes())
logger.Info("Parsing nuclei output", "outputSize", len(stdoutBytes))
findings, err := ParseJSONLOutput(stdoutBytes)
if err != nil { if err != nil {
logger.Error(err, "Failed to parse nuclei output",
"stdout", string(stdoutBytes),
"stderr", stderrStr)
return nil, fmt.Errorf("failed to parse nuclei output: %w", err) return nil, fmt.Errorf("failed to parse nuclei output: %w", err)
} }
logger.Info("Parsed findings", "count", len(findings))
// Calculate summary // Calculate summary
summary := calculateSummary(findings, len(targets), duration) summary := calculateSummary(findings, len(targets), duration)
logger.Info("Scan completed",
"findings", len(findings),
"duration", duration,
"targetsScanned", len(targets))
return &ScanResult{ return &ScanResult{
Findings: findings, Findings: findings,
Summary: summary, Summary: summary,
@@ -286,8 +181,11 @@ func (s *NucleiScanner) buildArgs(targetsFile string, options ScanOptions) []str
"-jsonl", "-jsonl",
"-silent", "-silent",
"-no-color", "-no-color",
"-rate-limit", "150", // Limit rate to avoid overwhelming targets }
"-bulk-size", "25", // Process targets in bulk
// Add templates path if configured
if s.templatesPath != "" {
args = append(args, "-t", s.templatesPath)
} }
// Add specific templates if provided // Add specific templates if provided
@@ -295,31 +193,6 @@ func (s *NucleiScanner) buildArgs(targetsFile string, options ScanOptions) []str
for _, t := range options.Templates { for _, t := range options.Templates {
args = append(args, "-t", t) args = append(args, "-t", t)
} }
} else {
// When no templates are specified, nuclei should use all available templates
// Only add templates path if it's configured AND contains templates
// Otherwise, let nuclei use its default template location (~/.nuclei/templates)
if s.templatesPath != "" {
// Check if templates directory exists and has content
if info, err := os.Stat(s.templatesPath); err == nil && info.IsDir() {
entries, err := os.ReadDir(s.templatesPath)
if err == nil {
hasTemplates := false
for _, entry := range entries {
if !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".yaml") || strings.HasSuffix(entry.Name(), ".yml")) {
hasTemplates = true
break
}
}
if hasTemplates {
args = append(args, "-t", s.templatesPath)
}
// If no templates found, don't add -t flag, let nuclei use default location
}
}
}
// If no templates path or it's empty, nuclei will use default location
// which it will download templates to on first run if needed
} }
// Add severity filter if provided // Add severity filter if provided